diff --git a/.github/workflows/build-kwctl.yml b/.github/workflows/build-kwctl.yml index dd298579d..20d67dca0 100644 --- a/.github/workflows/build-kwctl.yml +++ b/.github/workflows/build-kwctl.yml @@ -88,6 +88,7 @@ jobs: permissions: id-token: write attestations: write + contents: read steps: - uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 if: ${{ !inputs.build_only }} @@ -197,6 +198,7 @@ jobs: permissions: id-token: write attestations: write + contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -286,6 +288,7 @@ jobs: permissions: id-token: write attestations: write + contents: read steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/Cargo.lock b/Cargo.lock index a719adbfe..4e9b9584f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3589,6 +3589,7 @@ dependencies = [ "itertools 0.14.0", "k8s-openapi", "lazy_static", + "memchr", "pem", "policy-evaluator", "predicates", @@ -3617,6 +3618,7 @@ dependencies = [ "tracing-subscriber", "url", "walrus", + "wasm-encoder 0.227.1", ] [[package]] @@ -4708,7 +4710,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "policy-evaluator" -version = "0.31.0" +version = "0.32.0" dependencies = [ "anyhow", "assert-json-diff", @@ -7828,6 +7830,16 @@ dependencies = [ "wat", ] +[[package]] +name = "wasm-encoder" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bb72f02e7fbf07183443b27b0f3d4144abf8c114189f2e088ed95b696a7822" +dependencies = [ + "leb128fmt", + "wasmparser 0.227.1", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -7906,6 +7918,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.227.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f51cad774fb3c9461ab9bccc9c62dfb7388397b5deda31bf40e8108ccd678b2" +dependencies = [ + "bitflags 2.11.1", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "wasmparser" version = "0.244.0" diff --git a/api/policies/v1/factories.go b/api/policies/v1/factories.go index 45fee833b..1aff49213 100644 --- a/api/policies/v1/factories.go +++ b/api/policies/v1/factories.go @@ -479,13 +479,14 @@ func (f *ClusterAdmissionPolicyGroupFactory) Build() *ClusterAdmissionPolicyGrou } type PolicyServerBuilder struct { - name string - minAvailable *intstr.IntOrString - maxUnavailable *intstr.IntOrString - imagePullSecret string - limits corev1.ResourceList - requests corev1.ResourceList - sigstoreTrustConfigMap string + name string + minAvailable *intstr.IntOrString + maxUnavailable *intstr.IntOrString + imagePullSecret string + limits corev1.ResourceList + requests corev1.ResourceList + sigstoreTrustConfigMap string + namespacedPoliciesCapabilities []string } func NewPolicyServerFactory() *PolicyServerBuilder { @@ -529,6 +530,11 @@ func (f *PolicyServerBuilder) WithRequests(requests corev1.ResourceList) *Policy return f } +func (f *PolicyServerBuilder) WithNamespacedPoliciesCapabilities(capabilities []string) *PolicyServerBuilder { + f.namespacedPoliciesCapabilities = capabilities + return f +} + func (f *PolicyServerBuilder) Build() *PolicyServer { policyServer := PolicyServer{ ObjectMeta: metav1.ObjectMeta{ @@ -544,14 +550,15 @@ func (f *PolicyServerBuilder) Build() *PolicyServer { }, }, Spec: PolicyServerSpec{ - Image: policyServerRepository() + ":" + policyServerVersion(), - Replicas: 1, - MinAvailable: f.minAvailable, - MaxUnavailable: f.maxUnavailable, - ImagePullSecret: f.imagePullSecret, - Limits: f.limits, - Requests: f.requests, - SigstoreTrustConfig: f.sigstoreTrustConfigMap, + Image: policyServerRepository() + ":" + policyServerVersion(), + Replicas: 1, + MinAvailable: f.minAvailable, + MaxUnavailable: f.maxUnavailable, + ImagePullSecret: f.imagePullSecret, + Limits: f.limits, + Requests: f.requests, + SigstoreTrustConfig: f.sigstoreTrustConfigMap, + NamespacedPoliciesCapabilities: f.namespacedPoliciesCapabilities, Env: []corev1.EnvVar{ { Name: "KUBEWARDEN_LOG_LEVEL", diff --git a/api/policies/v1/policyserver_types.go b/api/policies/v1/policyserver_types.go index 9c4f02aa5..9f51172e8 100644 --- a/api/policies/v1/policyserver_types.go +++ b/api/policies/v1/policyserver_types.go @@ -139,6 +139,17 @@ type PolicyServerSpec struct { // remain unchanged, but new pods that reference it cannot be created. // +optional PriorityClassName string `json:"priorityClassName,omitempty"` + + // NamespacedPoliciesCapabilities lists host capability API calls allowed + // for namespaced policies running on this PolicyServer. When not set, + // no host capabilities are granted to namespaced policies. + // Supported wildcard patterns: + // - "*": allow all host capabilities + // - "category/*": allow all capabilities in a category (e.g. "oci/*") + // - "category/version/*": allow all capabilities of a specific version (e.g. "oci/v1/*") + // - Specific capability paths (e.g. "oci/v1/verify", "net/v1/dns_lookup_host") + // +optional + NamespacedPoliciesCapabilities []string `json:"namespacedPoliciesCapabilities,omitempty"` } type ReconciliationTransitionReason string diff --git a/api/policies/v1/policyserver_webhook.go b/api/policies/v1/policyserver_webhook.go index 794fbd0b0..0b7cb63e2 100644 --- a/api/policies/v1/policyserver_webhook.go +++ b/api/policies/v1/policyserver_webhook.go @@ -19,6 +19,9 @@ package v1 import ( "context" "fmt" + "maps" + "slices" + "strings" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -35,6 +38,46 @@ import ( "github.com/kubewarden/kubewarden-controller/internal/constants" ) +// capabilityNode is a node in the host-capability path tree. +// Leaf nodes (complete, addressable operations) have a nil value. +// Intermediate nodes carry a non-nil map of named children. +type capabilityNode map[string]capabilityNode + +// capabilityTree is the authoritative tree of all recognised host capability +// paths. It mirrors the namespaces and operations handled by the policy-server +// callback (crates/policy-server/src/evaluation/callback.rs). +// +//nolint:gochecknoglobals // effectively a constant, not used anywhere else +var capabilityTree = capabilityNode{ + "oci": { + "v1": { + "verify": nil, + "manifest_digest": nil, + "oci_manifest": nil, + "oci_manifest_config": nil, + }, + "v2": { + "verify": nil, + }, + }, + "net": { + "v1": { + "dns_lookup_host": nil, + }, + }, + "crypto": { + "v1": { + "is_certificate_trusted": nil, + }, + }, + "kubernetes": { + "list_resources_by_namespace": nil, + "list_resources_all": nil, + "get_resource": nil, + "can_i": nil, + }, +} + // SetupWebhookWithManager registers the PolicyServer webhook with the controller manager. func (ps *PolicyServer) SetupWebhookWithManager(mgr ctrl.Manager, deploymentsNamespace string) error { logger := mgr.GetLogger().WithName("policyserver-webhook") @@ -131,6 +174,7 @@ func (v *policyServerValidator) validate(ctx context.Context, policyServer *Poli } allErrs = append(allErrs, validateLimitsAndRequests(policyServer.Spec.Limits, policyServer.Spec.Requests)...) + allErrs = append(allErrs, validateNamespacedPoliciesCapabilities(policyServer.Spec.NamespacedPoliciesCapabilities)...) if len(allErrs) == 0 { return nil @@ -208,3 +252,78 @@ func validateLimitsAndRequests(limits, requests corev1.ResourceList) field.Error return allErrs } + +// validateNamespacedPoliciesCapabilities validates each capability pattern +// against the authoritative capability tree. +// +// Valid formats: +// - "*" (allow all capabilities) +// - "category/*" (e.g. "oci/*", "kubernetes/*") +// - "category/sub/*" (e.g. "oci/v1/*") +// - full path (e.g. "oci/v1/verify", "kubernetes/can_i") +// +// Every segment is validated against the tree, so unknown categories, +// unknown versions, and unknown operations are all rejected with an error +// listing the valid options at that level. +func validateNamespacedPoliciesCapabilities(capabilities []string) field.ErrorList { + var allErrs field.ErrorList + fieldPath := field.NewPath("spec").Child("namespacedPoliciesCapabilities") + + for i, pattern := range capabilities { + if err := validateSingleCapability(pattern, fieldPath.Index(i)); err != nil { + allErrs = append(allErrs, err) + } + } + + return allErrs +} + +// validateSingleCapability validates one capability pattern against the capability tree. +func validateSingleCapability(pattern string, path *field.Path) *field.Error { + if pattern == "" { + return field.Invalid(path, pattern, "capability must not be empty") + } + if pattern == "*" { + return nil + } + + parts := strings.Split(pattern, "/") + node := capabilityTree + + for i, part := range parts { + // Wildcard handling: "*" is only valid as the final segment. + if strings.Contains(part, "*") { + if part != "*" || i != len(parts)-1 { + return field.Invalid(path, pattern, + "wildcard \"*\" is only allowed as the last path segment (e.g. \"oci/*\" or \"oci/v1/*\")") + } + // Valid wildcard termination; parent node is already confirmed. + return nil + } + + child, found := node[part] + if !found { + return field.Invalid(path, pattern, + fmt.Sprintf("unknown segment %q, valid options at this level are: %s", + part, strings.Join(slices.Sorted(maps.Keys(node)), ", "))) + } + + if child == nil { + // Leaf reached, path must end here. + if i != len(parts)-1 { + return field.Invalid(path, pattern, + fmt.Sprintf("%q is a complete capability path and cannot have further segments", + strings.Join(parts[:i+1], "/"))) + } + return nil + } + + node = child + } + + // Consumed all parts but stopped at an intermediate node: the path is + // incomplete. Guide the user toward the wildcard form. + return field.Invalid(path, pattern, + fmt.Sprintf("%q is not a complete capability path; use %q to allow all capabilities under it", + pattern, pattern+"/*")) +} diff --git a/api/policies/v1/policyserver_webhook_test.go b/api/policies/v1/policyserver_webhook_test.go index b94747b2e..26374c1ad 100644 --- a/api/policies/v1/policyserver_webhook_test.go +++ b/api/policies/v1/policyserver_webhook_test.go @@ -279,3 +279,136 @@ func TestPolicyServerValidateSigstoreTrustConfig(t *testing.T) { }) } } + +func TestPolicyServerValidateNamespacedPoliciesCapabilities(t *testing.T) { + tests := []struct { + name string + capabilities []string + valid bool + error string + }{ + { + name: "nil capabilities (not set)", + capabilities: nil, + valid: true, + }, + { + name: "empty capabilities", + capabilities: []string{}, + valid: true, + }, + { + name: "wildcard all", + capabilities: []string{"*"}, + valid: true, + }, + { + name: "category wildcard", + capabilities: []string{"oci/*"}, + valid: true, + }, + { + name: "versioned category wildcard", + capabilities: []string{"oci/v1/*"}, + valid: true, + }, + { + name: "specific capability", + capabilities: []string{"oci/v1/verify"}, + valid: true, + }, + { + name: "multiple valid capabilities", + capabilities: []string{"oci/*", "net/v1/dns_lookup_host", "crypto/v1/is_certificate_trusted"}, + valid: true, + }, + { + name: "all known categories", + capabilities: []string{"oci/*", "kubernetes/*", "net/*", "crypto/*"}, + valid: true, + }, + { + name: "empty string", + capabilities: []string{""}, + valid: false, + error: "capability must not be empty", + }, + { + name: "invalid category", + capabilities: []string{"unknown/v1/foo"}, + valid: false, + error: "unknown segment", + }, + { + name: "invalid wildcard at category level", + capabilities: []string{"oci*"}, + valid: false, + error: "wildcard \"*\" is only allowed as the last path segment", + }, + { + name: "invalid wildcard in middle", + capabilities: []string{"oci/*/verify"}, + valid: false, + error: "wildcard \"*\" is only allowed as the last path segment", + }, + { + name: "invalid partial wildcard", + capabilities: []string{"oci/v1/oci_*"}, + valid: false, + error: "wildcard \"*\" is only allowed as the last path segment", + }, + { + name: "unknown version segment", + capabilities: []string{"oci/v3/verify"}, + valid: false, + error: "unknown segment \"v3\"", + }, + { + name: "unknown operation", + capabilities: []string{"oci/v1/unknown_op"}, + valid: false, + error: "unknown segment \"unknown_op\"", + }, + { + name: "incomplete path without wildcard", + capabilities: []string{"oci/v1"}, + valid: false, + error: "not a complete capability path", + }, + { + name: "kubernetes with spurious version segment", + capabilities: []string{"kubernetes/v1/can_i"}, + valid: false, + error: "unknown segment \"v1\"", + }, + { + name: "valid kubernetes operation", + capabilities: []string{"kubernetes/can_i"}, + valid: true, + }, + { + name: "valid kubernetes operation", + capabilities: []string{"kubernetes/can_i/have_dessert"}, + valid: false, + error: "is a complete capability path and cannot have further segments", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policyServer := NewPolicyServerFactory(). + WithNamespacedPoliciesCapabilities(test.capabilities). + Build() + + policyServerValidator := policyServerValidator{logger: logr.Discard()} + err := policyServerValidator.validate(t.Context(), policyServer) + + if test.valid { + require.NoError(t, err) + } else { + require.Error(t, err) + require.ErrorContains(t, err, test.error) + } + }) + } +} diff --git a/api/policies/v1/zz_generated.deepcopy.go b/api/policies/v1/zz_generated.deepcopy.go index ce3b73de9..121639f0f 100644 --- a/api/policies/v1/zz_generated.deepcopy.go +++ b/api/policies/v1/zz_generated.deepcopy.go @@ -720,6 +720,11 @@ func (in *PolicyServerBuilder) DeepCopyInto(out *PolicyServerBuilder) { (*out)[key] = val.DeepCopy() } } + if in.namespacedPoliciesCapabilities != nil { + in, out := &in.namespacedPoliciesCapabilities, &out.namespacedPoliciesCapabilities + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyServerBuilder. @@ -860,6 +865,11 @@ func (in *PolicyServerSpec) DeepCopyInto(out *PolicyServerSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.NamespacedPoliciesCapabilities != nil { + in, out := &in.NamespacedPoliciesCapabilities, &out.NamespacedPoliciesCapabilities + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyServerSpec. diff --git a/charts/kubewarden-crds/templates/policies.kubewarden.io_policyservers.yaml b/charts/kubewarden-crds/templates/policies.kubewarden.io_policyservers.yaml index c694e39b6..d81067277 100644 --- a/charts/kubewarden-crds/templates/policies.kubewarden.io_policyservers.yaml +++ b/charts/kubewarden-crds/templates/policies.kubewarden.io_policyservers.yaml @@ -1173,6 +1173,19 @@ spec: eviction. The value can be an absolute number or a percentage. Only one of MinAvailable or Max MaxUnavailable can be set. x-kubernetes-int-or-string: true + namespacedPoliciesCapabilities: + description: |- + NamespacedPoliciesCapabilities lists host capability API calls allowed + for namespaced policies running on this PolicyServer. When not set, + no host capabilities are granted to namespaced policies. + Supported wildcard patterns: + - "*": allow all host capabilities + - "category/*": allow all capabilities in a category (e.g. "oci/*") + - "category/version/*": allow all capabilities of a specific version (e.g. "oci/v1/*") + - Specific capability paths (e.g. "oci/v1/verify", "net/v1/dns_lookup_host") + items: + type: string + type: array priorityClassName: description: |- PriorityClassName is the name of the PriorityClass to be used for the diff --git a/charts/kubewarden-defaults/templates/policyserver-default.yaml b/charts/kubewarden-defaults/templates/policyserver-default.yaml index 9c375e6ba..a989224e8 100644 --- a/charts/kubewarden-defaults/templates/policyserver-default.yaml +++ b/charts/kubewarden-defaults/templates/policyserver-default.yaml @@ -74,6 +74,12 @@ spec: {{- end }} {{- end }} {{- end }} + {{- if .Values.policyServer.namespacedPoliciesCapabilities }} + namespacedPoliciesCapabilities: + {{- range .Values.policyServer.namespacedPoliciesCapabilities }} + - {{ . | quote }} + {{- end }} + {{- end }} {{- if .Values.policyServer.securityContexts }} securityContexts: {{ toYaml .Values.policyServer.securityContexts | nindent 4 }} {{- end }} diff --git a/charts/kubewarden-defaults/tests/namespacedPoliciesCapabilities_test.yaml b/charts/kubewarden-defaults/tests/namespacedPoliciesCapabilities_test.yaml new file mode 100644 index 000000000..3a76706e5 --- /dev/null +++ b/charts/kubewarden-defaults/tests/namespacedPoliciesCapabilities_test.yaml @@ -0,0 +1,133 @@ +suite: namespacedPoliciesCapabilities schema validation +templates: + - policyserver-default.yaml +tests: + # --- Valid inputs --- + - it: "should accept the global wildcard (*)" + set: + policyServer.namespacedPoliciesCapabilities: + - "*" + asserts: + - notFailedTemplate: {} + + - it: "should accept a category wildcard (oci/*)" + set: + policyServer.namespacedPoliciesCapabilities: + - "oci/*" + asserts: + - notFailedTemplate: {} + + - it: "should accept a sub-category wildcard (oci/v1/*)" + set: + policyServer.namespacedPoliciesCapabilities: + - "oci/v1/*" + asserts: + - notFailedTemplate: {} + + - it: "should accept a fully-qualified path (oci/v1/bla)" + set: + policyServer.namespacedPoliciesCapabilities: + - "oci/v1/bla" + asserts: + - notFailedTemplate: {} + + - it: "should accept the exact values from the bug report (oci/v1/bla and oci/*)" + set: + policyServer.namespacedPoliciesCapabilities: + - "oci/v1/bla" + - "oci/*" + asserts: + - notFailedTemplate: {} + + - it: "should accept hyphens and underscores in path segments (my-cat/sub_path/leaf-node)" + set: + policyServer.namespacedPoliciesCapabilities: + - "my-cat/sub_path/leaf-node" + asserts: + - notFailedTemplate: {} + + - it: "should accept a deeply nested sub-category wildcard (oci/v1/sub/*)" + set: + policyServer.namespacedPoliciesCapabilities: + - "oci/v1/sub/*" + asserts: + - notFailedTemplate: {} + + - it: "should accept null (feature disabled)" + set: + policyServer.namespacedPoliciesCapabilities: null + asserts: + - notFailedTemplate: {} + + - it: "should accept an empty array" + set: + policyServer.namespacedPoliciesCapabilities: [] + asserts: + - notFailedTemplate: {} + + - it: "should accept a mix of all valid forms" + set: + policyServer.namespacedPoliciesCapabilities: + - "*" + - "oci/*" + - "oci/v1/*" + - "oci/v1/bla" + asserts: + - notFailedTemplate: {} + + # --- Invalid inputs --- + - it: "should reject a path with a leading slash (/oci/v1/bla)" + set: + policyServer.namespacedPoliciesCapabilities: + - "/oci/v1/bla" + asserts: + - failedTemplate: + errorPattern: "namespacedPoliciesCapabilities" + + - it: "should reject a single segment with no slash (oci)" + set: + policyServer.namespacedPoliciesCapabilities: + - "oci" + asserts: + - failedTemplate: + errorPattern: "namespacedPoliciesCapabilities" + + - it: "should reject uppercase letters in segments (OCI/v1/bla)" + set: + policyServer.namespacedPoliciesCapabilities: + - "OCI/v1/bla" + asserts: + - failedTemplate: + errorPattern: "namespacedPoliciesCapabilities" + + - it: "should reject dots in segments (oci/v1.0/bla)" + set: + policyServer.namespacedPoliciesCapabilities: + - "oci/v1.0/bla" + asserts: + - failedTemplate: + errorPattern: "namespacedPoliciesCapabilities" + + - it: "should reject an empty string entry" + set: + policyServer.namespacedPoliciesCapabilities: + - "" + asserts: + - failedTemplate: + errorPattern: "namespacedPoliciesCapabilities" + + - it: "should reject a trailing slash (oci/v1/)" + set: + policyServer.namespacedPoliciesCapabilities: + - "oci/v1/" + asserts: + - failedTemplate: + errorPattern: "namespacedPoliciesCapabilities" + + - it: "should reject a bare slash (/)" + set: + policyServer.namespacedPoliciesCapabilities: + - "/" + asserts: + - failedTemplate: + errorPattern: "namespacedPoliciesCapabilities" diff --git a/charts/kubewarden-defaults/values.schema.json b/charts/kubewarden-defaults/values.schema.json index 22d32f5fd..7d01ae11c 100644 --- a/charts/kubewarden-defaults/values.schema.json +++ b/charts/kubewarden-defaults/values.schema.json @@ -12,16 +12,49 @@ "minAvailable": { "type": "string", "minLength": 1 + }, + "namespacedPoliciesCapabilities": { + "description": "Host capability paths allowed for namespaced policies on this PolicyServer. Catch obvious errors", + "type": [ + "array", + "null" + ], + "items": { + "type": "string", + "minLength": 1, + "anyOf": [ + { + "description": "Global wildcard", + "const": "*" + }, + { + "description": "Category wildcard: /*", + "pattern": "^[a-z0-9_-]+/\\*$" + }, + { + "description": "Sub-category wildcard: //*", + "pattern": "^[a-z0-9_-]+(/[a-z0-9_-]+)+/\\*$" + }, + { + "description": "Fully-qualified capability path: /", + "pattern": "^[a-z0-9_-]+(/[a-z0-9_-]+)+$" + } + ] + } } }, "anyOf": [ { "oneOf": [ { - "required": ["minAvailable"] + "required": [ + "minAvailable" + ] }, { - "required": ["maxUnavailable"] + "required": [ + "maxUnavailable" + ] } ] }, @@ -29,10 +62,14 @@ "not": { "allOf": [ { - "required": ["minAvailable"] + "required": [ + "minAvailable" + ] }, { - "required": ["maxUnavailable"] + "required": [ + "maxUnavailable" + ] } ] } diff --git a/charts/kubewarden-defaults/values.yaml b/charts/kubewarden-defaults/values.yaml index f440c4130..a4ce46947 100644 --- a/charts/kubewarden-defaults/values.yaml +++ b/charts/kubewarden-defaults/values.yaml @@ -183,6 +183,16 @@ policyServer: # certs: # - "cert4" sourceAuthorities: {} + # namespacedPoliciesCapabilities lists host capability API calls allowed + # for namespaced policies running on this PolicyServer. + # Supported patterns: [] (none), "*" (all), "category/*", + # "category/version/*", or specific paths. + # Example: + # namespacedPoliciesCapabilities: + # - "oci/*" + # - "net/v1/dns_lookup_host" + namespacedPoliciesCapabilities: + - "*" # all host capabilities are granted # limits and requests, see https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ limits: {} requests: {} diff --git a/config/crd/bases/policies.kubewarden.io_policyservers.yaml b/config/crd/bases/policies.kubewarden.io_policyservers.yaml index 5dd60a5cf..d62c51179 100644 --- a/config/crd/bases/policies.kubewarden.io_policyservers.yaml +++ b/config/crd/bases/policies.kubewarden.io_policyservers.yaml @@ -1173,6 +1173,19 @@ spec: eviction. The value can be an absolute number or a percentage. Only one of MinAvailable or Max MaxUnavailable can be set. x-kubernetes-int-or-string: true + namespacedPoliciesCapabilities: + description: |- + NamespacedPoliciesCapabilities lists host capability API calls allowed + for namespaced policies running on this PolicyServer. When not set, + no host capabilities are granted to namespaced policies. + Supported wildcard patterns: + - "*": allow all host capabilities + - "category/*": allow all capabilities in a category (e.g. "oci/*") + - "category/version/*": allow all capabilities of a specific version (e.g. "oci/v1/*") + - Specific capability paths (e.g. "oci/v1/verify", "net/v1/dns_lookup_host") + items: + type: string + type: array priorityClassName: description: |- PriorityClassName is the name of the PriorityClass to be used for the diff --git a/crates/burrego/src/evaluator.rs b/crates/burrego/src/evaluator.rs index 597270282..1878f1750 100644 --- a/crates/burrego/src/evaluator.rs +++ b/crates/burrego/src/evaluator.rs @@ -1,15 +1,18 @@ -use crate::builtins; -use crate::errors::{BurregoError, Result}; -use crate::host_callbacks::HostCallbacks; -use crate::opa_host_functions; -use crate::policy::Policy; -use crate::stack_helper::StackHelper; +use std::collections::{HashMap, HashSet}; use itertools::Itertools; -use std::collections::{HashMap, HashSet}; use tracing::debug; use wasmtime::{Engine, Instance, Linker, Memory, MemoryType, Module, Store}; +use crate::{ + builtins, + errors::{BurregoError, Result}, + host_callbacks::HostCallbacks, + opa_host_functions, + policy::Policy, + stack_helper::StackHelper, +}; + macro_rules! set_epoch_deadline_and_call_guest { ($epoch_deadline:expr, $store:expr, $code:block) => {{ if let Some(deadline) = $epoch_deadline { diff --git a/crates/context-aware-test-policy/metadata.yml b/crates/context-aware-test-policy/metadata.yml index b569e6737..b18b5358a 100644 --- a/crates/context-aware-test-policy/metadata.yml +++ b/crates/context-aware-test-policy/metadata.yml @@ -12,6 +12,10 @@ contextAwareResources: - apiVersion: v1 kind: Service executionMode: kubewarden-wapc +hostCapabilities: + - kubernetes/list_resources_by_namespace + - kubernetes/list_resources_all + - kubernetes/get_resource # Consider the policy for the background audit scans. Default is true. Note the # intrinsic limitations of the background audit feature on docs.kubewarden.io; # If your policy hits any limitations, set to false for the audit feature to diff --git a/crates/kwctl/Cargo.toml b/crates/kwctl/Cargo.toml index 4a4f1d904..567e55f41 100644 --- a/crates/kwctl/Cargo.toml +++ b/crates/kwctl/Cargo.toml @@ -22,6 +22,7 @@ is-terminal = "0.4.17" itertools = { workspace = true } k8s-openapi = { workspace = true } lazy_static = { workspace = true } +memchr = "2.8" pem = "3" policy-evaluator = { path = "../policy-evaluator" } prettytable-rs = "^0.10" @@ -63,6 +64,7 @@ sha2 = { workspace = true } tempfile = { workspace = true } testcontainers = { version = "0.27", features = ["blocking"] } tower-test = "0.4" +wasm-encoder = "0.227" [features] diff --git a/crates/kwctl/cli-docs.md b/crates/kwctl/cli-docs.md index 039b5af52..aa71aa689 100644 --- a/crates/kwctl/cli-docs.md +++ b/crates/kwctl/cli-docs.md @@ -115,6 +115,9 @@ each policy in the file using the same request during each evaluation. ###### **Options:** * `--allow-context-aware ` — Grant access to the Kubernetes resources defined inside of the policy's `contextAwareResources` section. Warning: review the list of resources carefully to avoid abuses. Disabled by default +* `--allowed-host-capabilities ` — Host capabilities the policy is allowed to use. Use '*' to allow all. Can be repeated multiple times. Examples: 'oci/*', 'net/v1/dns_lookup_host' + + Default value: `*` * `--cert-email ` — Expected email in Fulcio certificate * `--cert-oidc-issuer ` — Expected OIDC issuer in Fulcio certificates * `--disable-wasmtime-cache ` — Turn off usage of wasmtime cache @@ -366,6 +369,9 @@ It respects standard proxy environment variables when downloading policies: ###### **Options:** * `--allow-context-aware ` — Grant access to the Kubernetes resources defined inside of the policy's `contextAwareResources` section. Warning: review the list of resources carefully to avoid abuses. Disabled by default +* `--allowed-host-capabilities ` — Host capabilities the policy is allowed to use. Use '*' to allow all. Can be repeated multiple times. Examples: 'oci/*', 'net/v1/dns_lookup_host' + + Default value: `*` * `--cert-email ` — Expected email in Fulcio certificate * `--cert-oidc-issuer ` — Expected OIDC issuer in Fulcio certificates * `--disable-wasmtime-cache ` — Turn off usage of wasmtime cache diff --git a/crates/kwctl/src/annotate.rs b/crates/kwctl/src/annotate.rs index e080fc021..d653323c3 100644 --- a/crates/kwctl/src/annotate.rs +++ b/crates/kwctl/src/annotate.rs @@ -1,9 +1,19 @@ -use crate::backend::{Backend, BackendDetector}; +use std::{ + collections::BTreeSet, + fs::{self, File}, + path::PathBuf, +}; + use anyhow::{Result, anyhow}; -use policy_evaluator::validator::Validate; -use policy_evaluator::{ProtocolVersion, constants::*, policy_metadata::Metadata}; -use std::fs::{self, File}; -use std::path::PathBuf; +use policy_evaluator::{ + ProtocolVersion, constants::*, policy_metadata::Metadata, validator::Validate, +}; +use tracing::warn; + +use crate::{ + backend::{Backend, BackendDetector}, + wasm_scanner, +}; pub(crate) fn write_annotation( wasm_path: PathBuf, @@ -16,14 +26,19 @@ pub(crate) fn write_annotation( fs::read_to_string(path).map_err(|e| anyhow!("Error reading usage file: {}", e)) }) .transpose()?; + + let wasm_bytes = + std::fs::read(&wasm_path).map_err(|e| anyhow!("Error reading wasm file: {}", e))?; + + let mut module = walrus::Module::from_buffer(&wasm_bytes) + .map_err(|e| anyhow!("Error parsing wasm module: {}", e))?; + + let detected_capabilities = + wasm_scanner::scan(&module).map_err(|e| anyhow!("Error scanning wasm module: {}", e))?; + let backend_detector = BackendDetector::default(); - let metadata = prepare_metadata( - wasm_path.clone(), - metadata_path, - backend_detector, - usage.as_deref(), - )?; - write_annotated_wasm_file(wasm_path, destination, metadata) + let metadata = prepare_metadata(wasm_path, metadata_path, backend_detector, usage.as_deref())?; + write_annotated_wasm_file(&mut module, destination, metadata, &detected_capabilities) } fn prepare_metadata( @@ -67,15 +82,48 @@ fn prepare_metadata( .and(Ok(metadata)) } +fn warn_on_capabilities_mismatch( + detected: &[wasm_scanner::DetectedHostCapability], + metadata: &Metadata, +) { + let detected_set: BTreeSet = detected + .iter() + .map(|c| format!("{}/{}", c.namespace, c.operation)) + .collect(); + + let declared_set: BTreeSet = metadata + .host_capabilities + .as_ref() + .cloned() + .unwrap_or_default(); + + let used_but_undeclared: BTreeSet<&String> = detected_set.difference(&declared_set).collect(); + let declared_but_unused: BTreeSet<&String> = declared_set.difference(&detected_set).collect(); + + if !used_but_undeclared.is_empty() { + warn!( + capabilities = ?used_but_undeclared, + "host capabilities used by the policy but not declared in metadata" + ); + } + + if !declared_but_unused.is_empty() { + warn!( + capabilities = ?declared_but_unused, + "host capabilities declared in metadata but not detected in the policy" + ); + } +} + fn write_annotated_wasm_file( - input_path: PathBuf, + module: &mut walrus::Module, output_path: PathBuf, metadata: Metadata, + detected_capabilities: &[wasm_scanner::DetectedHostCapability], ) -> Result<()> { - let buf: Vec = std::fs::read(input_path)?; - let metadata_json = serde_json::to_vec(&metadata)?; + warn_on_capabilities_mismatch(detected_capabilities, &metadata); - let mut module = walrus::Module::from_buffer(buf.as_slice())?; + let metadata_json = serde_json::to_vec(&metadata)?; let custom_section = walrus::RawCustomSection { name: String::from(KUBEWARDEN_CUSTOM_SECTION_METADATA), diff --git a/crates/kwctl/src/cli.rs b/crates/kwctl/src/cli.rs index 4ab291062..f389f2449 100644 --- a/crates/kwctl/src/cli.rs +++ b/crates/kwctl/src/cli.rs @@ -346,11 +346,16 @@ the host replays back the answers found inside of the provided file. This is useful to test policies in a reproducible way, given no external interactions with OCI registries, DNS, Kubernetes are performed."#), Arg::new("sigstore-trust-config") - .long("sigstore-trust-config") - .value_parser(value_parser!(PathBuf)) - .value_name("PATH") - .help("JSON-formatted file conforming to the ClientTrustConfig message in the Sigstore protobuf specs. This file configures the entire Sigstore instance state, including the URIs used to access the CA and artifact transparency services as well as the cryptographic root of trust itself"), - ] + .long("sigstore-trust-config") + .value_parser(value_parser!(PathBuf)) + .value_name("PATH") + .help("JSON-formatted file conforming to the ClientTrustConfig message in the Sigstore protobuf specs. This file configures the entire Sigstore instance state, including the URIs used to access the CA and artifact transparency services as well as the cryptographic root of trust itself"), + Arg::new("allowed-host-capabilities") + .long("allowed-host-capabilities") + .num_args(0..) + .default_values(["*"]) + .help("Host capabilities the policy is allowed to use. Use '*' to allow all. Can be repeated multiple times. Examples: 'oci/*', 'net/v1/dns_lookup_host'"), + ] } fn subcommand_run() -> Command { diff --git a/crates/kwctl/src/command/run/evaluator.rs b/crates/kwctl/src/command/run/evaluator.rs index d03a4d3e3..469342248 100644 --- a/crates/kwctl/src/command/run/evaluator.rs +++ b/crates/kwctl/src/command/run/evaluator.rs @@ -5,6 +5,7 @@ use policy_evaluator::{ admission_request::AdmissionRequest, admission_response::AdmissionResponse, evaluation_context::EvaluationContext, + host_capabilities::HostCapabilities, kube, kubewarden_policy_sdk::settings::SettingsValidationResponse, policy_evaluator::{PolicyEvaluator, PolicySettings, ValidateRequest}, @@ -54,11 +55,15 @@ async fn build_callback_handler( pub(crate) enum Evaluator { Policy { - policy_evaluator: PolicyEvaluator, + // This enum uses the `Box` type to avoid the need for a large enum size causing memory layout + // problems. https://rust-lang.github.io/rust-clippy/master/index.html#large_enum_variant + policy_evaluator: Box, settings: PolicySettings, request: ValidateRequest, }, GroupPolicy { + // Contrary to PolicyEvaluator, PolicyGroupEvaluator is shared across closures in + // rhai_eval_env.clone(), which requires +Send+Sync policy_group_evaluator: Arc, request: ValidateRequest, }, @@ -79,6 +84,7 @@ impl Evaluator { raw, settings, ctx_aware_cfg, + allowed_host_capabilities, .. } => { let metadata = local_data.metadata(uri); @@ -120,13 +126,15 @@ impl Evaluator { callback_channel: Some(callback_handler.sender_channel()), ctx_aware_resources_allow_list: context_aware_allowed_resources.clone(), epoch_deadline: None, + host_capabilities: HostCapabilities::new(allowed_host_capabilities) + .map_err(|e| anyhow::anyhow!("Invalid host capabilities pattern: {e}"))?, }; let policy_evaluator = policy_evaluator_builder.build_pre()?.rehydrate(&eval_ctx)?; Ok(( Self::Policy { - policy_evaluator, + policy_evaluator: policy_evaluator.into(), request, settings: settings.clone(), }, @@ -190,7 +198,7 @@ impl Evaluator { Ok(( Self::GroupPolicy { - policy_group_evaluator: Arc::new(policy_group_evaluator), + policy_group_evaluator: policy_group_evaluator.into(), request, }, callback_handler, diff --git a/crates/kwctl/src/config/policy_definition.rs b/crates/kwctl/src/config/policy_definition.rs index 14f58aca3..b810cc4ba 100644 --- a/crates/kwctl/src/config/policy_definition.rs +++ b/crates/kwctl/src/config/policy_definition.rs @@ -1,7 +1,7 @@ -use std::str::FromStr; use std::{ collections::{BTreeSet, HashMap, HashSet}, fmt, + str::FromStr, }; use anyhow::{Result, anyhow}; @@ -9,6 +9,7 @@ use clap::ArgMatches; use k8s_openapi::api::core::v1::ObjectReference; use policy_evaluator::{ admission_response_handler::{policy_id::PolicyID, policy_mode::PolicyMode}, + host_capabilities::HostCapabilities, kubewarden_policy_sdk::crd::policies::{ AdmissionPolicy, AdmissionPolicyGroup, ClusterAdmissionPolicy, ClusterAdmissionPolicyGroup, }, @@ -45,6 +46,9 @@ pub(crate) enum PolicyDefinition { // determined after the policy is downloaded locally and its // metadata is inspected. ctx_aware_cfg: ContextAwareConfiguration, + // The list of host capabilities the policy is allowed to use. + // An empty list means no host capabilities are allowed. + allowed_host_capabilities: Vec, }, /// This is a group of policies. This can be defined only by providing a Kubewarden CRD /// file. @@ -164,6 +168,7 @@ impl TryFrom for PolicyDefinition { custom_rejection_message, settings, ctx_aware_cfg: ContextAwareConfiguration::NoAccess, + allowed_host_capabilities: vec![], }) } } @@ -204,6 +209,7 @@ impl TryFrom for PolicyDefinition { custom_rejection_message, settings, ctx_aware_cfg: ContextAwareConfiguration::AllowList(ctx_aware_allow_list), + allowed_host_capabilities: vec![], }) } } @@ -316,7 +322,10 @@ impl PolicyDefinition { /// reads all the CRDs defined inside of the given file and returns a /// list of PolicyDefinition - pub fn from_yaml_file(yaml_path: &str) -> Result> { + pub fn from_yaml_file( + yaml_path: &str, + allowed_host_capabilities: &[String], + ) -> Result> { let deserializer = serde_yaml::Deserializer::from_reader( std::fs::File::open(yaml_path) .map_err(|e| anyhow!("Cannot open YAML file {:?}: {}", yaml_path, e))?, @@ -328,7 +337,26 @@ impl PolicyDefinition { let value_yaml = serde_yaml::Value::deserialize(document) .map_err(|e| anyhow!("Cannot parse YAML file {:?}: {}", yaml_path, e))?; - let policy = PolicyDefinition::new(value_yaml)?; + let mut policy = PolicyDefinition::new(value_yaml)?; + // Overwrite the host capabilities with the value provided by the caller. + // For individual policies this sets the field directly. For group policies + // the same list is applied uniformly to all members, because host capabilities + // are not part of the Kubewarden CRD spec and must therefore come from the CLI. + let hc_allow_list = HostCapabilities::new(allowed_host_capabilities) + .map_err(|e| anyhow!("Invalid host capabilities pattern: {e}"))?; + match &mut policy { + PolicyDefinition::Policy { + allowed_host_capabilities: caps, + .. + } => { + *caps = allowed_host_capabilities.to_vec(); + } + PolicyDefinition::PolicyGroup { policy_members, .. } => { + for member in policy_members.values_mut() { + member.settings.host_capabilities = hc_allow_list.clone(); + } + } + } policies.push(policy); } @@ -394,6 +422,12 @@ impl PolicyDefinition { let policy_mode = PolicyMode::Protect; let custom_rejection_message = None; + let allowed_host_capabilities: Vec = matches + .get_many::("allowed-host-capabilities") + .unwrap_or_default() + .cloned() + .collect(); + Ok(PolicyDefinition::Policy { id: "policy-from-cli".to_string(), policy_mode, @@ -404,6 +438,7 @@ impl PolicyDefinition { raw, settings, ctx_aware_cfg, + allowed_host_capabilities, }) } @@ -471,6 +506,7 @@ mod tests { policy_mode, allowed_to_mutate, custom_rejection_message, + allowed_host_capabilities, } => { assert_eq!(id, name); assert_eq!(uri, module_uri); @@ -557,6 +593,7 @@ mod tests { policy_mode, allowed_to_mutate, custom_rejection_message, + allowed_host_capabilities, } => { assert_eq!(id, name); assert_eq!(uri, module_uri); @@ -669,6 +706,7 @@ mod tests { .expect("Failed to convert settings for member 1"), ctx_aware_resources_allow_list: pgm_1_expected_context_aware_resources, epoch_deadline: None, + host_capabilities: HostCapabilities::DenyAll, }, }, ), @@ -681,6 +719,7 @@ mod tests { .expect("Failed to convert settings for member 2"), ctx_aware_resources_allow_list: BTreeSet::new(), epoch_deadline: None, + host_capabilities: HostCapabilities::DenyAll, }, }, ), diff --git a/crates/kwctl/src/config/pull_and_run.rs b/crates/kwctl/src/config/pull_and_run.rs index e97f5b3a9..690212226 100644 --- a/crates/kwctl/src/config/pull_and_run.rs +++ b/crates/kwctl/src/config/pull_and_run.rs @@ -58,8 +58,13 @@ pub(crate) fn parse_policy_definitions(matches: &ArgMatches) -> Result = matches + .get_many::("allowed-host-capabilities") + .unwrap_or_default() + .cloned() + .collect(); // If the URI is a YAML file, parse it as a policy definition - return PolicyDefinition::from_yaml_file(uri); + return PolicyDefinition::from_yaml_file(uri, &allowed_host_capabilities); } Ok(vec![PolicyDefinition::from_cli(matches)?]) diff --git a/crates/kwctl/src/main.rs b/crates/kwctl/src/main.rs index 1fcbc90d9..f06ae89e5 100644 --- a/crates/kwctl/src/main.rs +++ b/crates/kwctl/src/main.rs @@ -43,6 +43,7 @@ mod save; mod scaffold; mod utils; mod verify; +mod wasm_scanner; pub(crate) const KWCTL_VERIFICATION_CONFIG: &str = "verification-config.yml"; diff --git a/crates/kwctl/src/scaffold/manifest.rs b/crates/kwctl/src/scaffold/manifest.rs index ddcebbf15..92f79fe34 100644 --- a/crates/kwctl/src/scaffold/manifest.rs +++ b/crates/kwctl/src/scaffold/manifest.rs @@ -248,6 +248,7 @@ mod tests { mutating: false, background_audit: true, context_aware_resources: BTreeSet::new(), + host_capabilities: None, execution_mode: Default::default(), policy_type: Default::default(), minimum_kubewarden_version: None, @@ -265,6 +266,7 @@ mod tests { mutating: false, background_audit: true, context_aware_resources: BTreeSet::new(), + host_capabilities: None, execution_mode: Default::default(), policy_type: Default::default(), minimum_kubewarden_version: None, @@ -292,6 +294,7 @@ mod tests { mutating: false, background_audit: true, context_aware_resources: BTreeSet::new(), + host_capabilities: None, execution_mode: Default::default(), policy_type: Default::default(), minimum_kubewarden_version: None, diff --git a/crates/kwctl/src/wasm_scanner.rs b/crates/kwctl/src/wasm_scanner.rs new file mode 100644 index 000000000..ea5d8c304 --- /dev/null +++ b/crates/kwctl/src/wasm_scanner.rs @@ -0,0 +1,218 @@ +use anyhow::Result; +use memchr::memmem; +use policy_evaluator::host_capabilities::HostCapabilities; + +#[derive(Debug, PartialEq)] +pub struct DetectedHostCapability { + pub namespace: String, + pub operation: String, +} + +pub fn scan(module: &walrus::Module) -> Result> { + // Collect all data segment payloads, separated by 0xFF to avoid + // cross-boundary false matches. + let mut all_data: Vec = Vec::new(); + for segment in module.data.iter() { + all_data.extend_from_slice(&segment.value); + all_data.push(0xFF); + } + + let operations = HostCapabilities::enumerate_operations(); + + // Collect all operation strings so we can detect prefix collisions generically. + // For each operation, find every other operation string it is a strict prefix of. + let op_strings: Vec<&str> = operations.iter().map(|(_, op)| op.as_str()).collect(); + + let mut capabilities = Vec::new(); + for (namespace, operation) in &operations { + // Collect all other operation strings that start with `operation` and are longer. + let longer_siblings: Vec<&str> = op_strings + .iter() + .copied() + .filter(|&other| other != operation.as_str() && other.starts_with(operation.as_str())) + .collect(); + + if is_operation_present(&all_data, namespace, operation, &longer_siblings) { + capabilities.push(DetectedHostCapability { + namespace: namespace.clone(), + operation: operation.clone(), + }); + } + } + + Ok(capabilities) +} + +fn is_operation_present( + data: &[u8], + namespace: &str, + operation: &str, + longer_siblings: &[&str], +) -> bool { + // The namespace string must be present (also passed to HostCall). + if memmem::find(data, namespace.as_bytes()).is_none() { + return false; + } + + // If there are longer operation strings that share our string as a prefix, + // we must confirm that `operation` appears at least once as a standalone + // match (i.e. not solely as part of one of those longer strings). + if longer_siblings.is_empty() { + memmem::find(data, operation.as_bytes()).is_some() + } else { + longer_siblings + .iter() + .all(|longer| has_standalone_match(data, operation.as_bytes(), longer.as_bytes())) + } +} + +/// Returns true if `needle` appears in `data` at a position that is NOT the +/// start of any occurrence of `longer` (which must begin with `needle`). +fn has_standalone_match(data: &[u8], needle: &[u8], longer: &[u8]) -> bool { + let mut offset = 0; + while let Some(pos) = memmem::find(&data[offset..], needle) { + let abs_pos = offset + pos; + if !data[abs_pos..].starts_with(longer) { + return true; + } + offset = abs_pos + 1; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + use wasm_encoder::{ConstExpr, DataSection, MemorySection, MemoryType, Module}; + + fn build_wasm(strings: &[&str]) -> walrus::Module { + let mut module = Module::new(); + + let mut memories = MemorySection::new(); + memories.memory(MemoryType { + minimum: 1, + maximum: None, + memory64: false, + shared: false, + page_size_log2: None, + }); + module.section(&memories); + + let mut data = DataSection::new(); + let mut payload: Vec = Vec::new(); + for s in strings { + payload.extend_from_slice(s.as_bytes()); + payload.push(0); + } + data.active(0, &ConstExpr::i32_const(0), payload); + module.section(&data); + + walrus::Module::from_buffer(&module.finish()).unwrap() + } + + #[test] + fn no_capabilities_without_kubewarden_marker() { + let module = build_wasm(&["hello", "world"]); + let caps = scan(&module).unwrap(); + assert!(caps.is_empty()); + } + + #[test] + fn kubewarden_policy_no_capabilities() { + let module = build_wasm(&["kubewarden"]); + let caps = scan(&module).unwrap(); + assert!(caps.is_empty()); + } + + #[test] + fn detects_single_capability_crypto() { + let module = build_wasm(&["kubewarden", "crypto", "v1/is_certificate_trusted"]); + let caps = scan(&module).unwrap(); + assert_eq!(caps.len(), 1); + assert_eq!(caps[0].operation, "v1/is_certificate_trusted"); + } + + #[test] + fn detects_single_capability_net() { + let module = build_wasm(&["kubewarden", "net", "v1/dns_lookup_host"]); + let caps = scan(&module).unwrap(); + assert_eq!(caps.len(), 1); + assert_eq!(caps[0].operation, "v1/dns_lookup_host"); + } + + #[test] + fn detects_all_capabilities() { + let all_ops = HostCapabilities::enumerate_operations(); + let mut strings: Vec<&str> = vec!["kubewarden"]; + for (ns, op) in &all_ops { + strings.push(ns.as_str()); + strings.push(op.as_str()); + } + let module = build_wasm(&strings); + let caps = scan(&module).unwrap(); + assert_eq!(caps.len(), all_ops.len()); + } + + #[test] + fn oci_manifest_config_only_does_not_trigger_oci_manifest() { + let module = build_wasm(&["kubewarden", "oci", "v1/oci_manifest_config"]); + let caps = scan(&module).unwrap(); + let ops: Vec<&str> = caps.iter().map(|c| c.operation.as_str()).collect(); + assert!( + ops.contains(&"v1/oci_manifest_config"), + "expected v1/oci_manifest_config" + ); + assert!( + !ops.contains(&"v1/oci_manifest"), + "v1/oci_manifest must not be a false positive" + ); + } + + #[test] + fn oci_manifest_standalone_is_detected() { + let module = build_wasm(&[ + "kubewarden", + "oci", + "v1/oci_manifest_config", + "v1/oci_manifest", + ]); + let caps = scan(&module).unwrap(); + let ops: Vec<&str> = caps.iter().map(|c| c.operation.as_str()).collect(); + assert!(ops.contains(&"v1/oci_manifest_config")); + assert!(ops.contains(&"v1/oci_manifest")); + } + + #[test] + fn namespace_required_for_short_ops() { + let module = build_wasm(&["kubewarden", "can_i"]); + let caps = scan(&module).unwrap(); + let ops: Vec<&str> = caps.iter().map(|c| c.operation.as_str()).collect(); + assert!( + !ops.contains(&"can_i"), + "can_i without namespace must not match" + ); + } + + #[test] + fn enumerate_operations_has_unique_entries() { + let ops = HostCapabilities::enumerate_operations(); + let mut keys: Vec<(&str, &str)> = ops + .iter() + .map(|(ns, op)| (ns.as_str(), op.as_str())) + .collect(); + let original_len = keys.len(); + keys.sort(); + keys.dedup(); + assert_eq!( + keys.len(), + original_len, + "duplicate entries in enumerate_operations" + ); + } + + #[test] + fn invalid_wasm_returns_error() { + assert!(walrus::Module::from_buffer(b"not a wasm binary").is_err()); + } +} diff --git a/crates/kwctl/tests/common/mod.rs b/crates/kwctl/tests/common/mod.rs index 2f0b7ed94..e26dacf26 100644 --- a/crates/kwctl/tests/common/mod.rs +++ b/crates/kwctl/tests/common/mod.rs @@ -1,7 +1,6 @@ use std::path::Path; -use assert_cmd::Command; -use assert_cmd::cargo::cargo_bin_cmd; +use assert_cmd::{Command, cargo::cargo_bin_cmd}; #[allow(dead_code)] pub fn setup_command(path: &Path) -> Command { diff --git a/crates/kwctl/tests/data/context-aware-annotate/metadata-correct.yml b/crates/kwctl/tests/data/context-aware-annotate/metadata-correct.yml new file mode 100644 index 000000000..f70d928f4 --- /dev/null +++ b/crates/kwctl/tests/data/context-aware-annotate/metadata-correct.yml @@ -0,0 +1,12 @@ +rules: + - apiGroups: [""] + apiVersions: ["v1"] + resources: ["pods"] + operations: ["CREATE"] +mutating: false +executionMode: kubewarden-wapc +hostCapabilities: + - kubernetes/get_resource + - kubernetes/list_resources_by_namespace +annotations: + io.kubewarden.policy.title: context-aware-policy-demo diff --git a/crates/kwctl/tests/data/context-aware-annotate/metadata-wrong.yml b/crates/kwctl/tests/data/context-aware-annotate/metadata-wrong.yml new file mode 100644 index 000000000..82ac4b9f9 --- /dev/null +++ b/crates/kwctl/tests/data/context-aware-annotate/metadata-wrong.yml @@ -0,0 +1,11 @@ +rules: + - apiGroups: [""] + apiVersions: ["v1"] + resources: ["pods"] + operations: ["CREATE"] +mutating: false +executionMode: kubewarden-wapc +hostCapabilities: + - oci/v1/verify +annotations: + io.kubewarden.policy.title: context-aware-policy-demo diff --git a/crates/kwctl/tests/e2e.rs b/crates/kwctl/tests/e2e.rs index 13a32e951..06a729c4a 100644 --- a/crates/kwctl/tests/e2e.rs +++ b/crates/kwctl/tests/e2e.rs @@ -13,7 +13,7 @@ use policy_evaluator::{ }, policy_fetcher, policy_metadata, }; -use predicates::{prelude::*, str::contains, str::is_empty}; +use predicates::{prelude::*, str::contains, str::is_empty, str::is_match}; use rstest::rstest; use sha2::{Digest, Sha256}; use tempfile::tempdir; @@ -495,6 +495,61 @@ fn test_run_context_from_yaml( .stdout(contains(format!("\"allowed\":{}", allowed))); } +#[rstest] +#[case::from_cli(false)] +#[case::from_yaml(true)] +fn test_run_ctx_aware_policy_host_capability_denied(#[case] from_yaml: bool) { + let tempdir = tempdir().expect("cannot create tempdir"); + pull_policies(tempdir.path(), POLICIES); + + // Use a replay session to avoid attempting a real Kubernetes connection. + // The replay never fires because the host capability gate blocks the + // kubernetes/* call before it reaches the replay handler. + let session_path = + test_data("host-capabilities-sessions/context-aware-demo-namespace-found.yml"); + + // Only constructed for the from_yaml case; must outlive cmd.assert() + // so the tempfile is not dropped before kwctl reads it. + let yaml_file = from_yaml.then(|| { + let crd = cluster_admission_policy( + "ctx-aware-policy", + "registry://ghcr.io/kubewarden/tests/context-aware-policy-demo:v0.1.0", + &[ContextAwareResourceSdk { + api_version: "v1".to_string(), + kind: "Namespace".to_string(), + }], + ); + write_tmp_yaml_file( + serde_yaml::to_string(&crd) + .expect("cannot serialize CRD") + .as_bytes(), + ) + }); + + let policy_arg: String = match &yaml_file { + Some(f) => f.path().to_string_lossy().into_owned(), + None => "registry://ghcr.io/kubewarden/tests/context-aware-policy-demo:v0.1.0".to_owned(), + }; + + let mut cmd = setup_command(tempdir.path()); + cmd.arg("run") + .arg("--allow-context-aware") + .arg("--allowed-host-capabilities") + .arg("oci/*") + .arg("--request-path") + .arg(test_data( + "context-aware-policy-request-pod-creation-all-labels.json", + )) + .arg("--replay-host-capabilities-interactions") + .arg(session_path) + .arg(policy_arg); + + cmd.assert().success(); + cmd.assert() + .stdout(contains("\"allowed\":false")) + .stdout(contains("has not been granted access")); +} + #[test] fn test_run_ctx_aware_group_policy() { let tempdir = tempdir().expect("cannot create tempdir"); @@ -551,6 +606,80 @@ fn test_run_ctx_aware_group_policy() { .stdout(contains(format!("\"allowed\":{}", true))); } +#[rstest] +#[case::host_capabilities_denied(true, false)] +#[case::host_capabilities_allowed(false, true)] +fn test_run_ctx_aware_group_policy_host_capability( + #[case] restrict_caps: bool, + #[case] expected_allowed: bool, +) { + let tempdir = tempdir().expect("cannot create tempdir"); + pull_policies(tempdir.path(), POLICIES); + + let crd = cluster_admission_policy_group::ClusterAdmissionPolicyGroup { + metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { + name: Some("group-policy".to_string()), + ..Default::default() + }, + spec: Some( + cluster_admission_policy_group::ClusterAdmissionPolicyGroupSpec { + expression: "demo_policy() && true".to_string(), + message: "you shall not pass!".to_string(), + policies: HashMap::from([( + "demo_policy".to_string(), + cluster_admission_policy_group::PolicyGroupMemberWithContext { + module: + "registry://ghcr.io/kubewarden/tests/context-aware-policy-demo:v0.1.0" + .to_string(), + context_aware_resources: vec![ContextAwareResourceSdk { + api_version: "v1".to_string(), + kind: "Namespace".to_string(), + }], + ..Default::default() + }, + )]), + ..Default::default() + }, + ), + ..Default::default() + }; + let yaml_file = write_tmp_yaml_file( + serde_yaml::to_string(&crd) + .expect("cannot serialize CRD") + .as_bytes(), + ); + + let session_path = + test_data("host-capabilities-sessions/context-aware-demo-namespace-found.yml"); + + let mut cmd = setup_command(tempdir.path()); + cmd.arg("run") + .arg("--allow-context-aware") + .arg("--request-path") + .arg(test_data( + "context-aware-policy-request-pod-creation-all-labels.json", + )) + .arg("--replay-host-capabilities-interactions") + .arg(session_path); + + if restrict_caps { + cmd.arg("--allowed-host-capabilities") + .arg("oci/*") + .arg("--"); + } + + cmd.arg(yaml_file.path()); + + cmd.assert().success(); + if expected_allowed { + cmd.assert().stdout(contains("\"allowed\":true")); + } else { + cmd.assert() + .stdout(contains("\"allowed\":false")) + .stdout(contains("has not been granted access")); + } +} + #[test] fn test_run_sha_prefix() { let tempdir = tempdir().unwrap(); @@ -903,6 +1032,57 @@ fn test_scaffold_from_vap( cmd.assert().stderr(stderr_predicate); } +#[rstest] +#[case::matching_capabilities("context-aware-annotate/metadata-correct.yml", false)] +#[case::mismatched_capabilities("context-aware-annotate/metadata-wrong.yml", true)] +fn test_annotate_host_capabilities(#[case] metadata_path: &str, #[case] expect_warning: bool) { + let tempdir = tempdir().unwrap(); + let wasm_path = tempdir.path().join("context-aware-policy-demo.wasm"); + + let mut cmd = setup_command(tempdir.path()); + cmd.arg("pull") + .arg("--output-path") + .arg(&wasm_path) + .arg("registry://ghcr.io/kubewarden/tests/context-aware-policy-demo:v0.1.0"); + cmd.assert().success(); + + let mut cmd = setup_command(tempdir.path()); + cmd.arg("--no-color") + .arg("annotate") + .arg("-m") + .arg(test_data(metadata_path)) + .arg(&wasm_path) + .arg("-o") + .arg("annotated-policy.wasm"); + + cmd.assert().success(); + + if expect_warning { + cmd.assert() + .stderr( + is_match( + r#"host capabilities used by the policy but not declared in metadata.*\{"kubernetes/get_resource", "kubernetes/list_resources_by_namespace"\}"#, + ) + .unwrap(), + ) + .stderr( + is_match( + r#"host capabilities declared in metadata but not detected in the policy.*\{"oci/v1/verify"\}"#, + ) + .unwrap(), + ); + } else { + cmd.assert() + .stderr( + contains("host capabilities used by the policy but not declared in metadata").not(), + ) + .stderr( + contains("host capabilities declared in metadata but not detected in the policy") + .not(), + ); + } +} + #[rstest] #[case::correct("rego-annotate/metadata-correct.yml", true, is_empty())] #[case::wrong( diff --git a/crates/policy-evaluator/Cargo.toml b/crates/policy-evaluator/Cargo.toml index 44ef5b6b2..51d957371 100644 --- a/crates/policy-evaluator/Cargo.toml +++ b/crates/policy-evaluator/Cargo.toml @@ -8,7 +8,7 @@ authors = [ ] edition = "2024" name = "policy-evaluator" -version = "0.31.0" +version = "0.32.0" [dependencies] anyhow = { workspace = true } diff --git a/crates/policy-evaluator/src/errors.rs b/crates/policy-evaluator/src/errors.rs index 7e5f94665..e9a2e025c 100644 --- a/crates/policy-evaluator/src/errors.rs +++ b/crates/policy-evaluator/src/errors.rs @@ -105,3 +105,25 @@ pub enum ResponseError { #[error("cannot deserialize JSONPatch: {0}")] Deserialize(#[source] serde_json::Error), } + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum HostCapabilitiesPatternError { + #[error( + "invalid pattern {pattern:?}: wildcard '*' must be the entire last path segment (e.g. 'oci/*'), not a partial match" + )] + InvalidWildcard { pattern: String }, + #[error("invalid pattern: pattern must not be empty")] + Empty, + #[error( + "invalid pattern {pattern:?}: unknown segment {segment:?}, valid options at this level are: {valid_options}" + )] + UnknownSegment { + pattern: String, + segment: String, + valid_options: String, + }, + #[error( + "invalid pattern {pattern:?}: not a complete capability path; use {suggestion:?} to allow all capabilities under it" + )] + IncompleteCapabilityPath { pattern: String, suggestion: String }, +} diff --git a/crates/policy-evaluator/src/evaluation_context.rs b/crates/policy-evaluator/src/evaluation_context.rs index 9ad92aca4..b75ff1dc3 100644 --- a/crates/policy-evaluator/src/evaluation_context.rs +++ b/crates/policy-evaluator/src/evaluation_context.rs @@ -1,9 +1,11 @@ -use std::collections::BTreeSet; -use std::fmt; +use std::{collections::BTreeSet, fmt}; + use tokio::sync::mpsc; -use crate::callback_requests::CallbackRequest; -use crate::policy_metadata::ContextAwareResource; +use crate::{ + callback_requests::CallbackRequest, host_capabilities::HostCapabilities, + policy_metadata::ContextAwareResource, +}; /// A struct that holds metadata and other data that are needed when a policy /// is being evaluated @@ -29,6 +31,11 @@ pub struct EvaluationContext { /// This could either be the global epoch deadline, or the one /// specific to the policy pub epoch_deadline: Option, + + /// The set of host capabilities this policy is allowed to invoke. + /// An empty list means no host capabilities are allowed (deny by default). + /// A list containing `*` means all capabilities are allowed. + pub host_capabilities: HostCapabilities, } impl EvaluationContext { @@ -43,6 +50,15 @@ impl EvaluationContext { self.ctx_aware_resources_allow_list .contains(&wanted_resource) } + + /// Checks if a policy has access to a host capability, based on the + /// `host_capabilities` allow list configured for this policy. + /// + /// The capability path is constructed as `{namespace}/{operation}`, + /// matching the waPC host callback namespace and operation parameters. + pub(crate) fn can_access_host_capability(&self, capability_path: &str) -> bool { + self.host_capabilities.is_allowed(capability_path) + } } impl fmt::Debug for EvaluationContext { @@ -54,8 +70,11 @@ impl fmt::Debug for EvaluationContext { write!( f, - r#"EvaluationContext {{ policy_id: "{}", callback_channel: {}, allowed_kubernetes_resources: {:?} }}"#, - self.policy_id, callback_channel, self.ctx_aware_resources_allow_list, + r#"EvaluationContext {{ policy_id: "{}", callback_channel: {}, allowed_kubernetes_resources: {:?}, host_capabilities: {} }}"#, + self.policy_id, + callback_channel, + self.ctx_aware_resources_allow_list, + self.host_capabilities, ) } } @@ -66,9 +85,8 @@ mod tests { use rstest::rstest; #[rstest] - #[case("nothing allowed", BTreeSet::new(), "v1", "Secret", false)] - #[case( - "try to access denied resource", + #[case::nothing_allowed(BTreeSet::new(), "v1", "Secret", false)] + #[case::try_to_access_denied_resource( BTreeSet::from([ ContextAwareResource{ api_version: "v1".to_string(), @@ -78,8 +96,7 @@ mod tests { "Secret", false, )] - #[case( - "access allowed resource", + #[case::access_allowed_resource( BTreeSet::from([ ContextAwareResource{ api_version: "v1".to_string(), @@ -91,17 +108,17 @@ mod tests { )] fn can_access_kubernetes_resource( - #[case] name: &str, #[case] allowed_resources: BTreeSet, #[case] api_version: &str, #[case] kind: &str, #[case] allowed: bool, ) { let ctx = EvaluationContext { - policy_id: name.to_string(), + policy_id: "a-context-aware-kubernetes-policy".to_string(), callback_channel: None, ctx_aware_resources_allow_list: allowed_resources, epoch_deadline: None, + host_capabilities: HostCapabilities::AllowAll, }; let requested_resource = ContextAwareResource { @@ -117,4 +134,25 @@ mod tests { ) ); } + + #[rstest] + #[case::deny_all(vec![], "oci/v1/verify", false)] + #[case::allow_all(vec!["*"], "oci/v1/verify", true)] + #[case::exact_match(vec!["oci/v1/verify"], "oci/v1/verify", true)] + #[case::prefix_match(vec!["oci/*"], "oci/v2/verify", true)] + #[case::no_match(vec!["net/v1/dns_lookup_host"], "oci/v1/verify", false)] + fn can_access_host_capability( + #[case] patterns: Vec<&str>, + #[case] capability: &str, + #[case] allowed: bool, + ) { + let ctx = EvaluationContext { + policy_id: "a-context-aware-policy".to_string(), + callback_channel: None, + ctx_aware_resources_allow_list: BTreeSet::new(), + epoch_deadline: None, + host_capabilities: HostCapabilities::new(patterns).expect("valid patterns"), + }; + assert_eq!(ctx.can_access_host_capability(capability), allowed); + } } diff --git a/crates/policy-evaluator/src/host_capabilities.rs b/crates/policy-evaluator/src/host_capabilities.rs new file mode 100644 index 000000000..1c5e864ae --- /dev/null +++ b/crates/policy-evaluator/src/host_capabilities.rs @@ -0,0 +1,516 @@ +use std::{ + collections::{HashMap, HashSet}, + fmt, + sync::LazyLock, +}; + +use serde::{Deserialize, Serialize}; + +use crate::errors::HostCapabilitiesPatternError; + +/// A node in the host-capability path tree. +/// Leaf nodes (complete, addressable operations) have a `None` value. +/// Intermediate nodes carry a `Some` map of named children. +struct CapabilityNode(HashMap<&'static str, Option>>); + +impl CapabilityNode { + fn leaf() -> Option> { + None + } + + fn node(children: HashMap<&'static str, Option>>) -> Option> { + Some(Box::new(Self(children))) + } +} + +static CAPABILITY_TREE: LazyLock = LazyLock::new(|| { + CapabilityNode(HashMap::from([ + ( + "oci", + CapabilityNode::node(HashMap::from([ + ( + "v1", + CapabilityNode::node(HashMap::from([ + ("verify", CapabilityNode::leaf()), + ("manifest_digest", CapabilityNode::leaf()), + ("oci_manifest", CapabilityNode::leaf()), + ("oci_manifest_config", CapabilityNode::leaf()), + ])), + ), + ( + "v2", + CapabilityNode::node(HashMap::from([("verify", CapabilityNode::leaf())])), + ), + ])), + ), + ( + "net", + CapabilityNode::node(HashMap::from([( + "v1", + CapabilityNode::node(HashMap::from([("dns_lookup_host", CapabilityNode::leaf())])), + )])), + ), + ( + "crypto", + CapabilityNode::node(HashMap::from([( + "v1", + CapabilityNode::node(HashMap::from([( + "is_certificate_trusted", + CapabilityNode::leaf(), + )])), + )])), + ), + ( + "kubernetes", + CapabilityNode::node(HashMap::from([ + ("list_resources_by_namespace", CapabilityNode::leaf()), + ("list_resources_all", CapabilityNode::leaf()), + ("get_resource", CapabilityNode::leaf()), + ("can_i", CapabilityNode::leaf()), + ])), + ), + ])) +}); + +/// Validates one capability pattern against `CAPABILITY_TREE`. +/// +/// `*` (global wildcard) is accepted without tree lookup — it is handled by +/// the caller before this function is reached. +/// +/// For prefix wildcards (`oci/*`, `oci/v1/*`) every segment *before* the +/// wildcard is verified to be a known intermediate node; the wildcard itself +/// is then accepted without further checking (the user is intentionally broad). +/// +/// For exact paths every segment must lead to a known node, and the final +/// segment must be a leaf. +fn validate_against_tree(pattern: &str) -> Result<(), HostCapabilitiesPatternError> { + let parts: Vec<&str> = pattern.split('/').collect(); + let mut node: &CapabilityNode = &CAPABILITY_TREE; + + for (i, &part) in parts.iter().enumerate() { + if part == "*" { + // Already guaranteed to be the last segment by the wildcard syntax + // check that runs before this function. The parent node exists + // (we reached this point), so the wildcard is valid. + return Ok(()); + } + + match node.0.get(part) { + None => { + let mut valid: Vec<&str> = node.0.keys().copied().collect(); + valid.sort_unstable(); + return Err(HostCapabilitiesPatternError::UnknownSegment { + pattern: pattern.to_string(), + segment: part.to_string(), + valid_options: valid.join(", "), + }); + } + Some(None) => { + // Leaf reached; the path must end here. + if i != parts.len() - 1 { + // There are more segments after the leaf — already caught + // by the wildcard-syntax check, but guard here for safety. + return Err(HostCapabilitiesPatternError::UnknownSegment { + pattern: pattern.to_string(), + segment: parts[i + 1].to_string(), + valid_options: String::new(), + }); + } + return Ok(()); + } + Some(Some(child)) => { + if i == parts.len() - 1 { + // Stopped at an intermediate node without a wildcard. + return Err(HostCapabilitiesPatternError::IncompleteCapabilityPath { + pattern: pattern.to_string(), + suggestion: format!("{pattern}/*"), + }); + } + node = child; + } + } + } + + Ok(()) +} + +/// Represents the set of host capabilities a policy is allowed to use. +/// +/// Host capability paths follow the format `{namespace}/{operation}`, e.g. +/// `oci/v1/verify`, `net/v1/dns_lookup_host`, `kubernetes/can_i`. +/// +/// Supported patterns: +/// - `*`: allow all capabilities +/// - `oci/*`: allow all OCI capabilities regardless of version +/// - `oci/v2/*`: allow all OCI v2 capabilities +/// - `oci/v1/verify`: allow only the exact capability +/// +/// Invalid patterns (rejected at parse time): +/// - `oci*`: wildcard must follow a `/` +/// - `oci/v1/oci_*`: wildcard must be the entire last segment +/// - `unknown/v1/op`: unknown segments are rejected against the capability tree +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "Vec", into = "Vec")] +pub enum HostCapabilities { + /// Deny all host capabilities (empty pattern list). + #[default] + DenyAll, + /// Allow all host capabilities (the `*` pattern was specified). + AllowAll, + /// Allow specific capabilities matched by prefix or exact patterns. + Patterns { + /// Prefix patterns (e.g., `oci/` from `oci/*`, `oci/v2/` from `oci/v2/*`) + prefixes: HashSet, + /// Exact capability paths (e.g., `oci/v1/verify`) + exact: HashSet, + }, +} + +impl HostCapabilities { + /// Returns all known host-capability operations as `(namespace, operation)` pairs, + /// e.g. `("oci", "v1/verify")`, `("kubernetes", "can_i")`. + /// + /// The list is derived by recursively walking `CAPABILITY_TREE` and collecting + /// every leaf path. + pub fn enumerate_operations() -> Vec<(String, String)> { + fn walk( + node: &CapabilityNode, + path: &mut Vec<&'static str>, + out: &mut Vec<(String, String)>, + ) { + for (&segment, child) in &node.0 { + path.push(segment); + match child { + None => { + // Leaf: first segment is the namespace, the rest is the operation. + let namespace = path[0].to_string(); + let operation = path[1..].join("/"); + out.push((namespace, operation)); + } + Some(inner) => walk(inner, path, out), + } + path.pop(); + } + } + + let mut out = Vec::new(); + walk(&CAPABILITY_TREE, &mut vec![], &mut out); + out.sort(); + out + } + + /// Creates a new allow list from a list of patterns. + /// + /// Returns an error if any pattern is syntactically invalid or refers to + /// an unknown capability namespace/operation. + pub fn new( + patterns: impl IntoIterator>, + ) -> Result { + let mut prefixes = HashSet::new(); + let mut exact = HashSet::new(); + + for pattern in patterns { + let pattern = pattern.as_ref(); + let trimmed = pattern.trim(); + if trimmed.is_empty() { + return Err(HostCapabilitiesPatternError::Empty); + } + + if trimmed == "*" { + return Ok(Self::AllowAll); + } + + if trimmed.contains('*') && !trimmed.ends_with("/*") { + return Err(HostCapabilitiesPatternError::InvalidWildcard { + pattern: pattern.to_string(), + }); + } + + validate_against_tree(trimmed)?; + + if let Some(prefix) = trimmed.strip_suffix("*") { + prefixes.insert(prefix.to_string()); + } else { + exact.insert(trimmed.to_string()); + } + } + + if prefixes.is_empty() && exact.is_empty() { + return Ok(Self::DenyAll); + } + + Ok(Self::Patterns { prefixes, exact }) + } + + /// Returns `true` if the given capability path is allowed by this allow list. + /// + /// The capability path is constructed as `{namespace}/{operation}`, matching + /// the waPC host callback namespace and operation parameters. + pub fn is_allowed(&self, capability_path: &str) -> bool { + match self { + Self::AllowAll => true, + Self::DenyAll => false, + Self::Patterns { exact, prefixes } => { + exact.contains(capability_path) + || prefixes + .iter() + .any(|p| capability_path.starts_with(p.as_str())) + } + } + } +} + +impl TryFrom> for HostCapabilities { + type Error = HostCapabilitiesPatternError; + + fn try_from(patterns: Vec) -> Result { + Self::new(patterns) + } +} + +impl From for Vec { + fn from(allow_list: HostCapabilities) -> Self { + match allow_list { + HostCapabilities::AllowAll => vec!["*".to_string()], + HostCapabilities::DenyAll => vec![], + HostCapabilities::Patterns { prefixes, exact } => { + let mut result: Vec = + prefixes.into_iter().map(|p| format!("{p}*")).collect(); + result.sort(); + let mut exact_sorted: Vec = exact.into_iter().collect(); + exact_sorted.sort(); + result.extend(exact_sorted); + result + } + } + } +} + +impl fmt::Display for HostCapabilities { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::AllowAll => write!(f, "[*]"), + Self::DenyAll => write!(f, "[]"), + Self::Patterns { prefixes, exact } => { + let mut items: Vec = prefixes.iter().map(|p| format!("{p}*")).collect(); + items.sort(); + let mut exact_sorted: Vec<&String> = exact.iter().collect(); + exact_sorted.sort(); + items.extend(exact_sorted.into_iter().cloned()); + write!(f, "[{}]", items.join(", ")) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[rstest] + #[case::empty_allows_nothing(vec![], "oci/v1/verify", false)] + #[case::wildcard_allows_all(vec!["*"], "oci/v1/verify", true)] + #[case::wildcard_allows_all_2(vec!["*"], "kubernetes/can_i", true)] + #[case::exact_match(vec!["oci/v1/verify"], "oci/v1/verify", true)] + #[case::exact_no_match(vec!["oci/v1/verify"], "oci/v2/verify", false)] + #[case::prefix_oci(vec!["oci/*"], "oci/v1/verify", true)] + #[case::prefix_oci_v2(vec!["oci/*"], "oci/v2/verify", true)] + #[case::prefix_oci_manifest(vec!["oci/*"], "oci/v1/manifest_digest", true)] + #[case::prefix_no_match(vec!["oci/*"], "net/v1/dns_lookup_host", false)] + #[case::prefix_versioned(vec!["oci/v2/*"], "oci/v2/verify", true)] + #[case::prefix_versioned_no_match(vec!["oci/v2/*"], "oci/v1/verify", false)] + #[case::multiple_patterns( + vec!["oci/v1/verify", "net/v1/dns_lookup_host"], + "oci/v1/verify", + true + )] + #[case::multiple_patterns_second( + vec!["oci/v1/verify", "net/v1/dns_lookup_host"], + "net/v1/dns_lookup_host", + true + )] + #[case::multiple_patterns_no_match( + vec!["oci/v1/verify", "net/v1/dns_lookup_host"], + "kubernetes/can_i", + false + )] + #[case::mixed_prefix_and_exact( + vec!["oci/*", "kubernetes/can_i"], + "oci/v1/manifest_digest", + true + )] + #[case::mixed_prefix_and_exact_2( + vec!["oci/*", "kubernetes/can_i"], + "kubernetes/can_i", + true + )] + #[case::mixed_no_match( + vec!["oci/*", "kubernetes/can_i"], + "net/v1/dns_lookup_host", + false + )] + fn is_capability_allowed( + #[case] patterns: Vec<&str>, + #[case] capability: &str, + #[case] expected: bool, + ) { + let allow_list = HostCapabilities::new(patterns).expect("patterns should be valid"); + assert_eq!( + allow_list.is_allowed(capability), + expected, + "capability={capability}" + ); + } + + #[rstest] + #[case::partial_wildcard("oci*")] + #[case::mid_wildcard("oci/v1/oci_*")] + #[case::wildcard_not_last("*/oci")] + #[case::empty("")] + fn invalid_syntax_patterns(#[case] pattern: &str) { + let result = HostCapabilities::new([pattern]); + assert!(result.is_err(), "pattern {pattern:?} should be invalid"); + } + + #[rstest] + #[case::unknown_namespace("unknown/v1/op")] + #[case::unknown_version("oci/v99/verify")] + #[case::unknown_operation("oci/v1/nonexistent")] + #[case::unknown_kubernetes_op("kubernetes/nonexistent")] + #[case::unknown_namespace_wildcard("unknown/*")] + #[case::unknown_version_wildcard("oci/v99/*")] + #[case::incomplete_path_oci("oci")] + #[case::incomplete_path_oci_v1("oci/v1")] + #[case::incomplete_path_net("net")] + #[case::incomplete_path_kubernetes("kubernetes")] + fn invalid_tree_patterns(#[case] pattern: &str) { + let result = HostCapabilities::new([pattern]); + assert!(result.is_err(), "pattern {pattern:?} should be invalid"); + } + + #[test] + fn unknown_segment_error_lists_valid_options() { + let err = HostCapabilities::new(["oci/v99/verify"]).unwrap_err(); + assert!( + matches!(err, HostCapabilitiesPatternError::UnknownSegment { ref segment, .. } if segment == "v99"), + "unexpected error: {err}" + ); + // The error message should mention the valid versions + let msg = err.to_string(); + assert!(msg.contains("v1"), "error should mention v1: {msg}"); + assert!(msg.contains("v2"), "error should mention v2: {msg}"); + } + + #[test] + fn incomplete_path_error_suggests_wildcard() { + let err = HostCapabilities::new(["oci/v1"]).unwrap_err(); + assert!( + matches!( + err, + HostCapabilitiesPatternError::IncompleteCapabilityPath { + ref suggestion, + .. + } if suggestion == "oci/v1/*" + ), + "unexpected error: {err}" + ); + } + + #[rstest] + #[case::standalone(vec!["*"])] + #[case::wildcard_with_others(vec!["*", "oci/*", "kubernetes/can_i"])] + fn wildcard_parses_to_allow_all(#[case] patterns: Vec<&str>) { + let result = HostCapabilities::new(patterns); + assert_eq!(result.unwrap(), HostCapabilities::AllowAll); + } + + #[test] + fn valid_patterns_parse() { + let patterns = vec![ + "oci/*", + "oci/v2/*", + "oci/v1/verify", + "oci/v1/manifest_digest", + "oci/v1/oci_manifest", + "oci/v1/oci_manifest_config", + "net/v1/dns_lookup_host", + "net/*", + "crypto/v1/is_certificate_trusted", + "crypto/*", + "kubernetes/can_i", + "kubernetes/get_resource", + "kubernetes/list_resources_all", + "kubernetes/list_resources_by_namespace", + "kubernetes/*", + ]; + let result = HostCapabilities::new(patterns); + assert!( + result.is_ok(), + "unexpected error: {:?}", + result.unwrap_err() + ); + } + + #[rstest] + #[case::oci("oci/v1/verify")] + #[case::kubernetes("kubernetes/can_i")] + fn deny_all_denies_all(#[case] capability: &str) { + assert!(!HostCapabilities::DenyAll.is_allowed(capability)); + } + + #[test] + fn serde_roundtrip() { + let patterns = vec!["oci/*".to_string(), "kubernetes/can_i".to_string()]; + let allow_list = HostCapabilities::new(patterns).unwrap(); + let json = serde_json::to_string(&allow_list).unwrap(); + let deserialized: HostCapabilities = serde_json::from_str(&json).unwrap(); + assert_eq!(allow_list, deserialized); + } + + #[test] + fn enumerate_operations_returns_all_known_leaf_paths() { + let ops = HostCapabilities::enumerate_operations(); + + // Every result must be parseable as a valid exact pattern. + for (ns, op) in &ops { + let path = format!("{ns}/{op}"); + assert!( + HostCapabilities::new([&path]).is_ok(), + "{path:?} returned by enumerate_operations is not a valid capability path" + ); + } + + // The expected complete set of leaf paths, kept in sync with CAPABILITY_TREE. + let mut expected: Vec<(String, String)> = vec![ + ("crypto", "v1/is_certificate_trusted"), + ("kubernetes", "can_i"), + ("kubernetes", "get_resource"), + ("kubernetes", "list_resources_all"), + ("kubernetes", "list_resources_by_namespace"), + ("net", "v1/dns_lookup_host"), + ("oci", "v1/manifest_digest"), + ("oci", "v1/oci_manifest"), + ("oci", "v1/oci_manifest_config"), + ("oci", "v1/verify"), + ("oci", "v2/verify"), + ] + .into_iter() + .map(|(ns, op)| (ns.to_string(), op.to_string())) + .collect(); + expected.sort(); + + assert_eq!(ops, expected); + } + + #[rstest] + #[case::allow_all(HostCapabilities::AllowAll, "[*]")] + #[case::deny_all(HostCapabilities::DenyAll, "[]")] + #[case::patterns( + HostCapabilities::new(["oci/*", "kubernetes/can_i"]).unwrap(), + "[oci/*, kubernetes/can_i]" + )] + fn display(#[case] allow_list: HostCapabilities, #[case] expected: &str) { + assert_eq!(allow_list.to_string(), expected); + } +} diff --git a/crates/policy-evaluator/src/lib.rs b/crates/policy-evaluator/src/lib.rs index a3db38307..79b46b8bb 100644 --- a/crates/policy-evaluator/src/lib.rs +++ b/crates/policy-evaluator/src/lib.rs @@ -8,6 +8,7 @@ pub mod callback_requests; pub mod constants; pub mod errors; pub mod evaluation_context; +pub mod host_capabilities; pub mod policy_artifacthub; pub mod policy_evaluator; pub mod policy_group_evaluator; diff --git a/crates/policy-evaluator/src/policy_artifacthub.rs b/crates/policy-evaluator/src/policy_artifacthub.rs index 868128549..4080745ea 100644 --- a/crates/policy-evaluator/src/policy_artifacthub.rs +++ b/crates/policy-evaluator/src/policy_artifacthub.rs @@ -533,6 +533,7 @@ mod tests { execution_mode: Default::default(), policy_type: PolicyType::Kubernetes, minimum_kubewarden_version: None, + host_capabilities: None, } } @@ -602,6 +603,7 @@ mod tests { execution_mode: Default::default(), minimum_kubewarden_version: None, policy_type: Default::default(), + host_capabilities: None, } } diff --git a/crates/policy-evaluator/src/policy_group_evaluator.rs b/crates/policy-evaluator/src/policy_group_evaluator.rs index 790623e23..1d77a64e8 100644 --- a/crates/policy-evaluator/src/policy_group_evaluator.rs +++ b/crates/policy-evaluator/src/policy_group_evaluator.rs @@ -9,8 +9,8 @@ pub mod errors; pub mod evaluator; use crate::{ - admission_response::AdmissionResponse, policy_evaluator::PolicySettings, - policy_metadata::ContextAwareResource, + admission_response::AdmissionResponse, host_capabilities::HostCapabilities, + policy_evaluator::PolicySettings, policy_metadata::ContextAwareResource, }; /// The settings of a policy group member @@ -22,6 +22,8 @@ pub struct PolicyGroupMemberSettings { pub ctx_aware_resources_allow_list: BTreeSet, /// The epoch deadlines to be used when executing this policy member pub epoch_deadline: Option, + /// The list of host capabilities granted to this policy member + pub host_capabilities: HostCapabilities, } /// This holds the a summary of the evaluation results of a policy group member @@ -74,6 +76,7 @@ impl TryFrom<&PolicyGroupMemberWithContext> for PolicyGroupMemberSettings { .timeout_eval_seconds .as_ref() .map(|t| Into::::into(t) as u64), + host_capabilities: HostCapabilities::DenyAll, }) } } @@ -91,6 +94,7 @@ impl TryFrom<&PolicyGroupMember> for PolicyGroupMemberSettings { .timeout_eval_seconds .as_ref() .map(|t| Into::::into(t) as u64), + host_capabilities: HostCapabilities::DenyAll, }) } } diff --git a/crates/policy-evaluator/src/policy_group_evaluator/evaluator.rs b/crates/policy-evaluator/src/policy_group_evaluator/evaluator.rs index 406e89890..61690e805 100644 --- a/crates/policy-evaluator/src/policy_group_evaluator/evaluator.rs +++ b/crates/policy-evaluator/src/policy_group_evaluator/evaluator.rs @@ -9,13 +9,15 @@ use rhai::EvalAltResult; use tokio::sync::mpsc; use tracing::debug; -use crate::admission_response::{self, AdmissionResponse, AdmissionResponseStatus}; -use crate::callback_requests::CallbackRequest; -use crate::evaluation_context::EvaluationContext; -use crate::policy_evaluator::{PolicyEvaluatorPre, ValidateRequest}; -use crate::policy_group_evaluator::{ - PolicyGroupMemberEvaluationResult, PolicyGroupMemberSettings, - errors::{EvaluationError, Result}, +use crate::{ + admission_response::{self, AdmissionResponse, AdmissionResponseStatus}, + callback_requests::CallbackRequest, + evaluation_context::EvaluationContext, + policy_evaluator::{PolicyEvaluatorPre, ValidateRequest}, + policy_group_evaluator::{ + PolicyGroupMemberEvaluationResult, PolicyGroupMemberSettings, + errors::{EvaluationError, Result}, + }, }; /// PolicyGroupEvaluator is an evaluator that can evaluate a group of policies @@ -260,6 +262,7 @@ impl PolicyGroupEvaluator { callback_channel: self.callback_channel.clone(), ctx_aware_resources_allow_list: settings.ctx_aware_resources_allow_list.clone(), epoch_deadline: settings.epoch_deadline, + host_capabilities: settings.host_capabilities.clone(), }; let mut evaluator = evaluator_pre.rehydrate(&eval_ctx).map_err(|e| { EvaluationError::CannotRehydratePolicyGroupMember(policy_id.to_owned(), e) @@ -334,6 +337,7 @@ impl PolicyGroupEvaluator { callback_channel: self.callback_channel.clone(), ctx_aware_resources_allow_list: settings.ctx_aware_resources_allow_list.clone(), epoch_deadline: settings.epoch_deadline, + host_capabilities: settings.host_capabilities.clone(), }; let mut evaluator = evaluator_pre.rehydrate(&eval_ctx).map_err(|e| { EvaluationError::CannotRehydratePolicyGroupMember(policy_id.to_owned(), e) @@ -368,7 +372,7 @@ mod tests { use wasmtime::Engine; use crate::{ - admission_request::AdmissionRequest, + admission_request::AdmissionRequest, host_capabilities::HostCapabilities, policy_evaluator::policy_evaluator_builder::PolicyEvaluatorBuilder, }; @@ -473,6 +477,7 @@ mod tests { settings: Default::default(), ctx_aware_resources_allow_list: Default::default(), epoch_deadline: None, + host_capabilities: HostCapabilities::AllowAll, }, ); } @@ -551,6 +556,7 @@ mod tests { settings: Default::default(), ctx_aware_resources_allow_list: Default::default(), epoch_deadline: None, + host_capabilities: HostCapabilities::AllowAll, }, ); } diff --git a/crates/policy-evaluator/src/policy_metadata.rs b/crates/policy-evaluator/src/policy_metadata.rs index 521472e08..91054f9d4 100644 --- a/crates/policy-evaluator/src/policy_metadata.rs +++ b/crates/policy-evaluator/src/policy_metadata.rs @@ -216,6 +216,8 @@ pub struct Metadata { #[validate(nested)] pub context_aware_resources: BTreeSet, #[serde(skip_serializing_if = "Option::is_none")] + pub host_capabilities: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub minimum_kubewarden_version: Option, } @@ -234,6 +236,7 @@ impl Default for Metadata { execution_mode: PolicyExecutionMode::KubewardenWapc, policy_type: PolicyType::Kubernetes, context_aware_resources: BTreeSet::new(), + host_capabilities: None, minimum_kubewarden_version: None, } } diff --git a/crates/policy-evaluator/src/runtimes/callback.rs b/crates/policy-evaluator/src/runtimes/callback.rs index cdc70eff5..85b6d9b80 100644 --- a/crates/policy-evaluator/src/runtimes/callback.rs +++ b/crates/policy-evaluator/src/runtimes/callback.rs @@ -11,8 +11,10 @@ use kubewarden_policy_sdk::host_capabilities::{ use tokio::sync::{mpsc, oneshot, oneshot::Receiver}; use tracing::{debug, error}; -use crate::callback_requests::{CallbackRequest, CallbackRequestType, CallbackResponse}; -use crate::evaluation_context::EvaluationContext; +use crate::{ + callback_requests::{CallbackRequest, CallbackRequestType, CallbackResponse}, + evaluation_context::EvaluationContext, +}; fn unknown_operation( namespace: &str, @@ -27,6 +29,23 @@ fn unknown_namespace(namespace: &str) -> Result, Box Result, Box> { + error!( + policy = policy_id, + capability = capability_path, + allowed_capabilities = %eval_ctx.host_capabilities, + "Policy tried to use a host capability it doesn't have access to" + ); + Err(format!( + "Policy has not been granted access to the '{capability_path}' host capability. The violation has been reported." + ) + .into()) +} + /// The callback function used by waPC and Wasi policies to use host capabilities pub(crate) fn host_callback( binding: &str, @@ -35,310 +54,318 @@ pub(crate) fn host_callback( payload: &[u8], eval_ctx: &Arc, ) -> Result, Box> { - match binding { - "kubewarden" => match namespace { - "tracing" => match operation { - "log" => { - if let Err(e) = eval_ctx.log(payload) { - error!( - payload = String::from_utf8_lossy(payload).to_string(), - error = e.to_string(), - "Cannot log event" - ); - } - Ok(Vec::new()) - } - _ => unknown_operation(namespace, operation), - }, - "oci" => match operation { - "v1/verify" => { - let req: SigstoreVerificationInputV1 = serde_json::from_slice(payload)?; - let req_type: CallbackRequestType = req.into(); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: req_type, - response_channel: tx, - }; + if binding != "kubewarden" { + error!(binding, "unknown binding"); + return Err(format!("unknown binding: {binding}").into()); + } - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) - } - "v2/verify" => { - let req: SigstoreVerificationInputV2 = serde_json::from_slice(payload)?; - let req_type: CallbackRequestType = req.into(); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: req_type, - response_channel: tx, - }; + // "tracing" is not gated by host capabilities; all other namespaces are. + // Check if host capability is allowed. + if namespace != "tracing" { + let capability_path = format!("{namespace}/{operation}"); + if !eval_ctx.can_access_host_capability(&capability_path) { + return host_capability_denied(&eval_ctx.policy_id, &capability_path, eval_ctx); + } + } - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) - } - "v1/manifest_digest" => { - let image: String = serde_json::from_slice(payload)?; - debug!( - eval_ctx.policy_id, - binding, operation, image, "Sending request via callback channel" - ); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: CallbackRequestType::OciManifestDigest { image }, - response_channel: tx, - }; - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) - } - "v1/oci_manifest" => { - let image: String = serde_json::from_slice(payload)?; - debug!( - eval_ctx.policy_id, - binding, operation, image, "Sending request via callback channel" - ); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: CallbackRequestType::OciManifest { image }, - response_channel: tx, - }; - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) - } - "v1/oci_manifest_config" => { - let image: String = serde_json::from_slice(payload)?; - debug!( - eval_ctx.policy_id, - binding, operation, image, "Sending request via callback channel" + match namespace { + "tracing" => match operation { + "log" => { + if let Err(e) = eval_ctx.log(payload) { + error!( + payload = String::from_utf8_lossy(payload).to_string(), + error = e.to_string(), + "Cannot log event" ); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: CallbackRequestType::OciManifestAndConfig { image }, - response_channel: tx, - }; - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) } - _ => unknown_operation(namespace, operation), - }, - "net" => match operation { - "v1/dns_lookup_host" => { - let host: String = serde_json::from_slice(payload)?; - debug!( - eval_ctx.policy_id, - binding, operation, host, "Sending request via callback channel" - ); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: CallbackRequestType::DNSLookupHost { host }, - response_channel: tx, - }; - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) - } - _ => unknown_operation(namespace, operation), - }, - "crypto" => match operation { - "v1/is_certificate_trusted" => { - let req: CertificateVerificationRequest = serde_json::from_slice(payload)?; + Ok(Vec::new()) + } + _ => unknown_operation(namespace, operation), + }, + "oci" => match operation { + "v1/verify" => { + let req: SigstoreVerificationInputV1 = serde_json::from_slice(payload)?; + let req_type: CallbackRequestType = req.into(); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: req_type, + response_channel: tx, + }; - debug!( - eval_ctx.policy_id, - binding, - operation, - ?req, - "Sending request via callback channel" - ); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: CallbackRequestType::from(req), - response_channel: tx, - }; - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) - } - _ => unknown_operation(namespace, operation), - }, - "kubernetes" => match operation { - "list_resources_by_namespace" => { - let req: ListResourcesByNamespaceRequest = serde_json::from_slice(payload)?; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + "v2/verify" => { + let req: SigstoreVerificationInputV2 = serde_json::from_slice(payload)?; + let req_type: CallbackRequestType = req.into(); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: req_type, + response_channel: tx, + }; - if !eval_ctx.can_access_kubernetes_resource(&req.api_version, &req.kind) { - error!( - policy = eval_ctx.policy_id, - resource_requested = format!("{}/{}", req.api_version, req.kind), - resources_allowed = ?eval_ctx.ctx_aware_resources_allow_list, - "Policy tried to access a Kubernetes resource it doesn't have access to"); - return Err(format!( - "Policy has not been granted access to Kubernetes {}/{} resources. The violation has been reported.", - req.api_version, - req.kind).into()); - } + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + "v1/manifest_digest" => { + let image: String = serde_json::from_slice(payload)?; + debug!( + eval_ctx.policy_id, + binding, operation, image, "Sending request via callback channel" + ); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: CallbackRequestType::OciManifestDigest { image }, + response_channel: tx, + }; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + "v1/oci_manifest" => { + let image: String = serde_json::from_slice(payload)?; + debug!( + eval_ctx.policy_id, + binding, operation, image, "Sending request via callback channel" + ); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: CallbackRequestType::OciManifest { image }, + response_channel: tx, + }; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + "v1/oci_manifest_config" => { + let image: String = serde_json::from_slice(payload)?; + debug!( + eval_ctx.policy_id, + binding, operation, image, "Sending request via callback channel" + ); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: CallbackRequestType::OciManifestAndConfig { image }, + response_channel: tx, + }; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + _ => unknown_operation(namespace, operation), + }, + "net" => match operation { + "v1/dns_lookup_host" => { + let host: String = serde_json::from_slice(payload)?; + debug!( + eval_ctx.policy_id, + binding, operation, host, "Sending request via callback channel" + ); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: CallbackRequestType::DNSLookupHost { host }, + response_channel: tx, + }; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + _ => unknown_operation(namespace, operation), + }, + "crypto" => match operation { + "v1/is_certificate_trusted" => { + let req: CertificateVerificationRequest = serde_json::from_slice(payload)?; - debug!( - eval_ctx.policy_id, - binding, - operation, - ?req, - "Sending request via callback channel" - ); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: CallbackRequestType::from(req), - response_channel: tx, - }; - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) - } - "list_resources_all" => { - let req: ListAllResourcesRequest = serde_json::from_slice(payload)?; - if !eval_ctx.can_access_kubernetes_resource(&req.api_version, &req.kind) { - error!( - policy = eval_ctx.policy_id, - resource_requested = format!("{}/{}", req.api_version, req.kind), - resources_allowed = ?eval_ctx.ctx_aware_resources_allow_list, - "Policy tried to access a Kubernetes resource it doesn't have access to"); - return Err(format!( - "Policy has not been granted access to Kubernetes {}/{} resources. The violation has been reported.", - req.api_version, - req.kind).into()); - } + debug!( + eval_ctx.policy_id, + binding, + operation, + ?req, + "Sending request via callback channel" + ); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: CallbackRequestType::from(req), + response_channel: tx, + }; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + _ => unknown_operation(namespace, operation), + }, + "kubernetes" => match operation { + "list_resources_by_namespace" => { + let req: ListResourcesByNamespaceRequest = serde_json::from_slice(payload)?; - debug!( - eval_ctx.policy_id, - binding, - operation, - ?req, - "Sending request via callback channel" - ); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: CallbackRequestType::from(req), - response_channel: tx, - }; - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) + if !eval_ctx.can_access_kubernetes_resource(&req.api_version, &req.kind) { + error!( + policy = eval_ctx.policy_id, + resource_requested = format!("{}/{}", req.api_version, req.kind), + resources_allowed = ?eval_ctx.ctx_aware_resources_allow_list, + "Policy tried to access a Kubernetes resource it doesn't have access to"); + return Err(format!( + "Policy has not been granted access to Kubernetes {}/{} resources. The violation has been reported.", + req.api_version, + req.kind).into()); } - "get_resource" => { - let req: GetResourceRequest = serde_json::from_slice(payload)?; - if !eval_ctx.can_access_kubernetes_resource(&req.api_version, &req.kind) { - error!( - policy = eval_ctx.policy_id, - resource_requested = format!("{}/{}", req.api_version, req.kind), - resources_allowed = ?eval_ctx.ctx_aware_resources_allow_list, - "Policy tried to access a Kubernetes resource it doesn't have access to"); - return Err(format!( - "Policy has not been granted access to Kubernetes {}/{} resources. The violation has been reported.", - req.api_version, - req.kind).into()); - } - debug!( - eval_ctx.policy_id, - binding, - operation, - ?req, - "Sending request via callback channel" - ); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: CallbackRequestType::from(req), - response_channel: tx, - }; - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) + debug!( + eval_ctx.policy_id, + binding, + operation, + ?req, + "Sending request via callback channel" + ); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: CallbackRequestType::from(req), + response_channel: tx, + }; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + "list_resources_all" => { + let req: ListAllResourcesRequest = serde_json::from_slice(payload)?; + if !eval_ctx.can_access_kubernetes_resource(&req.api_version, &req.kind) { + error!( + policy = eval_ctx.policy_id, + resource_requested = format!("{}/{}", req.api_version, req.kind), + resources_allowed = ?eval_ctx.ctx_aware_resources_allow_list, + "Policy tried to access a Kubernetes resource it doesn't have access to"); + return Err(format!( + "Policy has not been granted access to Kubernetes {}/{} resources. The violation has been reported.", + req.api_version, + req.kind).into()); } - "can_i" => { - let req: CanIRequest = serde_json::from_slice(payload)?; - debug!( - eval_ctx.policy_id, - binding, - namespace, - operation, - ?req, - "Sending request via callback channel" - ); - let (tx, rx) = oneshot::channel::>(); - let req = CallbackRequest { - request: CallbackRequestType::from(req), - response_channel: tx, - }; - send_request_and_wait_for_response( - &eval_ctx.policy_id, - binding, - operation, - req, - rx, - eval_ctx, - ) + debug!( + eval_ctx.policy_id, + binding, + operation, + ?req, + "Sending request via callback channel" + ); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: CallbackRequestType::from(req), + response_channel: tx, + }; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + "get_resource" => { + let req: GetResourceRequest = serde_json::from_slice(payload)?; + if !eval_ctx.can_access_kubernetes_resource(&req.api_version, &req.kind) { + error!( + policy = eval_ctx.policy_id, + resource_requested = format!("{}/{}", req.api_version, req.kind), + resources_allowed = ?eval_ctx.ctx_aware_resources_allow_list, + "Policy tried to access a Kubernetes resource it doesn't have access to"); + return Err(format!( + "Policy has not been granted access to Kubernetes {}/{} resources. The violation has been reported.", + req.api_version, + req.kind).into()); } - _ => unknown_operation(namespace, operation), - }, - _ => unknown_namespace(namespace), + + debug!( + eval_ctx.policy_id, + binding, + operation, + ?req, + "Sending request via callback channel" + ); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: CallbackRequestType::from(req), + response_channel: tx, + }; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + "can_i" => { + let req: CanIRequest = serde_json::from_slice(payload)?; + + debug!( + eval_ctx.policy_id, + binding, + namespace, + operation, + ?req, + "Sending request via callback channel" + ); + let (tx, rx) = oneshot::channel::>(); + let req = CallbackRequest { + request: CallbackRequestType::from(req), + response_channel: tx, + }; + send_request_and_wait_for_response( + &eval_ctx.policy_id, + binding, + operation, + req, + rx, + eval_ctx, + ) + } + _ => unknown_operation(namespace, operation), }, - _ => { - error!(binding, "unknown binding"); - Err(format!("unknown binding: {binding}").into()) - } + _ => unknown_namespace(namespace), } } @@ -396,3 +423,134 @@ fn send_request_and_wait_for_response( } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::sync::Arc; + + use rstest::rstest; + + use crate::evaluation_context::EvaluationContext; + use crate::host_capabilities::HostCapabilities; + + use super::host_callback; + + fn deny_all_ctx() -> Arc { + Arc::new(EvaluationContext { + policy_id: "test-policy".to_owned(), + callback_channel: None, + ctx_aware_resources_allow_list: BTreeSet::new(), + epoch_deadline: None, + host_capabilities: HostCapabilities::DenyAll, + }) + } + + fn allow_all_ctx() -> Arc { + Arc::new(EvaluationContext { + policy_id: "test-policy".to_owned(), + callback_channel: None, // None so allowed calls fail fast at channel send, not capability check + ctx_aware_resources_allow_list: BTreeSet::new(), + epoch_deadline: None, + host_capabilities: HostCapabilities::AllowAll, + }) + } + + #[rstest] + #[case("oci", "v1/verify")] + #[case("oci", "v2/verify")] + #[case("oci", "v1/manifest_digest")] + #[case("oci", "v1/oci_manifest")] + #[case("oci", "v1/oci_manifest_config")] + #[case("net", "v1/dns_lookup_host")] + #[case("crypto", "v1/is_certificate_trusted")] + #[case("kubernetes", "list_resources_by_namespace")] + #[case("kubernetes", "list_resources_all")] + #[case("kubernetes", "get_resource")] + #[case("kubernetes", "can_i")] + fn host_capability_denied_returns_denial_error( + #[case] namespace: &str, + #[case] operation: &str, + ) { + let ctx = deny_all_ctx(); + + // The capability check fires before payload deserialisation, so an empty + // payload is valid for all cases here. + let result = host_callback("kubewarden", namespace, operation, b"", &ctx); + + let err = result.expect_err("expected Err for denied capability"); + assert!( + err.to_string().contains("has not been granted access"), + "namespace={namespace}, operation={operation}: unexpected error: {err}" + ); + } + + #[rstest] + // oci: v1/verify uses externally-tagged SigstoreVerificationInputV1 + #[case( + "oci", + "v1/verify", + br#"{"SigstorePubKeyVerify":{"image":"ghcr.io/example/image:latest","pub_keys":[],"annotations":null}}"#.as_slice() + )] + // oci: v2/verify uses internally-tagged SigstoreVerificationInputV2 + #[case( + "oci", + "v2/verify", + br#"{"type":"SigstorePubKeyVerify","image":"ghcr.io/example/image:latest","pub_keys":[],"annotations":null}"#.as_slice() + )] + // oci: remaining operations take a JSON-encoded image reference string + #[case("oci", "v1/manifest_digest", br#""ghcr.io/example/image:latest""#.as_slice())] + #[case("oci", "v1/oci_manifest", br#""ghcr.io/example/image:latest""#.as_slice())] + #[case("oci", "v1/oci_manifest_config", br#""ghcr.io/example/image:latest""#.as_slice())] + // net: payload is a JSON-encoded hostname string + #[case("net", "v1/dns_lookup_host", br#""example.com""#.as_slice())] + // crypto: minimal CertificateVerificationRequest + #[case( + "crypto", + "v1/is_certificate_trusted", + br#"{"cert":{"encoding":"Pem","data":[]},"cert_chain":null,"not_after":null}"#.as_slice() + )] + // kubernetes: list/get operations also have a ctx_aware_resources check after the + // capability gate; with an empty allow-list the function returns a *kubernetes* + // resource denial rather than a host-capability denial, confirming the capability + // gate was cleared. + #[case( + "kubernetes", + "list_resources_by_namespace", + br#"{"api_version":"v1","kind":"Pod","namespace":"default","label_selector":null,"field_selector":null,"field_masks":null}"#.as_slice() + )] + #[case( + "kubernetes", + "list_resources_all", + br#"{"api_version":"v1","kind":"Pod","label_selector":null,"field_selector":null,"field_masks":null}"#.as_slice() + )] + #[case( + "kubernetes", + "get_resource", + br#"{"api_version":"v1","kind":"Pod","name":"test","namespace":"default","disable_cache":false}"#.as_slice() + )] + // kubernetes/can_i: no ctx_aware_resources check; proceeds straight to channel send + #[case( + "kubernetes", + "can_i", + br#"{"subject_access_review":{"groups":null,"resource_attributes":{"group":null,"name":null,"namespace":null,"resource":"pods","subresource":null,"verb":"get","version":null},"user":"test"},"disable_cache":false}"#.as_slice() + )] + fn host_capability_allowed_proceeds_past_capability_check( + #[case] namespace: &str, + #[case] operation: &str, + #[case] payload: &[u8], + ) { + let ctx = allow_all_ctx(); + let result = host_callback("kubewarden", namespace, operation, payload, &ctx); + + // The capability check passes; the function then fails for a different reason + // (channel send, or kubernetes resource check). Either way the error must NOT + // be a host-capability denial. + let err = result.expect_err("expected Err because callback channel is None"); + let msg = err.to_string(); + assert!( + !msg.contains("host capability"), + "namespace={namespace}, operation={operation}: should not be a host-capability denial, got: {msg}" + ); + } +} diff --git a/crates/policy-evaluator/src/runtimes/rego/runtime.rs b/crates/policy-evaluator/src/runtimes/rego/runtime.rs index ebbe16a56..411a1ec63 100644 --- a/crates/policy-evaluator/src/runtimes/rego/runtime.rs +++ b/crates/policy-evaluator/src/runtimes/rego/runtime.rs @@ -4,13 +4,15 @@ use serde::Deserialize; use serde_json::json; use tracing::{error, warn}; -use crate::runtimes::rego::{ - Stack, context_aware, context_aware::KubernetesContext, errors::RegoRuntimeError, -}; use crate::{ admission_request, admission_response::{AdmissionResponse, AdmissionResponseStatus}, policy_evaluator::{PolicySettings, RegoPolicyExecutionMode, ValidateRequest}, + runtimes::rego::{ + Stack, + context_aware::{self, KubernetesContext}, + errors::RegoRuntimeError, + }, }; pub(crate) struct Runtime<'a>(pub(crate) &'a mut Stack); @@ -216,7 +218,7 @@ impl Runtime<'_> { .evaluate(self.0.entrypoint_id, &input, data_raw) } - pub fn validate_settings(&mut self, _settings: String) -> SettingsValidationResponse { + pub fn validate_settings(&self, _settings: String) -> SettingsValidationResponse { // The burrego backend is mainly for compatibility with // existing OPA policies. Those policies don't have a generic // way of validating settings. Return true diff --git a/crates/policy-evaluator/src/runtimes/wapc/runtime.rs b/crates/policy-evaluator/src/runtimes/wapc/runtime.rs index ad2f9544a..a04cbdfaa 100644 --- a/crates/policy-evaluator/src/runtimes/wapc/runtime.rs +++ b/crates/policy-evaluator/src/runtimes/wapc/runtime.rs @@ -130,7 +130,7 @@ impl Runtime<'_> { } } - pub fn validate_settings(&mut self, settings: String) -> SettingsValidationResponse { + pub fn validate_settings(&self, settings: String) -> SettingsValidationResponse { match self.0.call("validate_settings", settings.as_bytes()) { Ok(res) => { let vr: Result = serde_json::from_slice(&res) @@ -162,7 +162,8 @@ impl Runtime<'_> { mod tests { use super::*; use crate::{ - evaluation_context::EvaluationContext, runtimes::wapc::callback::new_host_callback, + evaluation_context::EvaluationContext, host_capabilities::HostCapabilities, + runtimes::wapc::callback::new_host_callback, }; use std::{ sync::{self, Arc}, @@ -207,6 +208,7 @@ mod tests { callback_channel: None, ctx_aware_resources_allow_list: Default::default(), epoch_deadline: Some(epoch_deadline), + host_capabilities: HostCapabilities::AllowAll, }; let eval_ctx = Arc::new(eval_ctx); diff --git a/crates/policy-evaluator/tests/integration_test.rs b/crates/policy-evaluator/tests/integration_test.rs index 037fd92b8..a79fade85 100644 --- a/crates/policy-evaluator/tests/integration_test.rs +++ b/crates/policy-evaluator/tests/integration_test.rs @@ -1,40 +1,38 @@ #![allow(clippy::too_many_arguments)] -mod common; -mod k8s_mock; +use std::{collections::BTreeSet, future::Future}; use anyhow::Result; use core::panic; use hyper::{Request, Response}; -use kube::Client; -use kube::client::Body; +use kube::{Client, client::Body}; use kubewarden_policy_sdk::host_capabilities::oci::ManifestDigestResponse; use policy_evaluator::admission_response::PatchType; -use policy_fetcher::oci_client::manifest::OciImageManifest; +use policy_fetcher::oci_client::manifest::{OciDescriptor, OciImageManifest, OciManifest}; use rstest::*; use serde_json::json; -use std::collections::BTreeSet; -use std::future::Future; -use tokio::sync::mpsc; -use tokio::sync::oneshot; +use tokio::sync::{mpsc, oneshot}; use tower_test::mock::Handle; -use policy_fetcher::oci_client::manifest::{OciDescriptor, OciManifest}; - use policy_evaluator::{ admission_request::AdmissionRequest, admission_response::AdmissionResponseStatus, callback_requests::{CallbackRequest, CallbackRequestType, CallbackResponse}, evaluation_context::EvaluationContext, - policy_evaluator::PolicySettings, - policy_evaluator::{PolicyExecutionMode, ValidateRequest}, + host_capabilities::HostCapabilities, + policy_evaluator::{PolicyExecutionMode, PolicySettings, ValidateRequest}, policy_metadata::ContextAwareResource, }; -use crate::common::{ - CONTEXT_AWARE_POLICY_FILE, build_policy_evaluator, fetch_policy, load_request_data, - setup_callback_handler, +mod common; +mod k8s_mock; + +use crate::{ + common::{ + CONTEXT_AWARE_POLICY_FILE, build_policy_evaluator, fetch_policy, load_request_data, + setup_callback_handler, + }, + k8s_mock::{no_op_scenario, rego_scenario, wapc_and_wasi_scenario}, }; -use crate::k8s_mock::{rego_scenario, wapc_and_wasi_scenario}; #[rstest] #[case::wapc( @@ -179,6 +177,7 @@ async fn test_policy_evaluator( callback_channel: None, ctx_aware_resources_allow_list: Default::default(), epoch_deadline: None, + host_capabilities: HostCapabilities::AllowAll, }; let mut policy_evaluator = build_policy_evaluator(execution_mode, &policy, &eval_ctx); @@ -292,6 +291,7 @@ async fn test_runtime_context_aware( }, ]), epoch_deadline: Some(2), + host_capabilities: HostCapabilities::AllowAll, }; let request_data = load_request_data(request_file_path); @@ -313,6 +313,104 @@ async fn test_runtime_context_aware( .expect("cannot send shutdown signal"); } +#[test_log::test(rstest)] +#[case::host_capability_allowed( + PolicyExecutionMode::KubewardenWapc, + &CONTEXT_AWARE_POLICY_FILE, + "app_deployment.json", + wapc_and_wasi_scenario, + HostCapabilities::AllowAll, + true, +)] +#[case::host_capability_denied( + PolicyExecutionMode::KubewardenWapc, + &CONTEXT_AWARE_POLICY_FILE, + "app_deployment.json", + no_op_scenario, + HostCapabilities::DenyAll, + false, +)] +#[tokio::test(flavor = "multi_thread")] +async fn test_host_capabilities( + #[case] execution_mode: PolicyExecutionMode, + #[case] policy_uri: &str, + #[case] request_file_path: &str, + #[case] scenario: F, + #[case] host_capabilities: HostCapabilities, + #[case] expected_allowed: bool, +) where + F: FnOnce(Handle, Response>) -> Fut, + Fut: Future, +{ + use kube::client::Body; + + let tempdir = tempfile::TempDir::new().expect("cannot create tempdir"); + let policy = fetch_policy(policy_uri, tempdir.path().to_owned()).await; + + let (mocksvc, handle) = tower_test::mock::pair::, Response>(); + let client = Client::new(mocksvc, "default"); + scenario(handle).await; + + let (callback_handler_shutdown_channel_tx, callback_handler_channel) = + setup_callback_handler(Some(client), None).await; + + let eval_ctx = EvaluationContext { + policy_id: "test".to_owned(), + callback_channel: Some(callback_handler_channel), + ctx_aware_resources_allow_list: BTreeSet::from([ + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Namespace".to_owned(), + }, + ContextAwareResource { + api_version: "apps/v1".to_owned(), + kind: "Deployment".to_owned(), + }, + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Service".to_owned(), + }, + ]), + epoch_deadline: Some(2), + host_capabilities, + }; + + let request_data = load_request_data(request_file_path); + let request: AdmissionRequest = + serde_json::from_slice(&request_data).expect("cannot deserialize request"); + + tokio::task::spawn_blocking(move || { + let mut policy_evaluator = build_policy_evaluator(execution_mode, &policy, &eval_ctx); + let admission_response = policy_evaluator.validate( + ValidateRequest::AdmissionRequest(Box::new(request)), + &PolicySettings::default(), + ); + + assert_eq!( + expected_allowed, admission_response.allowed, + "unexpected admission response: {:?}", + admission_response + ); + + if !expected_allowed { + let message = admission_response + .status + .and_then(|s| s.message) + .unwrap_or_default(); + assert!( + message.contains("has not been granted access"), + "expected host capability denial message, got: {message}" + ); + } + }) + .await + .unwrap(); + + callback_handler_shutdown_channel_tx + .send(()) + .expect("cannot send shutdown signal"); +} + #[rstest] #[case::policy( "ghcr.io/kubewarden/tests/context-aware-test-policy:latest", @@ -339,6 +437,7 @@ async fn test_oci_manifest_capability( callback_channel: Some(callback_handler_channel), ctx_aware_resources_allow_list: Default::default(), epoch_deadline: None, + host_capabilities: HostCapabilities::AllowAll, }; let cb_channel: mpsc::Sender = eval_ctx @@ -430,6 +529,7 @@ async fn test_oci_manifest_and_config_capability( callback_channel: Some(callback_handler_channel), ctx_aware_resources_allow_list: Default::default(), epoch_deadline: None, + host_capabilities: HostCapabilities::AllowAll, }; let cb_channel: mpsc::Sender = eval_ctx @@ -498,6 +598,7 @@ async fn test_oci_digest_capability() { callback_channel: Some(callback_handler_channel), ctx_aware_resources_allow_list: Default::default(), epoch_deadline: None, + host_capabilities: HostCapabilities::AllowAll, }; let cb_channel: mpsc::Sender = eval_ctx diff --git a/crates/policy-evaluator/tests/k8s_mock/mod.rs b/crates/policy-evaluator/tests/k8s_mock/mod.rs index c9d13690e..14a2dfbbf 100644 --- a/crates/policy-evaluator/tests/k8s_mock/mod.rs +++ b/crates/policy-evaluator/tests/k8s_mock/mod.rs @@ -141,6 +141,11 @@ pub(crate) async fn rego_scenario(handle: Handle, Response>) }); } +/// A no-op scenario that immediately drops the mock handle. +/// Use for test cases where no Kubernetes callbacks are expected +/// (e.g., when host capabilities are denied before any k8s call is made). +pub(crate) async fn no_op_scenario(_handle: Handle, Response>) {} + fn send_response(send: SendResponse>, response: T) { let response = serde_json::to_vec(&response).unwrap(); send.send_response(Response::builder().body(Body::from(response)).unwrap()); diff --git a/crates/policy-server/src/config.rs b/crates/policy-server/src/config.rs index 51000fbdf..497bf1252 100644 --- a/crates/policy-server/src/config.rs +++ b/crates/policy-server/src/config.rs @@ -1,3 +1,11 @@ +use std::{ + collections::{BTreeSet, HashMap}, + env, + fs::{self, File}, + net::SocketAddr, + path::{Path, PathBuf}, +}; + use anyhow::{Result, anyhow}; use clap::ArgMatches; use lazy_static::lazy_static; @@ -13,13 +21,6 @@ use policy_evaluator::{ policy_metadata::ContextAwareResource, }; use serde::Deserialize; -use std::{ - collections::{BTreeSet, HashMap}, - env, - fs::{self, File}, - net::SocketAddr, - path::{Path, PathBuf}, -}; pub static SERVICE_NAME: &str = "kubewarden-policy-server"; const DOCKER_CONFIG_ENV_VAR: &str = "DOCKER_CONFIG"; @@ -324,6 +325,9 @@ pub struct PolicyGroupMember { pub context_aware_resources: BTreeSet, /// Timeout for the evaluation of the policy pub timeout_eval_seconds: Option, + /// List of host capabilities granted to this policy + #[serde(default)] + pub host_capabilities: Vec, } impl PolicyGroupMember { @@ -357,6 +361,9 @@ pub enum PolicyOrPolicyGroup { message: Option, /// Timeout for the evaluation of the policy timeout_eval_seconds: Option, + /// The list of host capabilities granted to this policy + #[serde(default)] + host_capabilities: Vec, }, /// A group of policies that are evaluated together using a given expression #[serde(rename_all = "camelCase")] @@ -470,6 +477,8 @@ example: kind: Namespace - apiVersion: v1 kind: Pod + hostCapabilities: + - kubernetes/* group_policy: policyMode: monitor expression: "true" @@ -509,6 +518,7 @@ group_policy: ]), message: Some("my custom error message".to_owned()), timeout_eval_seconds: None, + host_capabilities: vec!["kubernetes/*".to_owned()], }, ), ( @@ -525,6 +535,7 @@ group_policy: settings: Some(PolicySettings::default()), context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ( @@ -534,6 +545,7 @@ group_policy: settings: Some(PolicySettings::default()), context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ]), diff --git a/crates/policy-server/src/evaluation/evaluation_environment.rs b/crates/policy-server/src/evaluation/evaluation_environment.rs index 344c50be0..73c10c75b 100644 --- a/crates/policy-server/src/evaluation/evaluation_environment.rs +++ b/crates/policy-server/src/evaluation/evaluation_environment.rs @@ -12,6 +12,7 @@ use policy_evaluator::{ }, callback_requests::CallbackRequest, evaluation_context::EvaluationContext, + host_capabilities::HostCapabilities, kubewarden_policy_sdk::settings::SettingsValidationResponse, policy_evaluator::{PolicyEvaluator, PolicyEvaluatorPre, PolicyExecutionMode, ValidateRequest}, policy_evaluator_builder::PolicyEvaluatorBuilder, @@ -73,6 +74,10 @@ pub(crate) struct EvaluationEnvironment { /// policy is allowed to access. policy_id_to_ctx_aware_allowed_resources: HashMap>, + /// A map with the ID of the policy as key, and the allowed host capabilities + /// as value. + policy_id_to_host_capabilities: HashMap, + /// Map a `policy_id` to the module's digest. /// This allows us to deduplicate the Wasm modules defined by the user. policy_id_to_module_digest: HashMap, @@ -208,6 +213,7 @@ impl<'engine, 'precompiled_policies> EvaluationEnvironmentBuilder<'engine, 'prec allowed_to_mutate, context_aware_resources, timeout_eval_seconds, + host_capabilities, .. } => { let policy_evaluation_settings = PolicyEvaluationSettings { @@ -221,11 +227,19 @@ impl<'engine, 'precompiled_policies> EvaluationEnvironmentBuilder<'engine, 'prec let epoch_deadline = timeout_eval_seconds.or(self.global_policy_evaluation_limit_seconds); + let host_capabilities = + HostCapabilities::new(host_capabilities).map_err(|e| { + EvaluationError::BootstrapFailure(format!( + "invalid hostCapabilities pattern for policy {id}: {e}" + )) + })?; + let eval_ctx = EvaluationContext { policy_id: id.to_string(), callback_channel: Some(self.callback_handler_tx.clone()), ctx_aware_resources_allow_list: context_aware_resources.to_owned(), epoch_deadline, + host_capabilities, }; if let Err(e) = self.bootstrap_policy( @@ -290,6 +304,15 @@ impl<'engine, 'precompiled_policies> EvaluationEnvironmentBuilder<'engine, 'prec .timeout_eval_seconds .or(self.global_policy_evaluation_limit_seconds); + let host_capabilities = HostCapabilities::new( + policy.host_capabilities.clone(), + ) + .map_err(|e| { + EvaluationError::BootstrapFailure(format!( + "invalid hostCapabilities pattern for policy {id}: {e}" + )) + })?; + let eval_ctx = EvaluationContext { policy_id: policy_id.to_string(), callback_channel: Some(self.callback_handler_tx.clone()), @@ -297,6 +320,7 @@ impl<'engine, 'precompiled_policies> EvaluationEnvironmentBuilder<'engine, 'prec .context_aware_resources .to_owned(), epoch_deadline, + host_capabilities, }; if let Err(e) = self.bootstrap_policy( @@ -420,6 +444,9 @@ impl EvaluationEnvironment { eval_ctx.ctx_aware_resources_allow_list, ); + self.policy_id_to_host_capabilities + .insert(policy_id.to_owned(), eval_ctx.host_capabilities); + Ok(()) } @@ -541,11 +568,18 @@ impl EvaluationEnvironment { .get(policy_id) .ok_or(EvaluationError::PolicyNotFound(policy_id.to_string()))?; + let host_capabilities = self + .policy_id_to_host_capabilities + .get(policy_id) + .cloned() + .unwrap_or(HostCapabilities::DenyAll); + let eval_ctx = EvaluationContext { policy_id: policy_id.to_string(), callback_channel: self.callback_handler_tx.clone(), ctx_aware_resources_allow_list: ctx_aware_resources_allow_list.clone(), epoch_deadline, + host_capabilities, }; policy_evaluator_pre.rehydrate(&eval_ctx).map_err(|e| { @@ -640,6 +674,12 @@ impl EvaluationEnvironment { .get(&policy_id) .ok_or(EvaluationError::PolicyNotFound(policy_id.to_string()))?; + let host_capabilities = self + .policy_id_to_host_capabilities + .get(&policy_id) + .cloned() + .unwrap_or(HostCapabilities::DenyAll); + let policy_settings = self.get_policy_settings(&policy_id)?; let settings = match policy_settings.settings { PolicyOrPolicyGroupSettings::Policy(settings) => settings, @@ -654,6 +694,7 @@ impl EvaluationEnvironment { settings, ctx_aware_resources_allow_list: ctx_aware_resources_allow_list.clone(), epoch_deadline, + host_capabilities, }; evaluator.add_policy_member( @@ -783,6 +824,7 @@ mod tests { context_aware_resources: BTreeSet::new(), message: None, timeout_eval_seconds: None, + host_capabilities: vec![], }, ); precompiled_policies.insert(policy_url, Ok(precompiled_policy.clone())); @@ -799,6 +841,7 @@ mod tests { context_aware_resources: BTreeSet::new(), message: None, timeout_eval_seconds: Some(5), + host_capabilities: vec![], }, ); @@ -814,6 +857,7 @@ mod tests { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, )] .into_iter() @@ -842,6 +886,7 @@ mod tests { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, )] .into_iter() @@ -880,6 +925,7 @@ mod tests { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, )] .into_iter() @@ -900,6 +946,7 @@ mod tests { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ( @@ -909,6 +956,7 @@ mod tests { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ( @@ -918,6 +966,7 @@ mod tests { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ] @@ -941,6 +990,7 @@ mod tests { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ( @@ -950,6 +1000,7 @@ mod tests { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ( @@ -959,6 +1010,7 @@ mod tests { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ] diff --git a/crates/policy-server/tests/common/mod.rs b/crates/policy-server/tests/common/mod.rs index cd3f50854..dc9464cd6 100644 --- a/crates/policy-server/tests/common/mod.rs +++ b/crates/policy-server/tests/common/mod.rs @@ -7,6 +7,7 @@ use std::{ use axum::Router; use policy_evaluator::admission_response_handler::policy_mode::PolicyMode; use policy_evaluator::policy_evaluator::PolicySettings; +use policy_evaluator::policy_metadata::ContextAwareResource; use policy_server::{ PolicyServer, config::{Config, PolicyGroupMember, PolicyOrPolicyGroup}, @@ -35,6 +36,7 @@ pub(crate) fn default_test_config() -> Config { context_aware_resources: BTreeSet::new(), message: None, timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ( @@ -53,6 +55,7 @@ pub(crate) fn default_test_config() -> Config { context_aware_resources: BTreeSet::new(), message: None, timeout_eval_seconds: None, + host_capabilities: vec![], }, ), ( @@ -62,6 +65,7 @@ pub(crate) fn default_test_config() -> Config { policy_mode: PolicyMode::Protect, allowed_to_mutate: None, timeout_eval_seconds: None, + host_capabilities: vec![], settings: Some( PolicySettings::try_from(&json!({ "sleepMilliseconds": 2 @@ -85,6 +89,7 @@ pub(crate) fn default_test_config() -> Config { settings: None, context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, )]), }, @@ -108,6 +113,7 @@ pub(crate) fn default_test_config() -> Config { ), context_aware_resources: BTreeSet::new(), timeout_eval_seconds: None, + host_capabilities: vec![], }, )]), }, @@ -119,6 +125,7 @@ pub(crate) fn default_test_config() -> Config { policy_mode: PolicyMode::Protect, allowed_to_mutate: None, timeout_eval_seconds: Some(1), + host_capabilities: vec![], settings: Some( PolicySettings::try_from(&json!({ "sleepMilliseconds": 2 @@ -147,6 +154,66 @@ pub(crate) fn pod_privileged_test_config() -> Config { context_aware_resources: BTreeSet::new(), message: None, timeout_eval_seconds: None, + host_capabilities: vec![], + }, + )]); + + let mut config = default_config(); + config.policies.extend(policies); + config +} + +/// Returns a Config with the context-aware test policy registered under +/// the given `policy_id`, using the provided `host_capabilities` and +/// `context_aware_resources`. +pub(crate) fn context_aware_policy_test_config( + policy_id: &str, + host_capabilities: Vec, + context_aware_resources: BTreeSet, +) -> Config { + let policies = HashMap::from([( + policy_id.to_owned(), + PolicyOrPolicyGroup::Policy { + module: "ghcr.io/kubewarden/tests/context-aware-test-policy:latest".to_owned(), + policy_mode: PolicyMode::Protect, + allowed_to_mutate: None, + settings: None, + context_aware_resources, + message: None, + timeout_eval_seconds: None, + host_capabilities, + }, + )]); + + let mut config = default_config(); + config.policies.extend(policies); + config +} + +/// Returns a Config with the context-aware test policy registered as a group policy +/// under the given `policy_id`. The single group member uses the provided +/// `host_capabilities` and `context_aware_resources`. +pub(crate) fn context_aware_policy_group_test_config( + policy_id: &str, + host_capabilities: Vec, + context_aware_resources: BTreeSet, +) -> Config { + let policies = HashMap::from([( + policy_id.to_owned(), + PolicyOrPolicyGroup::PolicyGroup { + expression: "ctx_aware_policy() && true".to_string(), + message: "group policy rejected".to_string(), + policy_mode: PolicyMode::Protect, + policies: HashMap::from([( + "ctx_aware_policy".to_string(), + PolicyGroupMember { + module: "ghcr.io/kubewarden/tests/context-aware-test-policy:latest".to_owned(), + settings: None, + context_aware_resources, + timeout_eval_seconds: None, + host_capabilities, + }, + )]), }, )]); diff --git a/crates/policy-server/tests/data/deployment_admission_review.json b/crates/policy-server/tests/data/deployment_admission_review.json new file mode 100644 index 000000000..ed4fdff4a --- /dev/null +++ b/crates/policy-server/tests/data/deployment_admission_review.json @@ -0,0 +1,79 @@ +{ + "apiVersion": "admission.k8s.io/v1", + "kind": "AdmissionReview", + "request": { + "uid": "8a1e598b-cc4b-49b7-a465-bee6204c18db", + "kind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "resource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "requestKind": { + "group": "apps", + "version": "v1", + "kind": "Deployment" + }, + "requestResource": { + "group": "apps", + "version": "v1", + "resource": "deployments" + }, + "name": "api", + "namespace": "customer-1", + "operation": "CREATE", + "userInfo": { + "username": "kubernetes-admin", + "groups": [ + "system:masters", + "system:authenticated" + ] + }, + "object": { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "name": "api", + "namespace": "customer-1", + "labels": { + "app": "api", + "app.kubernetes.io/component": "api", + "customer-id": "1" + } + }, + "spec": { + "replicas": 1, + "selector": { + "matchLabels": { + "app": "api" + } + }, + "template": { + "metadata": { + "labels": { + "app": "api", + "app.kubernetes.io/component": "api" + } + }, + "spec": { + "containers": [ + { + "name": "api", + "image": "api:1.0.0", + "ports": [ + { + "containerPort": 8080 + } + ] + } + ] + } + } + } + } + } +} diff --git a/crates/policy-server/tests/integration_test.rs b/crates/policy-server/tests/integration_test.rs index 424585e8c..4f5e0fcb8 100644 --- a/crates/policy-server/tests/integration_test.rs +++ b/crates/policy-server/tests/integration_test.rs @@ -1,27 +1,22 @@ -mod common; - -use std::path::PathBuf; use std::{ collections::{BTreeSet, HashMap}, + path::PathBuf, time::Duration, }; #[cfg(feature = "otel_tests")] use std::{fs::File, io::BufRead}; -use common::{app, setup}; - use axum::{ body::Body, http::{self, Request, header}, }; use backon::{ExponentialBuilder, Retryable}; use http_body_util::BodyExt; -use policy_evaluator::admission_response::{self, StatusCause, StatusDetails}; use policy_evaluator::{ - admission_response::AdmissionResponseStatus, - admission_response_handler::policy_mode::PolicyMode, policy_evaluator::PolicySettings, - policy_fetcher::proxy::ProxyConfig, policy_fetcher::sources::Sources, - policy_fetcher::verify::config::VerificationConfigV1, + admission_response::{self, AdmissionResponseStatus, StatusCause, StatusDetails}, + admission_response_handler::policy_mode::PolicyMode, + policy_evaluator::PolicySettings, + policy_fetcher::{proxy::ProxyConfig, sources::Sources, verify::config::VerificationConfigV1}, }; use policy_server::{api::admission_review::AdmissionReviewResponse, config::PolicyOrPolicyGroup}; use regex::Regex; @@ -30,8 +25,12 @@ use serde_json::json; use tokio::fs; use tower::ServiceExt; -use crate::common::default_test_config; -use crate::common::pod_privileged_test_config; +mod common; + +use crate::common::{ + app, context_aware_policy_group_test_config, context_aware_policy_test_config, + default_test_config, pod_privileged_test_config, setup, +}; #[tokio::test] async fn test_validate() { @@ -84,6 +83,7 @@ async fn test_validate_custom_rejection_message() { context_aware_resources: BTreeSet::new(), message: Some("Custom error message".to_owned()), timeout_eval_seconds: None, + host_capabilities: vec![], }, ); let app = app(config).await; @@ -518,6 +518,226 @@ async fn test_timeout_protection_policy_specific_reject() { ); } +#[tokio::test] +async fn test_context_aware_policy_host_capability_denied() { + use policy_evaluator::policy_metadata::ContextAwareResource; + use std::collections::BTreeSet; + + setup(); + + let context_aware_resources = BTreeSet::from([ + ContextAwareResource { + api_version: "apps/v1".to_owned(), + kind: "Deployment".to_owned(), + }, + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Namespace".to_owned(), + }, + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Service".to_owned(), + }, + ]); + + let config = context_aware_policy_test_config( + "context-aware-test-policy", + vec![], + context_aware_resources, + ); + let app = app(config).await; + + let request = Request::builder() + .method(http::Method::POST) + .header(header::CONTENT_TYPE, "application/json") + .uri("/validate/context-aware-test-policy") + .body(Body::from(include_str!( + "data/deployment_admission_review.json" + ))) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), 200); + + let admission_review_response: AdmissionReviewResponse = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + // Invoking a host capability (e.g. `kubernetes/list_resources_by_namespace`) must + // be blocked by the policy-server before the call reaches the Kubernetes client. + // The resulting admission response must be denied and the status message must + // contain the capability-denial text. + assert!( + !admission_review_response.response.allowed, + "expected admission to be denied when host capabilities are empty" + ); + + let message = admission_review_response + .response + .status + .and_then(|s| s.message) + .unwrap_or_default(); + assert!( + message.contains("has not been granted access"), + "expected host capability denial message, got: {message}" + ); +} + +#[tokio::test] +async fn test_context_aware_policy_host_capability_allowed() { + use policy_evaluator::policy_metadata::ContextAwareResource; + use std::collections::BTreeSet; + + setup(); + + let context_aware_resources = BTreeSet::from([ + ContextAwareResource { + api_version: "apps/v1".to_owned(), + kind: "Deployment".to_owned(), + }, + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Namespace".to_owned(), + }, + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Service".to_owned(), + }, + ]); + + let config = context_aware_policy_test_config( + "context-aware-test-policy", + vec!["*".to_owned()], + context_aware_resources, + ); + let app = app(config).await; + + let request = Request::builder() + .method(http::Method::POST) + .header(header::CONTENT_TYPE, "application/json") + .uri("/validate/context-aware-test-policy") + .body(Body::from(include_str!( + "data/deployment_admission_review.json" + ))) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), 200); + + let admission_review_response: AdmissionReviewResponse = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + // The capability check passed, so the policy may fail for other reasons + // (no Kubernetes cluster available in tests), but it must NOT be rejected + // because of a capability-denial. + let message = admission_review_response + .response + .status + .and_then(|s| s.message) + .unwrap_or_default(); + assert!( + !message.contains("has not been granted access"), + "expected no capability-denial message when host capabilities are allowed, got: {message}" + ); +} + +#[tokio::test] +#[rstest] +#[case::host_capabilities_denied(vec![], true)] +#[case::host_capabilities_allowed(vec!["*".to_owned()], false)] +async fn test_context_aware_group_policy_host_capability( + #[case] host_capabilities: Vec, + #[case] expect_cap_denied: bool, +) { + use policy_evaluator::policy_metadata::ContextAwareResource; + use std::collections::BTreeSet; + + setup(); + + let context_aware_resources = BTreeSet::from([ + ContextAwareResource { + api_version: "apps/v1".to_owned(), + kind: "Deployment".to_owned(), + }, + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Namespace".to_owned(), + }, + ContextAwareResource { + api_version: "v1".to_owned(), + kind: "Service".to_owned(), + }, + ]); + + let config = context_aware_policy_group_test_config( + "ctx-aware-group", + host_capabilities, + context_aware_resources, + ); + let app = app(config).await; + + let request = Request::builder() + .method(http::Method::POST) + .header(header::CONTENT_TYPE, "application/json") + .uri("/validate/ctx-aware-group") + .body(Body::from(include_str!( + "data/deployment_admission_review.json" + ))) + .unwrap(); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), 200); + + let admission_review_response: AdmissionReviewResponse = + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap(); + + if expect_cap_denied { + assert!( + !admission_review_response.response.allowed, + "expected admission to be denied when group member has no host capabilities" + ); + // The host capability denial detail is in status.details.causes, because the + // group policy wraps the member error with its own top-level message. + let causes = admission_review_response + .response + .status + .and_then(|s| s.details) + .map(|d| d.causes) + .unwrap_or_default(); + assert!( + causes.iter().any(|c| c + .message + .as_deref() + .unwrap_or_default() + .contains("has not been granted access")), + "expected 'has not been granted access' in a status cause for group policy, got: {causes:?}" + ); + } else { + // The host capability gate must have passed: no cause or top-level message should + // contain the capability-denial text. (The policy itself may still be denied because + // there is no live Kubernetes cluster in the test environment.) + let status = admission_review_response.response.status; + let top_message = status + .as_ref() + .and_then(|s| s.message.as_deref()) + .unwrap_or_default() + .to_owned(); + let causes = status + .and_then(|s| s.details) + .map(|d| d.causes) + .unwrap_or_default(); + assert!( + !top_message.contains("has not been granted access") + && !causes.iter().any(|c| c + .message + .as_deref() + .unwrap_or_default() + .contains("has not been granted access")), + "expected no capability-denial in group policy when all host capabilities are allowed, \ + got top_message: {top_message:?}, causes: {causes:?}" + ); + } +} + #[tokio::test] async fn test_verified_policy() { setup(); @@ -558,6 +778,7 @@ async fn test_verified_policy() { context_aware_resources: BTreeSet::new(), message: None, timeout_eval_seconds: None, + host_capabilities: vec![], }, )]); config.verification_config = Some(verification_config); @@ -597,6 +818,7 @@ async fn test_policy_with_invalid_settings() { context_aware_resources: BTreeSet::new(), message: None, timeout_eval_seconds: None, + host_capabilities: vec![], }, ); config.continue_on_errors = true; @@ -644,6 +866,7 @@ async fn test_policy_with_wrong_url() { context_aware_resources: BTreeSet::new(), message: None, timeout_eval_seconds: None, + host_capabilities: vec![], }, ); config.continue_on_errors = true; diff --git a/crates/policy-server/tests/sigstore.rs b/crates/policy-server/tests/sigstore.rs index 4349280f1..7efea29a7 100644 --- a/crates/policy-server/tests/sigstore.rs +++ b/crates/policy-server/tests/sigstore.rs @@ -103,6 +103,7 @@ mod sigstore_tests { context_aware_resources: BTreeSet::new(), message: None, timeout_eval_seconds: None, + host_capabilities: vec![], }, )]); diff --git a/docs/crds/CRD-docs-for-docs-repo.adoc b/docs/crds/CRD-docs-for-docs-repo.adoc index d1a989613..e082e0ec4 100644 --- a/docs/crds/CRD-docs-for-docs-repo.adoc +++ b/docs/crds/CRD-docs-for-docs-repo.adoc @@ -932,6 +932,15 @@ resources. + Note: If the referenced PriorityClass is deleted, existing pods + remain unchanged, but new pods that reference it cannot be created. + | | Optional: \{} + +| *`namespacedPoliciesCapabilities`* __string array__ | NamespacedPoliciesCapabilities lists host capability API calls allowed + +for namespaced policies running on this PolicyServer. When not set, + +no host capabilities are granted to namespaced policies. + +Supported wildcard patterns: + +- "*": allow all host capabilities + +- "category/*": allow all capabilities in a category (e.g. "oci/*") + +- "category/version/*": allow all capabilities of a specific version (e.g. "oci/v1/*") + +- Specific capability paths (e.g. "oci/v1/verify", "net/v1/dns_lookup_host") + | | Optional: \{} + + |=== diff --git a/docs/crds/CRD-docs-for-docs-repo.md b/docs/crds/CRD-docs-for-docs-repo.md index c173925d9..8fb8eeaaf 100644 --- a/docs/crds/CRD-docs-for-docs-repo.md +++ b/docs/crds/CRD-docs-for-docs-repo.md @@ -538,6 +538,7 @@ _Appears in:_ | `requests` _[ResourceList](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#resourcelist-v1-core)_ | Requests describes the minimum amount of compute resources required.
If Request is omitted for, it defaults to Limits if that is explicitly specified,
otherwise to an implementation-defined value | | Optional: \{\}
| | `tolerations` _[Toleration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.31/#toleration-v1-core) array_ | Tolerations describe the policy server pod's tolerations. It can be
used to ensure that the policy server pod is not scheduled onto a
node with a taint. | | | | `priorityClassName` _string_ | PriorityClassName is the name of the PriorityClass to be used for the
policy server pods. Useful to schedule policy server pods with higher
priority to ensure their availability over other cluster workload
resources.
Note: If the referenced PriorityClass is deleted, existing pods
remain unchanged, but new pods that reference it cannot be created. | | Optional: \{\}
| +| `namespacedPoliciesCapabilities` _string array_ | NamespacedPoliciesCapabilities lists host capability API calls allowed
for namespaced policies running on this PolicyServer. When not set,
no host capabilities are granted to namespaced policies.
Supported wildcard patterns:
- "*": allow all host capabilities
- "category/*": allow all capabilities in a category (e.g. "oci/*")
- "category/version/*": allow all capabilities of a specific version (e.g. "oci/v1/*")
- Specific capability paths (e.g. "oci/v1/verify", "net/v1/dns_lookup_host") | | Optional: \{\}
| diff --git a/internal/controller/policyserver_controller_configmap.go b/internal/controller/policyserver_controller_configmap.go index 47528fe41..200dd6d91 100644 --- a/internal/controller/policyserver_controller_configmap.go +++ b/internal/controller/policyserver_controller_configmap.go @@ -27,6 +27,7 @@ type policyGroupMemberWithContext struct { Settings runtime.RawExtension `json:"settings,omitempty"` ContextAwareResources []policiesv1.ContextAwareResource `json:"contextAwareResources,omitempty"` TimeoutEvalSeconds *int32 `json:"timeoutEvalSeconds,omitempty"` + HostCapabilities []string `json:"hostCapabilities"` } type policyServerConfigEntry struct { @@ -38,6 +39,7 @@ type policyServerConfigEntry struct { Settings runtime.RawExtension `json:"settings,omitempty"` Message string `json:"message,omitempty"` TimeoutEvalSeconds *int32 `json:"timeoutEvalSeconds,omitempty"` + HostCapabilities []string `json:"hostCapabilities"` // The following fields are used by policy groups only. Policies map[string]policyGroupMemberWithContext `json:"policies,omitempty"` Expression string `json:"expression,omitempty"` @@ -65,6 +67,8 @@ func (p *policyServerConfigEntry) UnmarshalJSON(b []byte) error { func (p policyServerConfigEntry) MarshalJSON() ([]byte, error) { if len(p.Policies) > 0 { + // For policy groups, hostCapabilities is not emitted at the group level. + // Each group member carries its own hostCapabilities field instead. bytes, err := json.Marshal(struct { NamespacedName types.NamespacedName `json:"namespacedName"` PolicyMode string `json:"policyMode"` @@ -93,6 +97,7 @@ func (p policyServerConfigEntry) MarshalJSON() ([]byte, error) { Settings runtime.RawExtension `json:"settings,omitempty"` Message string `json:"message,omitempty"` TimeoutEvalSeconds *int32 `json:"timeoutEvalSeconds,omitempty"` + HostCapabilities []string `json:"hostCapabilities"` }{ NamespacedName: p.NamespacedName, Module: p.Module, @@ -102,6 +107,7 @@ func (p policyServerConfigEntry) MarshalJSON() ([]byte, error) { Settings: p.Settings, Message: p.Message, TimeoutEvalSeconds: p.TimeoutEvalSeconds, + HostCapabilities: p.HostCapabilities, }) if err != nil { return nil, errors.New("failed to encode policy server configuration") @@ -143,7 +149,7 @@ func (r *PolicyServerReconciler) reconcilePolicyServerConfigMap( // Function used to update the ConfigMap data when creating or updating it. func (r *PolicyServerReconciler) updateConfigMapData(cfg *corev1.ConfigMap, policyServer *policiesv1.PolicyServer, policies []policiesv1.Policy) error { - policiesMap := buildPoliciesMap(policies) + policiesMap := buildPoliciesMap(policies, policyServer) policiesYML, err := json.Marshal(policiesMap) if err != nil { return fmt.Errorf("cannot marshal policies: %w", err) @@ -189,7 +195,7 @@ func (r *PolicyServerReconciler) policyServerConfigMapVersion(ctx context.Contex return unstructuredObj.GetResourceVersion(), nil } -func buildPolicyGroupMembersWithContext(policies policiesv1.PolicyGroupMembersWithContext) map[string]policyGroupMemberWithContext { +func buildPolicyGroupMembersWithContext(policies policiesv1.PolicyGroupMembersWithContext, hostCaps []string) map[string]policyGroupMemberWithContext { policyGroupMembers := map[string]policyGroupMemberWithContext{} for name, policy := range policies { policyGroupMembers[name] = policyGroupMemberWithContext{ @@ -197,14 +203,38 @@ func buildPolicyGroupMembersWithContext(policies policiesv1.PolicyGroupMembersWi Settings: policy.Settings, ContextAwareResources: policy.ContextAwareResources, TimeoutEvalSeconds: policy.TimeoutEvalSeconds, + HostCapabilities: hostCaps, } } return policyGroupMembers } -func buildPoliciesMap(admissionPolicies []policiesv1.Policy) policyConfigEntryMap { +// hostCapabilitiesForPolicy returns the host capabilities for a policy based on +// whether it is namespaced or cluster-wide. +// Cluster-wide policies always get all host capabilities ("*"). +// Namespaced policies get the capabilities configured on the PolicyServer. If it +// is not configured on the PolicyServer, they get all host capabilities ("*"). +func hostCapabilitiesForPolicy(admissionPolicy policiesv1.Policy, policyServer *policiesv1.PolicyServer) []string { + if admissionPolicy.GetNamespace() == "" { + // Cluster-wide policy: grant all host capabilities + return []string{"*"} + } + + // Namespaced policy: use the PolicyServer's configured capabilities + if policyServer.Spec.NamespacedPoliciesCapabilities != nil { + return policyServer.Spec.NamespacedPoliciesCapabilities + } + + // Namespaced policy and no PolicyServer.spec.NamespacedPoliciesCapabilities, + // default to grant all host capabilities + return []string{"*"} +} + +func buildPoliciesMap(admissionPolicies []policiesv1.Policy, policyServer *policiesv1.PolicyServer) policyConfigEntryMap { policies := policyConfigEntryMap{} for _, admissionPolicy := range admissionPolicies { + hostCaps := hostCapabilitiesForPolicy(admissionPolicy, policyServer) + configEntry := policyServerConfigEntry{ NamespacedName: types.NamespacedName{ Namespace: admissionPolicy.GetNamespace(), @@ -217,10 +247,11 @@ func buildPoliciesMap(admissionPolicies []policiesv1.Policy) policyConfigEntryMa ContextAwareResources: admissionPolicy.GetContextAwareResources(), Message: admissionPolicy.GetMessage(), TimeoutEvalSeconds: admissionPolicy.GetTimeoutEvalSeconds(), + HostCapabilities: hostCaps, } if policyGroup, ok := admissionPolicy.(policiesv1.PolicyGroup); ok { - configEntry.Policies = buildPolicyGroupMembersWithContext(policyGroup.GetPolicyGroupMembersWithContext()) + configEntry.Policies = buildPolicyGroupMembersWithContext(policyGroup.GetPolicyGroupMembersWithContext(), hostCaps) configEntry.Expression = policyGroup.GetExpression() } diff --git a/internal/controller/policyserver_controller_test.go b/internal/controller/policyserver_controller_test.go index 9592717f4..e76591f57 100644 --- a/internal/controller/policyserver_controller_test.go +++ b/internal/controller/policyserver_controller_test.go @@ -341,6 +341,7 @@ var _ = Describe("PolicyServer controller", func() { ContextAwareResources: admissionPolicy.GetContextAwareResources(), Message: admissionPolicy.GetMessage(), TimeoutEvalSeconds: admissionPolicy.GetTimeoutEvalSeconds(), + HostCapabilities: []string{"*"}, } policiesMap[clusterAdmissionPolicy.GetUniqueName()] = policyServerConfigEntry{ NamespacedName: types.NamespacedName{ @@ -353,6 +354,7 @@ var _ = Describe("PolicyServer controller", func() { Settings: clusterAdmissionPolicy.GetSettings(), ContextAwareResources: clusterAdmissionPolicy.GetContextAwareResources(), TimeoutEvalSeconds: clusterAdmissionPolicy.GetTimeoutEvalSeconds(), + HostCapabilities: []string{"*"}, } policiesMap[admissionPolicyGroup.GetUniqueName()] = policyServerConfigEntry{ NamespacedName: types.NamespacedName{ @@ -364,7 +366,7 @@ var _ = Describe("PolicyServer controller", func() { AllowedToMutate: admissionPolicyGroup.IsMutating(), Settings: admissionPolicyGroup.GetSettings(), ContextAwareResources: admissionPolicyGroup.GetContextAwareResources(), - Policies: buildPolicyGroupMembersWithContext(admissionPolicyGroup.GetPolicyGroupMembersWithContext()), + Policies: buildPolicyGroupMembersWithContext(admissionPolicyGroup.GetPolicyGroupMembersWithContext(), []string{"*"}), Expression: admissionPolicyGroup.GetExpression(), Message: admissionPolicyGroup.GetMessage(), TimeoutEvalSeconds: &timeoutEvalSeconds, @@ -379,7 +381,7 @@ var _ = Describe("PolicyServer controller", func() { Settings: clusterPolicyGroup.GetSettings(), ContextAwareResources: clusterPolicyGroup.GetContextAwareResources(), PolicyMode: string(clusterPolicyGroup.GetPolicyMode()), - Policies: buildPolicyGroupMembersWithContext(clusterPolicyGroup.GetPolicyGroupMembersWithContext()), + Policies: buildPolicyGroupMembersWithContext(clusterPolicyGroup.GetPolicyGroupMembersWithContext(), []string{"*"}), Expression: clusterPolicyGroup.GetExpression(), Message: clusterPolicyGroup.GetMessage(), TimeoutEvalSeconds: &timeoutEvalSeconds, @@ -409,9 +411,10 @@ var _ = Describe("PolicyServer controller", func() { "Namespace": Equal(admissionPolicy.GetNamespace()), "Name": Equal(admissionPolicy.GetName()), }), - "module": Equal(admissionPolicy.GetModule()), - "policyMode": Equal(string(admissionPolicy.GetPolicyMode())), - "message": Equal(admissionPolicy.GetMessage()), + "module": Equal(admissionPolicy.GetModule()), + "policyMode": Equal(string(admissionPolicy.GetPolicyMode())), + "message": Equal(admissionPolicy.GetMessage()), + "hostCapabilities": ConsistOf("*"), }), Not(MatchAllKeys(Keys{ "timeoutEvalSeconds": Ignore(), @@ -426,6 +429,7 @@ var _ = Describe("PolicyServer controller", func() { "allowedToMutate": Equal(clusterAdmissionPolicy.IsMutating()), "timeoutEvalSeconds": BeNumerically("==", *clusterAdmissionPolicy.GetTimeoutEvalSeconds()), "settings": BeNil(), + "hostCapabilities": ConsistOf("*"), "contextAwareResources": And(ContainElement(MatchAllKeys(Keys{ "apiVersion": Equal("v1"), "kind": Equal("Pod"), @@ -445,7 +449,8 @@ var _ = Describe("PolicyServer controller", func() { }), "policies": MatchKeys(IgnoreExtras, Keys{ "pod_privileged": And(MatchKeys(IgnoreExtras, Keys{ - "module": Equal(admissionPolicyGroup.GetPolicyGroupMembersWithContext()["pod_privileged"].Module), + "module": Equal(admissionPolicyGroup.GetPolicyGroupMembersWithContext()["pod_privileged"].Module), + "hostCapabilities": ConsistOf("*"), }), Not(MatchAllKeys(Keys{ "timeoutEvalSeconds": Ignore(), }))), @@ -466,14 +471,16 @@ var _ = Describe("PolicyServer controller", func() { "module": Equal(clusterPolicyGroup.GetPolicyGroupMembersWithContext()["pod_privileged"].Module), "settings": Ignore(), "timeoutEvalSeconds": BeNumerically("==", timeoutEvalSeconds), + "hostCapabilities": ConsistOf("*"), "contextAwareResources": And(ContainElement(MatchAllKeys(Keys{ "apiVersion": Equal("v1"), "kind": Equal("Pod"), })), HaveLen(1)), }), "user_group_psp": And(MatchAllKeys(Keys{ - "module": Equal(clusterPolicyGroup.GetPolicyGroupMembersWithContext()["user_group_psp"].Module), - "settings": Ignore(), + "module": Equal(clusterPolicyGroup.GetPolicyGroupMembersWithContext()["user_group_psp"].Module), + "settings": Ignore(), + "hostCapabilities": ConsistOf("*"), "contextAwareResources": And(ContainElement(MatchAllKeys(Keys{ "apiVersion": Equal("v1"), "kind": Equal("Deployment"), @@ -497,6 +504,120 @@ var _ = Describe("PolicyServer controller", func() { }))) }) + It("should set * on namespaced and cluster-wide policy group members when PolicyServer has no NamespacedPoliciesCapabilities", func() { + policyServer := policiesv1.NewPolicyServerFactory().WithName(policyServerName).Build() + createPolicyServerAndWaitForItsService(ctx, policyServer) + + admissionPolicyGroup := policiesv1.NewAdmissionPolicyGroupFactory(). + WithPolicyServer(policyServerName). + Build() + Expect(k8sClient.Create(ctx, admissionPolicyGroup)).To(Succeed()) + + clusterPolicyGroup := policiesv1.NewClusterAdmissionPolicyGroupFactory(). + WithPolicyServer(policyServerName). + Build() + Expect(k8sClient.Create(ctx, clusterPolicyGroup)).To(Succeed()) + + Eventually(func() *corev1.ConfigMap { + configMap, _ := getTestPolicyServerConfigMap(ctx, policyServerName) + return configMap + }, timeout, pollInterval).Should(PointTo(MatchFields(IgnoreExtras, Fields{ + "Data": MatchAllKeys(Keys{ + constants.PolicyServerConfigPoliciesEntry: And( + policyGroupHostCapabilitiesMatcher(admissionPolicyGroup, clusterPolicyGroup, ConsistOf("*"), ConsistOf("*")), + ), + constants.PolicyServerConfigSourcesEntry: Equal("{}"), + }), + }))) + }) + + It("should set host capabilities on namespaced policy group members from PolicyServer NamespacedPoliciesCapabilities and * on cluster-wide group members", func() { + policyServer := policiesv1.NewPolicyServerFactory(). + WithName(policyServerName). + WithNamespacedPoliciesCapabilities([]string{"net/*"}). + Build() + createPolicyServerAndWaitForItsService(ctx, policyServer) + + admissionPolicyGroup := policiesv1.NewAdmissionPolicyGroupFactory(). + WithPolicyServer(policyServerName). + Build() + Expect(k8sClient.Create(ctx, admissionPolicyGroup)).To(Succeed()) + + clusterPolicyGroup := policiesv1.NewClusterAdmissionPolicyGroupFactory(). + WithPolicyServer(policyServerName). + Build() + Expect(k8sClient.Create(ctx, clusterPolicyGroup)).To(Succeed()) + + Eventually(func() *corev1.ConfigMap { + configMap, _ := getTestPolicyServerConfigMap(ctx, policyServerName) + return configMap + }, timeout, pollInterval).Should(PointTo(MatchFields(IgnoreExtras, Fields{ + "Data": MatchAllKeys(Keys{ + constants.PolicyServerConfigPoliciesEntry: And( + policyGroupHostCapabilitiesMatcher(admissionPolicyGroup, clusterPolicyGroup, ConsistOf("net/*"), ConsistOf("*")), + ), + constants.PolicyServerConfigSourcesEntry: Equal("{}"), + }), + }))) + }) + + It("should set * on namespaced and cluster-wide admission policies when PolicyServer has no NamespacedPoliciesCapabilities", func() { + policyServer := policiesv1.NewPolicyServerFactory().WithName(policyServerName).Build() + createPolicyServerAndWaitForItsService(ctx, policyServer) + + admissionPolicy := policiesv1.NewAdmissionPolicyFactory(). + WithPolicyServer(policyServerName). + Build() + Expect(k8sClient.Create(ctx, admissionPolicy)).To(Succeed()) + + clusterAdmissionPolicy := policiesv1.NewClusterAdmissionPolicyFactory(). + WithPolicyServer(policyServerName). + Build() + Expect(k8sClient.Create(ctx, clusterAdmissionPolicy)).To(Succeed()) + + Eventually(func() *corev1.ConfigMap { + configMap, _ := getTestPolicyServerConfigMap(ctx, policyServerName) + return configMap + }, timeout, pollInterval).Should(PointTo(MatchFields(IgnoreExtras, Fields{ + "Data": MatchAllKeys(Keys{ + constants.PolicyServerConfigPoliciesEntry: And( + admissionPolicyHostCapabilitiesMatcher(admissionPolicy, clusterAdmissionPolicy, ConsistOf("*"), ConsistOf("*")), + ), + constants.PolicyServerConfigSourcesEntry: Equal("{}"), + }), + }))) + }) + + It("should set host capabilities on namespaced admission policies from PolicyServer NamespacedPoliciesCapabilities and * on cluster-wide admission policies", func() { + policyServer := policiesv1.NewPolicyServerFactory(). + WithName(policyServerName). + WithNamespacedPoliciesCapabilities([]string{"net/*"}). + Build() + createPolicyServerAndWaitForItsService(ctx, policyServer) + + admissionPolicy := policiesv1.NewAdmissionPolicyFactory(). + WithPolicyServer(policyServerName). + Build() + Expect(k8sClient.Create(ctx, admissionPolicy)).To(Succeed()) + + clusterAdmissionPolicy := policiesv1.NewClusterAdmissionPolicyFactory(). + WithPolicyServer(policyServerName). + Build() + Expect(k8sClient.Create(ctx, clusterAdmissionPolicy)).To(Succeed()) + + Eventually(func() *corev1.ConfigMap { + configMap, _ := getTestPolicyServerConfigMap(ctx, policyServerName) + return configMap + }, timeout, pollInterval).Should(PointTo(MatchFields(IgnoreExtras, Fields{ + "Data": MatchAllKeys(Keys{ + constants.PolicyServerConfigPoliciesEntry: And( + admissionPolicyHostCapabilitiesMatcher(admissionPolicy, clusterAdmissionPolicy, ConsistOf("net/*"), ConsistOf("*")), + ), + constants.PolicyServerConfigSourcesEntry: Equal("{}"), + }), + }))) + }) + It("should create the policy server configmap with the sources authorities", func() { policyServer := policiesv1.NewPolicyServerFactory().WithName(policyServerName).Build() policyServer.Spec.InsecureSources = []string{"localhost:5000"} diff --git a/internal/controller/utils_test.go b/internal/controller/utils_test.go index 5eb2bc0ad..55cd4b2f3 100644 --- a/internal/controller/utils_test.go +++ b/internal/controller/utils_test.go @@ -18,6 +18,7 @@ package controller import ( "context" + "encoding/json" "errors" "fmt" "math/rand" @@ -196,6 +197,64 @@ func createPolicyServerAndWaitForItsService(ctx context.Context, policyServer *p }, timeout, pollInterval).Should(Succeed()) } +// policyGroupHostCapabilitiesMatcher builds a matcher that verifies the +// hostCapabilities field for the "pod_privileged" member inside both an +// AdmissionPolicyGroup and a ClusterAdmissionPolicyGroup entry of the policy +// server configmap. Cluster-wide groups always expect ["*"]; the expected +// matcher for the namespaced group is supplied by the caller. +func policyGroupHostCapabilitiesMatcher( + admissionPolicyGroup *policiesv1.AdmissionPolicyGroup, + clusterPolicyGroup *policiesv1.ClusterAdmissionPolicyGroup, + namespacedHostCapsMatcher types.GomegaMatcher, + clusterHostCapsMatcher types.GomegaMatcher, +) types.GomegaMatcher { + return WithTransform(func(data string) (map[string]interface{}, error) { + policiesData := map[string]interface{}{} + err := json.Unmarshal([]byte(data), &policiesData) + return policiesData, err + }, MatchKeys(IgnoreExtras, Keys{ + admissionPolicyGroup.GetUniqueName(): MatchKeys(IgnoreExtras, Keys{ + "policies": MatchKeys(IgnoreExtras, Keys{ + "pod_privileged": MatchKeys(IgnoreExtras, Keys{ + "hostCapabilities": namespacedHostCapsMatcher, + }), + }), + }), + clusterPolicyGroup.GetUniqueName(): MatchKeys(IgnoreExtras, Keys{ + "policies": MatchKeys(IgnoreExtras, Keys{ + "pod_privileged": MatchKeys(IgnoreExtras, Keys{ + "hostCapabilities": clusterHostCapsMatcher, + }), + }), + }), + })) +} + +// admissionPolicyHostCapabilitiesMatcher builds a matcher that verifies the +// hostCapabilities field for both an AdmissionPolicy and a +// ClusterAdmissionPolicy entry of the policy server configmap. +// Cluster-wide policies always expect ["*"]; the expected matcher for the +// namespaced policy is supplied by the caller. +func admissionPolicyHostCapabilitiesMatcher( + admissionPolicy *policiesv1.AdmissionPolicy, + clusterAdmissionPolicy *policiesv1.ClusterAdmissionPolicy, + namespacedHostCapsMatcher types.GomegaMatcher, + clusterHostCapsMatcher types.GomegaMatcher, +) types.GomegaMatcher { + return WithTransform(func(data string) (map[string]interface{}, error) { + policiesData := map[string]interface{}{} + err := json.Unmarshal([]byte(data), &policiesData) + return policiesData, err + }, MatchKeys(IgnoreExtras, Keys{ + admissionPolicy.GetUniqueName(): MatchKeys(IgnoreExtras, Keys{ + "hostCapabilities": namespacedHostCapsMatcher, + }), + clusterAdmissionPolicy.GetUniqueName(): MatchKeys(IgnoreExtras, Keys{ + "hostCapabilities": clusterHostCapsMatcher, + }), + })) +} + func createConfigMapWithSigstoreTrustConfig(ctx context.Context, name string) { configMap := &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{