From f1b760a87a27a0818b4a3f3db314c1d9ea35b5de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oliver=20B=C3=A4hler?= <26610571+oliverbaehler@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:24:54 +0200 Subject: [PATCH] feat(rules): improve metadata enforcement and add ingress rules (#2050) * feat: implement namespace metadata enforcement Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler * feat: add ingress enforcment Signed-off-by: Oliver Baehler --------- Signed-off-by: Oliver Baehler --- Makefile | 1 + .../crds/capsule.clastix.io_rulestatuses.yaml | 276 ++++++ .../crds/capsule.clastix.io_tenants.yaml | 92 ++ charts/capsule/templates/configuration.yaml | 33 + charts/capsule/values.yaml | 2 - cmd/controller/main.go | 18 +- e2e/namespace_metadata_forbidden_test.go | 2 +- e2e/rbac.yaml | 34 + e2e/rules_enforce_ingress_test.go | 910 ++++++++++++++++++ e2e/rules_enforce_metadata_test.go | 473 +++++++++ .../distro/capsule/example-setup/tenants.yaml | 54 +- internal/controllers/cfg/invalidator/regex.go | 12 + .../rulestatus/managed_metadata.go | 382 ++++++++ .../rulestatus/managed_metadata_test.go | 323 +++++++ internal/controllers/rulestatus/manager.go | 37 +- .../namespace/mutation/rules_metadata.go | 70 ++ .../namespace/mutation/rules_metadata_test.go | 82 ++ .../namespace/validation/rules_metadata.go | 114 +++ .../validation/rules_metadata_test.go | 108 +++ internal/webhook/pod/handler.go | 6 +- .../rules/generic/mutation/metadata.go | 128 +++ .../rules/generic/mutation/metadata_test.go | 55 ++ .../rules/generic/mutation/register.go | 33 + .../rules/generic/validation/ingress.go | 447 +++++++++ .../rules/generic/validation/ingress_test.go | 246 +++++ .../rules/generic/validation/metadata.go | 154 ++- .../rules/generic/validation/metadata_test.go | 144 ++- .../rules/generic/validation/register.go | 30 +- internal/webhook/service/handler.go | 6 +- pkg/api/rules/enforce_ingress_types.go | 38 + pkg/api/rules/enforce_metadata_types.go | 60 ++ pkg/api/rules/enforce_metadata_types_test.go | 41 + pkg/api/rules/enforce_types.go | 29 + pkg/api/rules/rule_body_types.go | 5 + pkg/api/rules/zz_generated.deepcopy.go | 58 ++ pkg/api/runtime/version_kind_types.go | 88 +- pkg/api/runtime/version_kind_types_test.go | 103 -- pkg/ruleengine/audience.go | 101 ++ pkg/ruleengine/audience_test.go | 95 ++ pkg/ruleengine/enforce_evaluator.go | 26 +- pkg/ruleengine/enforce_evaluator_test.go | 28 + pkg/ruleengine/validate.go | 145 ++- pkg/ruleengine/validate_ingress_test.go | 66 ++ pkg/ruleengine/validate_test.go | 58 ++ pkg/runtime/events/reasons.go | 1 + pkg/runtime/handlers/typed_tenant_ruleset.go | 60 +- .../handlers/typed_tenant_ruleset_test.go | 31 + 47 files changed, 5007 insertions(+), 298 deletions(-) create mode 100644 e2e/rbac.yaml create mode 100644 e2e/rules_enforce_ingress_test.go create mode 100644 internal/controllers/rulestatus/managed_metadata.go create mode 100644 internal/controllers/rulestatus/managed_metadata_test.go create mode 100644 internal/webhook/namespace/mutation/rules_metadata.go create mode 100644 internal/webhook/namespace/mutation/rules_metadata_test.go create mode 100644 internal/webhook/namespace/validation/rules_metadata.go create mode 100644 internal/webhook/namespace/validation/rules_metadata_test.go create mode 100644 internal/webhook/rules/generic/mutation/metadata.go create mode 100644 internal/webhook/rules/generic/mutation/metadata_test.go create mode 100644 internal/webhook/rules/generic/mutation/register.go create mode 100644 internal/webhook/rules/generic/validation/ingress.go create mode 100644 internal/webhook/rules/generic/validation/ingress_test.go create mode 100644 pkg/api/rules/enforce_ingress_types.go create mode 100644 pkg/api/rules/enforce_metadata_types_test.go create mode 100644 pkg/ruleengine/audience.go create mode 100644 pkg/ruleengine/audience_test.go create mode 100644 pkg/ruleengine/validate_ingress_test.go create mode 100644 pkg/runtime/handlers/typed_tenant_ruleset_test.go diff --git a/Makefile b/Makefile index 2e57be3e..0135fdcf 100644 --- a/Makefile +++ b/Makefile @@ -114,6 +114,7 @@ helm-test-exec: ct helm-controller-version ko-build-all # Setup development env dev-build: kind $(KIND) create cluster --wait=60s --name $(CLUSTER_NAME) --image kindest/node:$(KUBERNETES_SUPPORTED_VERSION) --config ./hack/kind-cluster.yaml + $(KUBECTL) apply --force-conflicts --server-side=true -f ./e2e/rbac.yaml $(MAKE) dev-install-gw-api-crds .PHONY: dev-destroy diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml index b8cfcaa0..5fa37e96 100644 --- a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -53,6 +53,26 @@ spec: description: For future implementation where users might manage RuleStatus CRs themselves properties: + audience: + description: |- + Audience limits this rule to matching request subjects. + An empty audience matches every request. + items: + properties: + kind: + enum: + - User + - Group + - ServiceAccount + - Custom + type: string + name: + type: string + required: + - kind + - name + type: object + type: array enforce: description: Enforcement for given rule properties: @@ -68,6 +88,58 @@ spec: - deny - audit type: string + ingress: + description: Enforcement for Ingress and Gateway API resource + hostnames. + properties: + hostnames: + description: |- + Hostnames defines allowed, denied, or audited hostname expressions. + A resource targeted by an allow or deny rule must declare non-empty values + in all hostname fields. Audit-only rules record missing hostnames without + denying them. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided values + exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + minItems: 1 + type: array + types: + description: Types defines the resource kinds to which hostname + enforcement applies. + items: + enum: + - Ingress + - Route + - ListenerSet + - HTTPRoute + - Gateway + - TLSRoute + - GRPCRoute + type: string + minItems: 1 + type: array + type: object metadata: description: Enforcement for object metadata on namespaced resources. items: @@ -77,6 +149,16 @@ spec: annotations: additionalProperties: properties: + default: + description: |- + Default is applied by admission mutation when the concrete metadata key is absent. + It is not reconciled after admission. + type: string + managed: + description: |- + Managed is enforced by admission mutation and reconciled by the RuleStatus + controller using server-side apply when the rule configuration changes. + type: string required: default: false description: |- @@ -150,6 +232,16 @@ spec: labels: additionalProperties: properties: + default: + description: |- + Default is applied by admission mutation when the concrete metadata key is absent. + It is not reconciled after admission. + type: string + managed: + description: |- + Managed is enforced by admission mutation and reconciled by the RuleStatus + controller using server-side apply when the rule configuration changes. + type: string required: default: false description: |- @@ -457,6 +549,26 @@ spec: Deprecated: use Rules. Rule contains a legacy flattened view and cannot fully represent action-aware rules. properties: + audience: + description: |- + Audience limits this rule to matching request subjects. + An empty audience matches every request. + items: + properties: + kind: + enum: + - User + - Group + - ServiceAccount + - Custom + type: string + name: + type: string + required: + - kind + - name + type: object + type: array enforce: description: Enforcement for given rule properties: @@ -472,6 +584,58 @@ spec: - deny - audit type: string + ingress: + description: Enforcement for Ingress and Gateway API resource + hostnames. + properties: + hostnames: + description: |- + Hostnames defines allowed, denied, or audited hostname expressions. + A resource targeted by an allow or deny rule must declare non-empty values + in all hostname fields. Audit-only rules record missing hostnames without + denying them. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided values + exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + minItems: 1 + type: array + types: + description: Types defines the resource kinds to which + hostname enforcement applies. + items: + enum: + - Ingress + - Route + - ListenerSet + - HTTPRoute + - Gateway + - TLSRoute + - GRPCRoute + type: string + minItems: 1 + type: array + type: object metadata: description: Enforcement for object metadata on namespaced resources. @@ -482,6 +646,16 @@ spec: annotations: additionalProperties: properties: + default: + description: |- + Default is applied by admission mutation when the concrete metadata key is absent. + It is not reconciled after admission. + type: string + managed: + description: |- + Managed is enforced by admission mutation and reconciled by the RuleStatus + controller using server-side apply when the rule configuration changes. + type: string required: default: false description: |- @@ -555,6 +729,16 @@ spec: labels: additionalProperties: properties: + default: + description: |- + Default is applied by admission mutation when the concrete metadata key is absent. + It is not reconciled after admission. + type: string + managed: + description: |- + Managed is enforced by admission mutation and reconciled by the RuleStatus + controller using server-side apply when the rule configuration changes. + type: string required: default: false description: |- @@ -800,6 +984,26 @@ spec: description: For future implementation where users might manage RuleStatus CRs themselves properties: + audience: + description: |- + Audience limits this rule to matching request subjects. + An empty audience matches every request. + items: + properties: + kind: + enum: + - User + - Group + - ServiceAccount + - Custom + type: string + name: + type: string + required: + - kind + - name + type: object + type: array enforce: description: Enforcement for given rule properties: @@ -815,6 +1019,58 @@ spec: - deny - audit type: string + ingress: + description: Enforcement for Ingress and Gateway API resource + hostnames. + properties: + hostnames: + description: |- + Hostnames defines allowed, denied, or audited hostname expressions. + A resource targeted by an allow or deny rule must declare non-empty values + in all hostname fields. Audit-only rules record missing hostnames without + denying them. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + minItems: 1 + type: array + types: + description: Types defines the resource kinds to which + hostname enforcement applies. + items: + enum: + - Ingress + - Route + - ListenerSet + - HTTPRoute + - Gateway + - TLSRoute + - GRPCRoute + type: string + minItems: 1 + type: array + type: object metadata: description: Enforcement for object metadata on namespaced resources. @@ -825,6 +1081,16 @@ spec: annotations: additionalProperties: properties: + default: + description: |- + Default is applied by admission mutation when the concrete metadata key is absent. + It is not reconciled after admission. + type: string + managed: + description: |- + Managed is enforced by admission mutation and reconciled by the RuleStatus + controller using server-side apply when the rule configuration changes. + type: string required: default: false description: |- @@ -898,6 +1164,16 @@ spec: labels: additionalProperties: properties: + default: + description: |- + Default is applied by admission mutation when the concrete metadata key is absent. + It is not reconciled after admission. + type: string + managed: + description: |- + Managed is enforced by admission mutation and reconciled by the RuleStatus + controller using server-side apply when the rule configuration changes. + type: string required: default: false description: |- diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index 5691d78d..f5fc5405 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -2500,6 +2500,26 @@ spec: items: description: Rules Distributed via Tenants properties: + audience: + description: |- + Audience limits this rule to matching request subjects. + An empty audience matches every request. + items: + properties: + kind: + enum: + - User + - Group + - ServiceAccount + - Custom + type: string + name: + type: string + required: + - kind + - name + type: object + type: array enforce: description: Enforcement for given rule properties: @@ -2515,6 +2535,58 @@ spec: - deny - audit type: string + ingress: + description: Enforcement for Ingress and Gateway API resource + hostnames. + properties: + hostnames: + description: |- + Hostnames defines allowed, denied, or audited hostname expressions. + A resource targeted by an allow or deny rule must declare non-empty values + in all hostname fields. Audit-only rules record missing hostnames without + denying them. + items: + description: |- + At least one of Exact or Exp must be set. + Both may be set together. + properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array + exp: + description: Exp matches regular expression. + minLength: 1 + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + minItems: 1 + type: array + types: + description: Types defines the resource kinds to which + hostname enforcement applies. + items: + enum: + - Ingress + - Route + - ListenerSet + - HTTPRoute + - Gateway + - TLSRoute + - GRPCRoute + type: string + minItems: 1 + type: array + type: object metadata: description: Enforcement for object metadata on namespaced resources. @@ -2525,6 +2597,16 @@ spec: annotations: additionalProperties: properties: + default: + description: |- + Default is applied by admission mutation when the concrete metadata key is absent. + It is not reconciled after admission. + type: string + managed: + description: |- + Managed is enforced by admission mutation and reconciled by the RuleStatus + controller using server-side apply when the rule configuration changes. + type: string required: default: false description: |- @@ -2598,6 +2680,16 @@ spec: labels: additionalProperties: properties: + default: + description: |- + Default is applied by admission mutation when the concrete metadata key is absent. + It is not reconciled after admission. + type: string + managed: + description: |- + Managed is enforced by admission mutation and reconciled by the RuleStatus + controller using server-side apply when the rule configuration changes. + type: string required: default: false description: |- diff --git a/charts/capsule/templates/configuration.yaml b/charts/capsule/templates/configuration.yaml index ec345c2c..c708327a 100644 --- a/charts/capsule/templates/configuration.yaml +++ b/charts/capsule/templates/configuration.yaml @@ -972,6 +972,39 @@ spec: {{- end }} webhooks: {{- $any := false -}} + {{- with .Values.webhooks.hooks.generic }} + {{- if .enabled }} + {{- $any = true }} + - name: generic.rules.mutating.projectcapsule.dev + {{- with .opts }} + opts: + {{- toYaml . | nindent 10 }} + {{- end }} + admissionReviewVersions: + - v1 + - v1beta1 + path: "/rules/generic/mutating" + failurePolicy: {{ .failurePolicy }} + matchPolicy: {{ .matchPolicy }} + reinvocationPolicy: {{ .reinvocationPolicy }} + {{- with .namespaceSelector }} + namespaceSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .objectSelector }} + objectSelector: + {{- toYaml . | nindent 10 }} + {{- end }} + {{- with .matchConditions }} + matchConditions: + {{- toYaml . | nindent 10 }} + {{- end }} + rules: + {{- toYaml .rules | nindent 10 }} + sideEffects: None + timeoutSeconds: {{ $.Values.webhooks.mutatingWebhooksTimeoutSeconds }} + {{- end }} + {{- end }} {{- with (mergeOverwrite .Values.webhooks.hooks.namespaces .Values.webhooks.hooks.namespaceOwnerReference) }} {{- if .enabled }} {{- $any = true }} diff --git a/charts/capsule/values.yaml b/charts/capsule/values.yaml index 46d6a012..341668b7 100644 --- a/charts/capsule/values.yaml +++ b/charts/capsule/values.yaml @@ -1002,7 +1002,6 @@ webhooks: resources: - '*' scope: Namespaced - # -- Generic Rules API generic: # -- Enable the Hook @@ -1046,7 +1045,6 @@ webhooks: - '*' scope: Namespaced - resourcepools: pools: # -- Enable the Hook diff --git a/cmd/controller/main.go b/cmd/controller/main.go index ea2e13f9..5ad6a5bc 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -76,6 +76,7 @@ import ( "github.com/projectcapsule/capsule/internal/webhook/pvc" "github.com/projectcapsule/capsule/internal/webhook/resourcepool" "github.com/projectcapsule/capsule/internal/webhook/route" + rulesgenericmutation "github.com/projectcapsule/capsule/internal/webhook/rules/generic/mutation" rulesgenericvalidation "github.com/projectcapsule/capsule/internal/webhook/rules/generic/validation" podrules "github.com/projectcapsule/capsule/internal/webhook/rules/pods/validation" servicerules "github.com/projectcapsule/capsule/internal/webhook/rules/services/validation" @@ -685,11 +686,12 @@ func main() { // webhooks: the order matters, don't change it and just append webhooksList := append( make([]handlers.Webhook, 0), - rulesgenericvalidation.Register(regexCache), + rulesgenericmutation.Register(cfg), + rulesgenericvalidation.Register(regexCache, cfg), route.GenericReplicasHandler(), route.GenericManagedHandler(cfg), route.Pod( - pod.Handler( + pod.Handler(cfg, podrules.PodRules(regexCache, registryCache), pod.ImagePullPolicy(), pod.ContainerRegistryLegacy(cfg), @@ -710,7 +712,7 @@ func main() { ), ), route.Service( - service.Handler( + service.Handler(cfg, servicerules.ServiceRules(regexCache), service.Validating(), ), @@ -759,6 +761,7 @@ func main() { namespacevalidation.CordoningHandler(cfg), namespacevalidation.QuotaHandler(), namespacevalidation.PrefixHandler(cfg), + namespacevalidation.RulesMetadataHandler(regexCache, cfg), namespacevalidation.UserMetadataHandler(), namespacevalidation.RequiredMetadataHandler(), ), @@ -768,6 +771,8 @@ func main() { cfg, namespacemutation.OwnerReferenceHandler(cfg), namespacemutation.MetadataHandler(cfg), + // Tenant metadata must be resolved before applying namespace rules. + namespacemutation.RulesMetadataHandler(cfg), namespacemutation.NamespacePatchGuardHandler(cfg), ), ), @@ -887,9 +892,10 @@ func main() { } if err = (&rulestatuscontroller.Manager{ - Client: manager.GetClient(), - Log: ctrl.Log.WithName("capsule.ctrl").WithName("ruleset"), - Metrics: metrics.MustMakeRuleStatusRecorder(), + Client: manager.GetClient(), + RESTConfig: manager.GetConfig(), + Log: ctrl.Log.WithName("capsule.ctrl").WithName("ruleset"), + Metrics: metrics.MustMakeRuleStatusRecorder(), }).SetupWithManager(manager, controllerConfig); err != nil { setupLog.Error(err, "unable to create controller", "controller", "RuleSet") os.Exit(1) diff --git a/e2e/namespace_metadata_forbidden_test.go b/e2e/namespace_metadata_forbidden_test.go index 4c26765b..72e314f3 100644 --- a/e2e/namespace_metadata_forbidden_test.go +++ b/e2e/namespace_metadata_forbidden_test.go @@ -20,7 +20,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/rbac" ) -var _ = Describe("creating a Namespace with user-specified labels and annotations", Ordered, Label("namespace", "metadata", "forbidden"), func() { +var _ = Describe("creating a Namespace with user-specified labels and annotations", Ordered, Label("config", "namespace", "metadata", "forbidden"), func() { originConfig := &capsulev1beta2.CapsuleConfiguration{} tnt := &capsulev1beta2.Tenant{ diff --git a/e2e/rbac.yaml b/e2e/rbac.yaml new file mode 100644 index 00000000..a42b07c0 --- /dev/null +++ b/e2e/rbac.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: gateway-permissions + labels: + projectcapsule.dev/aggregate-to-controller: "true" +rules: +- apiGroups: ["gateway.networking.k8s.io"] + resources: ["*"] + verbs: ["get", "create", "update", "patch", "watch", "list", "delete"] +# Fix Kubernetes 1.34 +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: admin-patch +rules: +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["*"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: admin-patch +subjects: +- kind: User + name: admin + apiGroup: rbac.authorization.k8s.io +roleRef: + kind: ClusterRole + name: admin-patch + apiGroup: rbac.authorization.k8s.io diff --git a/e2e/rules_enforce_ingress_test.go b/e2e/rules_enforce_ingress_test.go new file mode 100644 index 00000000..1158fe1d --- /dev/null +++ b/e2e/rules_enforce_ingress_test.go @@ -0,0 +1,910 @@ +// Copyright 2020-2026 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "fmt" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + networkingv1 "k8s.io/api/networking/v1" + rbacv1 "k8s.io/api/rbac/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/utils" +) + +var _ = Describe("enforcing ingress hostname namespace rules", Ordered, Label("tenant", "rules", "enforce", "ingress"), func() { + const ( + ownerName = "e2e-rules-ingress" + gatewayAPIClusterRole = "e2e-rules-ingress-gateway-api" + ) + + var tnt *capsulev1beta2.Tenant + + hostnameByExact := func(hostnames ...string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{Exact: hostnames} + } + + hostnameByExpression := func(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{Expression: expression}, + } + } + + hostnameByMatch := func(exact []string, expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + Exact: exact, + ExpressionRegex: runtime.ExpressionRegex{ + Expression: expression, + }, + } + } + + hostnameByNegatedExpression := func(expression string) runtime.ExpressionMatch { + return runtime.ExpressionMatch{ + ExpressionRegex: runtime.ExpressionRegex{ + Expression: expression, + Negate: true, + }, + } + } + + ingressRule := func( + action rules.ActionType, + types []rules.IngressType, + hostnames ...runtime.ExpressionMatch, + ) *rules.NamespaceRuleBodyTenant { + return &rules.NamespaceRuleBodyTenant{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: action, + Ingress: rules.NamespaceRuleEnforceIngressBody{ + Types: types, + Hostnames: hostnames, + }, + }, + }, + } + } + + selectedRule := func( + selector map[string]string, + rule *rules.NamespaceRuleBodyTenant, + ) *rules.NamespaceRuleBodyTenant { + rule.NamespaceSelector = &metav1.LabelSelector{MatchLabels: selector} + + return rule + } + + targetTypes := []rules.IngressType{ + rules.IngressTypeIngress, + rules.IngressTypeHTTPRoute, + rules.IngressTypeGateway, + } + + baseTenantRules := func() []*rules.NamespaceRuleBodyTenant { + return []*rules.NamespaceRuleBodyTenant{ + ingressRule( + rules.ActionTypeAudit, + targetTypes, + hostnameByExact("audited.example.com", "audited.example.net"), + ), + selectedRule( + map[string]string{"enforce-hostnames": "true"}, + ingressRule( + rules.ActionTypeAllow, + targetTypes, + hostnameByExact("internal.example.com"), + hostnameByExpression("^[a-z0-9-]+\\.example\\.com$"), + ), + ), + selectedRule( + map[string]string{"enforce-hostnames": "true"}, + ingressRule( + rules.ActionTypeDeny, + targetTypes, + hostnameByExact("blocked.example.com"), + ), + ), + selectedRule( + map[string]string{"allow-blocked-hostname": "true"}, + ingressRule( + rules.ActionTypeAllow, + targetTypes, + hostnameByExact("blocked.example.com"), + ), + ), + selectedRule( + map[string]string{"combined-hostname-match": "true"}, + ingressRule( + rules.ActionTypeAllow, + targetTypes, + hostnameByMatch( + []string{"combined-exact.example.net"}, + "^combined-[a-z0-9-]+\\.example\\.org$", + ), + ), + ), + selectedRule( + map[string]string{"negated-hostname-match": "true"}, + ingressRule( + rules.ActionTypeDeny, + targetTypes, + hostnameByNegatedExpression("^([a-z0-9-]+\\.)*trusted\\.example$"), + ), + ), + } + } + + newTenant := func() *capsulev1beta2.Tenant { + return &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-rule-ingress", + Labels: map[string]string{ + "env": "e2e", + }, + }, + Spec: capsulev1beta2.TenantSpec{ + Owners: rbac.OwnerListSpec{ + { + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Name: ownerName, + Kind: "User", + }, + }, + }, + }, + AdditionalRoleBindings: []rbac.AdditionalRoleBindingsSpec{ + { + ClusterRoleName: gatewayAPIClusterRole, + Subjects: []rbacv1.Subject{ + { + APIGroup: rbacv1.GroupName, + Kind: rbacv1.UserKind, + Name: ownerName, + }, + }, + }, + }, + Rules: baseTenantRules(), + }, + } + } + + type expectedIngressStatusRule struct { + action rules.ActionType + hostnames []runtime.ExpressionMatch + } + + expectNamespaceStatusRules := func(nsName string, want []expectedIngressStatusRule) { + Eventually(func(g Gomega) { + nsStatus := &capsulev1beta2.RuleStatus{} + g.Expect(k8sClient.Get( + context.Background(), + client.ObjectKey{Name: meta.NameForManagedRuleStatus(), Namespace: nsName}, + nsStatus, + )).To(Succeed()) + + g.Expect(nsStatus.Status.Rules).To(HaveLen(len(want))) + + for i, expected := range want { + got := nsStatus.Status.Rules[i] + g.Expect(got).NotTo(BeNil()) + g.Expect(got.Enforce).NotTo(BeNil()) + g.Expect(got.Enforce.Action).To(Equal(expected.action)) + g.Expect(got.Enforce.Ingress.Types).To(Equal(targetTypes)) + g.Expect(got.Enforce.Ingress.Hostnames).To(Equal(expected.hostnames)) + } + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + auditStatusRule := expectedIngressStatusRule{ + action: rules.ActionTypeAudit, + hostnames: []runtime.ExpressionMatch{ + hostnameByExact("audited.example.com", "audited.example.net"), + }, + } + + allowStatusRule := expectedIngressStatusRule{ + action: rules.ActionTypeAllow, + hostnames: []runtime.ExpressionMatch{ + hostnameByExact("internal.example.com"), + hostnameByExpression("^[a-z0-9-]+\\.example\\.com$"), + }, + } + + denyStatusRule := expectedIngressStatusRule{ + action: rules.ActionTypeDeny, + hostnames: []runtime.ExpressionMatch{ + hostnameByExact("blocked.example.com"), + }, + } + + exceptionStatusRule := expectedIngressStatusRule{ + action: rules.ActionTypeAllow, + hostnames: []runtime.ExpressionMatch{ + hostnameByExact("blocked.example.com"), + }, + } + + combinedStatusRule := expectedIngressStatusRule{ + action: rules.ActionTypeAllow, + hostnames: []runtime.ExpressionMatch{ + hostnameByMatch( + []string{"combined-exact.example.net"}, + "^combined-[a-z0-9-]+\\.example\\.org$", + ), + }, + } + + negatedStatusRule := expectedIngressStatusRule{ + action: rules.ActionTypeDeny, + hostnames: []runtime.ExpressionMatch{ + hostnameByNegatedExpression("^([a-z0-9-]+\\.)*trusted\\.example$"), + }, + } + + createNamespace := func(labels map[string]string, expectedRules ...expectedIngressStatusRule) *corev1.Namespace { + if labels == nil { + labels = map[string]string{} + } + labels[meta.TenantLabel] = tnt.GetName() + + ns := NewNamespace("", labels) + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + expectNamespaceStatusRules(ns.Name, expectedRules) + + return ns + } + + ingress := func(name string, hostnames ...string) *networkingv1.Ingress { + obj := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } + + for _, hostname := range hostnames { + obj.Spec.Rules = append(obj.Spec.Rules, networkingv1.IngressRule{ + Host: hostname, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: ptr.To(networkingv1.PathTypePrefix), + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "tenant-api", + Port: networkingv1.ServiceBackendPort{Number: 8080}, + }, + }, + }, + }, + }, + }, + }) + } + + return obj + } + + hostlessIngress := func(name string) *networkingv1.Ingress { + obj := ingress(name) + obj.Spec.DefaultBackend = &networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: "tenant-api", + Port: networkingv1.ServiceBackendPort{Number: 8080}, + }, + } + + return obj + } + + createIngressAndExpectAllowed := func(cs kubernetes.Interface, nsName string, obj *networkingv1.Ingress) { + EventuallyCreation(func() error { + _, err := cs.NetworkingV1().Ingresses(nsName).Create(context.Background(), obj, metav1.CreateOptions{}) + + return err + }).Should(Succeed()) + } + + createIngressAndExpectDenied := func( + cs kubernetes.Interface, + nsName string, + obj *networkingv1.Ingress, + substrings ...string, + ) { + base := obj.DeepCopy() + baseName := base.Name + + Eventually(func() error { + candidate := base.DeepCopy() + candidate.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6) + + _, err := cs.NetworkingV1().Ingresses(nsName).Create(context.Background(), candidate, metav1.CreateOptions{}) + if err == nil { + _ = cs.NetworkingV1().Ingresses(nsName).Delete(context.Background(), candidate.Name, metav1.DeleteOptions{}) + + return fmt.Errorf("expected ingress create to be denied, but it succeeded") + } + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("unexpected AlreadyExists: %v", err) + } + + for _, substring := range substrings { + if !strings.Contains(err.Error(), substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, err) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + expectAuditEvent := func(cs kubernetes.Interface, nsName, kind, objectName string, substrings ...string) { + Eventually(func() error { + eventList, err := cs.EventsV1().Events(nsName).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return err + } + + for _, event := range eventList.Items { + if event.Reason != events.ReasonNamespaceRuleAudit || + event.Regarding.Kind != kind || + event.Regarding.Name != objectName { + continue + } + + matched := true + for _, substring := range substrings { + if !strings.Contains(event.Note, substring) { + matched = false + + break + } + } + if matched { + return nil + } + } + + return fmt.Errorf("expected audit event for %s %q containing %q", kind, objectName, substrings) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + requireOptionalAPI := func(nsName string, list client.ObjectList, description string) { + if err := k8sClient.List(context.Background(), list, client.InNamespace(nsName)); err != nil { + if utils.IsUnsupportedAPI(err) { + Skip(fmt.Sprintf("%s is not available: %s", description, err)) + } + + Expect(err).NotTo(HaveOccurred()) + } + } + + BeforeAll(func() { + utilruntime.Must(gatewayv1.Install(scheme.Scheme)) + + role := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: gatewayAPIClusterRole}, + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"gateway.networking.k8s.io"}, + Resources: []string{"gateways", "httproutes"}, + Verbs: []string{"create", "delete"}, + }, + }, + } + Eventually(func() error { + current := &rbacv1.ClusterRole{} + err := k8sClient.Get(context.Background(), client.ObjectKey{Name: gatewayAPIClusterRole}, current) + if apierrors.IsNotFound(err) { + return k8sClient.Create(context.Background(), role) + } + if err != nil { + return err + } + + current.Rules = role.Rules + + return k8sClient.Update(context.Background(), current) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + AfterAll(func() { + err := k8sClient.Delete(context.Background(), &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{Name: gatewayAPIClusterRole}, + }) + if !apierrors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + }) + + JustBeforeEach(func() { + tnt = newTenant() + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.Background(), tnt) + }).Should(Succeed()) + + TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) + }) + + JustAfterEach(func() { + EventuallyDeletion(tnt) + }) + + It("projects ingress rules into namespace RuleStatus", func() { + createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + }) + + It("allows exact and regex hostnames across Ingress rules and TLS hosts", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + obj := ingress("allowed-hostnames", "internal.example.com", "api.example.com") + obj.Spec.TLS = []networkingv1.IngressTLS{{Hosts: []string{"api.example.com"}}} + + createIngressAndExpectAllowed(cs, ns.Name, obj) + }) + + It("supports exact and regex alternatives in the same hostname matcher", func() { + ns := createNamespace( + map[string]string{"combined-hostname-match": "true"}, + auditStatusRule, + combinedStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createIngressAndExpectAllowed(cs, ns.Name, ingress("combined-exact", "combined-exact.example.net")) + createIngressAndExpectAllowed(cs, ns.Name, ingress("combined-regex", "combined-team-a.example.org")) + createIngressAndExpectDenied(cs, ns.Name, ingress("combined-miss", "combined.example.com"), + "combined.example.com", + "not allowed", + "combined-exact.example.net", + "combined-[a-z0-9-]+", + ) + }) + + It("applies negation to hostname expressions", func() { + ns := createNamespace( + map[string]string{"negated-hostname-match": "true"}, + auditStatusRule, + negatedStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createIngressAndExpectAllowed(cs, ns.Name, ingress("negated-trusted", "api.trusted.example")) + createIngressAndExpectDenied(cs, ns.Name, ingress("negated-denied", "api.untrusted.example"), + "api.untrusted.example", + "denied", + "not exp:", + ) + }) + + It("denies a hostname outside the allow-list", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createIngressAndExpectDenied(cs, ns.Name, ingress("allow-miss", "api.example.net"), + "api.example.net", + "spec.rules[0].host", + "not allowed", + ) + }) + + It("reports the index of a rejected hostname in a multi-rule Ingress", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createIngressAndExpectDenied( + cs, + ns.Name, + ingress("multi-rule-allow-miss", "api.example.com", "api.example.net"), + "api.example.net", + "spec.rules[1].host", + "not allowed", + ) + }) + + It("denies a disallowed TLS hostname even when the routing hostname is allowed", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + obj := ingress("tls-allow-miss", "api.example.com") + obj.Spec.TLS = []networkingv1.IngressTLS{{Hosts: []string{"legacy.example.net"}}} + + createIngressAndExpectDenied(cs, ns.Name, obj, + "legacy.example.net", + "spec.tls[0].hosts[0]", + "not allowed", + ) + }) + + It("reports the index of a rejected hostname in a multi-host TLS entry", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + obj := ingress("multi-tls-allow-miss", "api.example.com") + obj.Spec.TLS = []networkingv1.IngressTLS{{ + Hosts: []string{"api.example.com", "legacy.example.net"}, + }} + + createIngressAndExpectDenied(cs, ns.Name, obj, + "legacy.example.net", + "spec.tls[0].hosts[1]", + "not allowed", + ) + }) + + It("applies later deny and namespace-selected allow precedence", func() { + deniedNS := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + allowedNS := createNamespace( + map[string]string{ + "enforce-hostnames": "true", + "allow-blocked-hostname": "true", + }, + auditStatusRule, + allowStatusRule, + denyStatusRule, + exceptionStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createIngressAndExpectDenied(cs, deniedNS.Name, ingress("blocked-denied", "blocked.example.com"), + "blocked.example.com", + "denied", + ) + createIngressAndExpectAllowed(cs, allowedNS.Name, ingress("blocked-allowed", "blocked.example.com")) + }) + + It("denies an update that changes an allowed hostname to an allow-list miss", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + obj := ingress("hostname-update", "api.example.com") + + createIngressAndExpectAllowed(cs, ns.Name, obj) + + Eventually(func() error { + current, err := cs.NetworkingV1().Ingresses(ns.Name).Get(context.Background(), obj.Name, metav1.GetOptions{}) + if err != nil { + return err + } + current.Spec.Rules[0].Host = "api.example.net" + + _, err = cs.NetworkingV1().Ingresses(ns.Name).Update(context.Background(), current, metav1.UpdateOptions{}) + if err == nil { + return fmt.Errorf("expected ingress update to be denied, but it succeeded") + } + for _, substring := range []string{"api.example.net", "spec.rules[0].host", "not allowed"} { + if !strings.Contains(err.Error(), substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, err) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + It("audits a matching hostname without blocking admission", func() { + ns := createNamespace(nil, auditStatusRule) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + obj := ingress("hostname-audited", "audited.example.com") + + createIngressAndExpectAllowed(cs, ns.Name, obj) + expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name, + `ingress hostname "audited.example.com"`, + "spec.rules[0].host", + "matched audit namespace rule", + ) + }) + + It("emits an audit event even when an allow-list denies the request", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + obj := ingress("audited-and-denied", "audited.example.net") + + _, err := cs.NetworkingV1().Ingresses(ns.Name).Create(context.Background(), obj, metav1.CreateOptions{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("audited.example.net")) + Expect(err.Error()).To(ContainSubstring("not allowed")) + + expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name, + `ingress hostname "audited.example.net"`, + "matched audit namespace rule", + ) + }) + + It("admits an empty hostname for audit-only rules and emits an audit event", func() { + ns := createNamespace(nil, auditStatusRule) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + obj := hostlessIngress("empty-hostname-audited") + + createIngressAndExpectAllowed(cs, ns.Name, obj) + expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name, + "empty hostname detected", + "spec.rules[].host", + "audit namespace rule", + ) + }) + + It("audits an empty TLS hosts entry without blocking audit-only admission", func() { + ns := createNamespace(nil, auditStatusRule) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + obj := ingress("empty-tls-hosts-audited", "neutral.example.org") + obj.Spec.TLS = []networkingv1.IngressTLS{{SecretName: "tenant-api-tls"}} + + createIngressAndExpectAllowed(cs, ns.Name, obj) + expectAuditEvent(clusterAdminClient(), ns.Name, "Ingress", obj.Name, + "empty hostname detected", + "spec.tls[0].hosts", + "audit namespace rule", + ) + }) + + It("denies an empty TLS hosts entry when allow or deny rules apply", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + obj := ingress("empty-tls-hosts-denied", "api.example.com") + obj.Spec.TLS = []networkingv1.IngressTLS{{SecretName: "tenant-api-tls"}} + + createIngressAndExpectDenied(cs, ns.Name, obj, + "hostname is required", + "spec.tls[0].hosts", + "Ingress", + ) + }) + + It("denies an empty hostname when allow or deny rules apply", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createIngressAndExpectDenied(cs, ns.Name, hostlessIngress("empty-hostname-denied"), + "hostname is required", + "spec.rules[].host", + "Ingress", + ) + }) + + It("enforces every HTTPRoute hostname when Gateway API is available", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + + requireOptionalAPI(ns.Name, &gatewayv1.HTTPRouteList{}, "Gateway API HTTPRoute") + + owner := impersonationClient(ownerName, withDefaultGroups(nil)) + allowed := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route-hostnames-allowed", Namespace: ns.Name}, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"api.example.com", "internal.example.com"}, + }, + } + EventuallyCreation(func() error { + return owner.Create(context.Background(), allowed) + }).Should(Succeed()) + + Eventually(func() error { + denied := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("route-hostnames-denied-%d", time.Now().UnixNano()%1e6), + Namespace: ns.Name, + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{"api.example.com", "api.example.net"}, + }, + } + + err := owner.Create(context.Background(), denied) + if err == nil { + _ = owner.Delete(context.Background(), denied) + + return fmt.Errorf("expected HTTPRoute create to be denied, but it succeeded") + } + for _, substring := range []string{"api.example.net", "spec.hostnames[1]", "not allowed"} { + if !strings.Contains(err.Error(), substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, err) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + It("audits or rejects an HTTPRoute without hostnames according to action", func() { + auditNS := createNamespace(nil, auditStatusRule) + enforceNS := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + requireOptionalAPI(auditNS.Name, &gatewayv1.HTTPRouteList{}, "Gateway API HTTPRoute") + + owner := impersonationClient(ownerName, withDefaultGroups(nil)) + audited := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route-empty-hostnames-audited", Namespace: auditNS.Name}, + } + EventuallyCreation(func() error { + return owner.Create(context.Background(), audited) + }).Should(Succeed()) + expectAuditEvent(clusterAdminClient(), auditNS.Name, "HTTPRoute", audited.Name, + "empty hostname detected", + "spec.hostnames[]", + "audit namespace rule", + ) + + denied := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{Name: "route-empty-hostnames-denied", Namespace: enforceNS.Name}, + } + err := owner.Create(context.Background(), denied) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("hostname is required")) + Expect(err.Error()).To(ContainSubstring("spec.hostnames[]")) + Expect(err.Error()).To(ContainSubstring("HTTPRoute")) + }) + + It("enforces every Gateway listener hostname when Gateway API is available", func() { + ns := createNamespace( + map[string]string{"enforce-hostnames": "true"}, + auditStatusRule, + allowStatusRule, + denyStatusRule, + ) + requireOptionalAPI(ns.Name, &gatewayv1.GatewayList{}, "Gateway API Gateway") + + owner := impersonationClient(ownerName, withDefaultGroups(nil)) + allowed := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gateway-hostnames-allowed", Namespace: ns.Name}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "unmanaged-e2e-class", + Listeners: []gatewayv1.Listener{ + { + Name: "http", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + Hostname: ptr.To(gatewayv1.Hostname("api.example.com")), + }, + { + Name: "http-alt", + Protocol: gatewayv1.HTTPProtocolType, + Port: 8080, + Hostname: ptr.To(gatewayv1.Hostname("internal.example.com")), + }, + }, + }, + } + EventuallyCreation(func() error { + return owner.Create(context.Background(), allowed) + }).Should(Succeed()) + + Eventually(func() error { + denied := allowed.DeepCopy() + denied.ResourceVersion = "" + denied.Name = fmt.Sprintf("gateway-hostnames-denied-%d", time.Now().UnixNano()%1e6) + denied.Spec.Listeners[1].Hostname = ptr.To(gatewayv1.Hostname("api.example.net")) + + err := owner.Create(context.Background(), denied) + if err == nil { + _ = owner.Delete(context.Background(), denied) + + return fmt.Errorf("expected Gateway create to be denied, but it succeeded") + } + for _, substring := range []string{"api.example.net", "spec.listeners[1].hostname", "not allowed"} { + if !strings.Contains(err.Error(), substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, err) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + It("audits a Gateway listener without a hostname without blocking admission", func() { + ns := createNamespace(nil, auditStatusRule) + requireOptionalAPI(ns.Name, &gatewayv1.GatewayList{}, "Gateway API Gateway") + + owner := impersonationClient(ownerName, withDefaultGroups(nil)) + obj := &gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{Name: "gateway-empty-hostname-audited", Namespace: ns.Name}, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: "unmanaged-e2e-class", + Listeners: []gatewayv1.Listener{ + { + Name: "http", + Protocol: gatewayv1.HTTPProtocolType, + Port: 80, + }, + }, + }, + } + EventuallyCreation(func() error { + return owner.Create(context.Background(), obj) + }).Should(Succeed()) + expectAuditEvent(clusterAdminClient(), ns.Name, "Gateway", obj.Name, + "empty hostname detected", + "spec.listeners[0].hostname", + "audit namespace rule", + ) + }) +}) diff --git a/e2e/rules_enforce_metadata_test.go b/e2e/rules_enforce_metadata_test.go index 5f59c734..ff875880 100644 --- a/e2e/rules_enforce_metadata_test.go +++ b/e2e/rules_enforce_metadata_test.go @@ -15,6 +15,7 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -363,6 +364,36 @@ var _ = Describe("enforcing generic metadata namespace rules", Ordered, Label("t tnt.Spec.Rules = next } + waitForProjectedMetadata := func(nsName, key string, wantDefault, wantManaged *string) { + Eventually(func(g Gomega) { + status := &capsulev1beta2.RuleStatus{} + g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{ + Name: meta.NameForManagedRuleStatus(), + Namespace: nsName, + }, status)).To(Succeed()) + + for _, body := range status.Status.Rules { + if body == nil || body.Enforce == nil { + continue + } + for _, metadata := range body.Enforce.Metadata { + if policy, ok := metadata.Labels[key]; ok { + g.Expect(policy.Default).To(Equal(wantDefault)) + g.Expect(policy.Managed).To(Equal(wantManaged)) + return + } + if policy, ok := metadata.Annotations[key]; ok { + g.Expect(policy.Default).To(Equal(wantDefault)) + g.Expect(policy.Managed).To(Equal(wantManaged)) + return + } + } + } + + g.Expect(false).To(BeTrue(), "metadata policy %q was not projected", key) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + createNamespace := func(labels map[string]string) *corev1.Namespace { if labels == nil { labels = map[string]string{} @@ -378,6 +409,34 @@ var _ = Describe("enforcing generic metadata namespace rules", Ordered, Label("t return ns } + createNamespaceAndExpectDenied := func(labels map[string]string, substrings ...string) { + if labels == nil { + labels = map[string]string{} + } + labels[meta.TenantLabel] = tnt.GetName() + base := NewNamespace("", labels) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + Eventually(func() error { + candidate := base.DeepCopy() + candidate.Name = fmt.Sprintf("%s-%d", base.Name, time.Now().UnixNano()%1e6) + _, err := cs.CoreV1().Namespaces().Create(context.Background(), candidate, metav1.CreateOptions{}) + if err == nil { + _ = cs.CoreV1().Namespaces().Delete(context.Background(), candidate.Name, metav1.DeleteOptions{}) + return fmt.Errorf("expected namespace create to be denied, but it succeeded") + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + configMap := func(name string, labels map[string]string, annotations map[string]string) *corev1.ConfigMap { return &corev1.ConfigMap{ ObjectMeta: metav1.ObjectMeta{ @@ -823,6 +882,65 @@ var _ = Describe("enforcing generic metadata namespace rules", Ordered, Label("t ) }) + It("matches label and annotation key expressions through the regex cache", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "v1", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + `example\.corp/label-.*`: metadataValueRule(false, metadataByExact("allowed")), + }, + map[string]rules.MetadataValueRule{ + "example.corp/*": metadataValueRule(false, metadataByExpression("^INV-[0-9]{4}$")), + }, + ), + }) + + ns := createNamespace(nil) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + createConfigMapAndExpectAllowed(cs, ns.Name, configMap( + "key-regex-allowed", + map[string]string{"example.corp/label-team": "allowed"}, + map[string]string{"example.corp/cost-center": "INV-1234"}, + )) + + createConfigMapAndExpectDenied(cs, ns.Name, configMap( + "key-regex-label-denied", + map[string]string{"example.corp/label-team": "blocked"}, + nil, + ), "metadata", "example.corp/label-team", "blocked", "not allowed") + + createConfigMapAndExpectDenied(cs, ns.Name, configMap( + "key-regex-annotation-denied", + nil, + map[string]string{"example.corp/cost-center": "BAD-1234"}, + ), "metadata", "example.corp/cost-center", "BAD-1234", "not allowed") + }) + + It("requires Namespace to be explicitly included in kinds", func() { + policy := map[string]rules.MetadataValueRule{ + "example.corp/namespace-policy": metadataValueRule(true, metadataByExact("allowed")), + } + + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule(rules.ActionTypeAllow, "*", []string{"*"}, policy, nil), + }) + createNamespace(nil) + + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule(rules.ActionTypeAllow, "*", []string{"*", "Namespace"}, policy, nil), + }) + createNamespaceAndExpectDenied( + nil, + "metadata", + "example.corp/namespace-policy", + "required", + ) + createNamespace(map[string]string{"example.corp/namespace-policy": "allowed"}) + }) + It("applies one metadata rule to multiple core kinds", func() { ns := createNamespace(nil) cs := ownerClient(tnt.Spec.Owners[0].UserSpec) @@ -2179,4 +2297,359 @@ var _ = Describe("enforcing generic metadata namespace rules", Ordered, Label("t "denied", ) }) + + It("applies Namespace defaults only when Namespace is explicitly targeted", func() { + defaultValue := "baseline" + policy := map[string]rules.MetadataValueRule{ + "example.corp/namespace-default": { + Default: &defaultValue, + }, + } + + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule(rules.ActionTypeAllow, "*", []string{"*"}, policy, nil), + }) + withoutExplicitKind := createNamespace(nil) + + Eventually(func(g Gomega) { + current := &corev1.Namespace{} + g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: withoutExplicitKind.Name}, current)).To(Succeed()) + g.Expect(current.Labels).NotTo(HaveKey("example.corp/namespace-default")) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule(rules.ActionTypeAllow, "*", []string{"*", "Namespace"}, policy, nil), + }) + waitForProjectedMetadata(withoutExplicitKind.Name, "example.corp/namespace-default", &defaultValue, nil) + + withExplicitKind := createNamespace(nil) + Eventually(func(g Gomega) { + current := &corev1.Namespace{} + g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: withExplicitKind.Name}, current)).To(Succeed()) + g.Expect(current.Labels).To(HaveKeyWithValue("example.corp/namespace-default", defaultValue)) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + It("applies defaults, preserves supplied values, and lets managed values win in combination", func() { + defaultOnly := "defaulted" + combinedDefault := "fallback" + combinedManaged := "controlled" + standaloneManaged := "managed-annotation" + + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "v1", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "example.corp/default": { + Default: &defaultOnly, + }, + "example.corp/combined": { + Default: &combinedDefault, + Managed: &combinedManaged, + }, + }, + map[string]rules.MetadataValueRule{ + "example.corp/managed": { + Managed: &standaloneManaged, + }, + }, + ), + }) + + ns := createNamespace(nil) + waitForProjectedMetadata(ns.Name, "example.corp/combined", &combinedDefault, &combinedManaged) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + created, err := cs.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap( + "metadata-default-managed-combination", + map[string]string{ + "example.corp/combined": "user-value", + }, + map[string]string{ + "example.corp/managed": "user-value", + }, + ), metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(created.Labels).To(HaveKeyWithValue("example.corp/default", defaultOnly)) + Expect(created.Labels).To(HaveKeyWithValue("example.corp/combined", combinedManaged)) + Expect(created.Annotations).To(HaveKeyWithValue("example.corp/managed", standaloneManaged)) + + preserved, err := cs.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap( + "metadata-default-preserves-user-value", + map[string]string{ + "example.corp/default": "supplied", + }, + nil, + ), metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(preserved.Labels).To(HaveKeyWithValue("example.corp/default", "supplied")) + Expect(preserved.Labels).To(HaveKeyWithValue("example.corp/combined", combinedManaged)) + Expect(preserved.Annotations).To(HaveKeyWithValue("example.corp/managed", standaloneManaged)) + }) + + It("reconciles standalone managed metadata and removes it when the rule is removed", func() { + managedLabel := "managed-label" + managedAnnotation := "managed-annotation" + managedNamespace := "managed-namespace" + + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "v1", + []string{"ConfigMap"}, + map[string]rules.MetadataValueRule{ + "example.corp/lifecycle": {Managed: &managedLabel}, + }, + map[string]rules.MetadataValueRule{ + "example.corp/lifecycle": {Managed: &managedAnnotation}, + }, + ), + metadataRule( + rules.ActionTypeAllow, + "v1", + []string{"Namespace"}, + map[string]rules.MetadataValueRule{ + "example.corp/namespace-lifecycle": {Managed: &managedNamespace}, + }, + nil, + ), + }) + + ns := createNamespace(map[string]string{"example.corp/user-owned": "keep"}) + waitForProjectedMetadata(ns.Name, "example.corp/lifecycle", nil, &managedLabel) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + cm, err := cs.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap( + "managed-metadata-lifecycle", + map[string]string{"example.corp/user-owned": "keep"}, + map[string]string{"example.corp/user-annotation": "keep"}, + ), metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func(g Gomega) { + current, getErr := cs.CoreV1().ConfigMaps(ns.Name).Get(context.Background(), cm.Name, metav1.GetOptions{}) + g.Expect(getErr).NotTo(HaveOccurred()) + g.Expect(current.Labels).To(HaveKeyWithValue("example.corp/lifecycle", managedLabel)) + g.Expect(current.Annotations).To(HaveKeyWithValue("example.corp/lifecycle", managedAnnotation)) + + currentNamespace := &corev1.Namespace{} + g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: ns.Name}, currentNamespace)).To(Succeed()) + g.Expect(currentNamespace.Labels).To(HaveKeyWithValue("example.corp/namespace-lifecycle", managedNamespace)) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + updateTenantRules(nil) + + Eventually(func(g Gomega) { + current, getErr := cs.CoreV1().ConfigMaps(ns.Name).Get(context.Background(), cm.Name, metav1.GetOptions{}) + g.Expect(getErr).NotTo(HaveOccurred()) + g.Expect(current.Labels).NotTo(HaveKey("example.corp/lifecycle")) + g.Expect(current.Annotations).NotTo(HaveKey("example.corp/lifecycle")) + g.Expect(current.Labels).To(HaveKeyWithValue("example.corp/user-owned", "keep")) + g.Expect(current.Annotations).To(HaveKeyWithValue("example.corp/user-annotation", "keep")) + + currentNamespace := &corev1.Namespace{} + g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: ns.Name}, currentNamespace)).To(Succeed()) + g.Expect(currentNamespace.Labels).NotTo(HaveKey("example.corp/namespace-lifecycle")) + g.Expect(currentNamespace.Labels).To(HaveKeyWithValue("example.corp/user-owned", "keep")) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + It("filters mutation and validation by audience across metadata, Service, and workload rules", func() { + defaultValue := "owner-default" + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + { + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Audience: []rules.Audience{{Kind: rules.AudienceKindUser, Name: ownerName}}, + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Metadata: []rules.MetadataRule{{ + VersionKinds: runtime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"ConfigMap"}}, + Labels: map[string]rules.MetadataValueRule{ + "example.corp/audience-default": {Default: &defaultValue}, + "example.corp/audience-blocked": metadataValueRule(false, metadataByExact("true")), + }, + }}, + }, + }, + }, + { + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Audience: []rules.Audience{{Kind: rules.AudienceKindGroup, Name: ownerName}}, + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Services: rules.NamespaceRuleEnforceServicesBody{ + Types: []rules.ServiceType{rules.ServiceTypeClusterIP}, + }, + }, + }, + }, + { + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Audience: []rules.Audience{{Kind: rules.AudienceKindCustom, Name: string(rules.CustomAudienceTenantOwner)}}, + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + QoSClasses: []corev1.PodQOSClass{corev1.PodQOSBestEffort}, + }, + }, + }, + }, + }) + + ns := createNamespace(nil) + waitForProjectedMetadata(ns.Name, "example.corp/audience-default", &defaultValue, nil) + owner := ownerClient(tnt.Spec.Owners[0].UserSpec) + admin := clusterAdminClient() + + ownerCM, err := owner.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap( + "audience-owner-mutated", + nil, + nil, + ), metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(ownerCM.Labels).To(HaveKeyWithValue("example.corp/audience-default", defaultValue)) + + createConfigMapAndExpectDenied(owner, ns.Name, configMap( + "audience-owner-denied", + map[string]string{"example.corp/audience-blocked": "true"}, + nil, + ), "example.corp/audience-blocked", "denied") + + adminCM, err := admin.CoreV1().ConfigMaps(ns.Name).Create(context.Background(), configMap( + "audience-admin-bypasses-metadata", + map[string]string{"example.corp/audience-blocked": "true"}, + nil, + ), metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(adminCM.Labels).NotTo(HaveKey("example.corp/audience-default")) + + createServiceAndExpectDenied(owner, ns.Name, service("audience-owner-service-denied", nil, nil), "ClusterIP", "denied") + createServiceAndExpectAllowed(admin, ns.Name, service("audience-admin-service-allowed", nil, nil)) + + ownerPod := MakePod(ns.Name, "audience-owner-pod-denied", nil, nil, "nginx:1.25", "", "") + Eventually(func() error { + _, createErr := owner.CoreV1().Pods(ns.Name).Create(context.Background(), ownerPod, metav1.CreateOptions{}) + if createErr == nil { + _ = owner.CoreV1().Pods(ns.Name).Delete(context.Background(), ownerPod.Name, metav1.DeleteOptions{}) + return fmt.Errorf("expected TenantOwner audience to deny the BestEffort Pod") + } + if !strings.Contains(createErr.Error(), "BestEffort") || !strings.Contains(createErr.Error(), "denied") { + return createErr + } + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + adminPod := MakePod(ns.Name, "audience-admin-pod-allowed", nil, nil, "nginx:1.25", "", "") + EventuallyCreation(func() error { + _, createErr := admin.CoreV1().Pods(ns.Name).Create(context.Background(), adminPod, metav1.CreateOptions{}) + return createErr + }).Should(Succeed()) + }) + + It("rejects forbidden namespace metadata injected through the status subresource", func() { + const policyKey = "pod-security.kubernetes.io/enforce" + + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "v1", + []string{"Namespace"}, + map[string]rules.MetadataValueRule{ + policyKey: metadataValueRule(true, metadataByExact("baseline", "restricted")), + }, + nil, + ), + }) + + ns := createNamespace(map[string]string{policyKey: "baseline"}) + waitForProjectedMetadata(ns.Name, policyKey, nil, nil) + + createNamespaceStatusRBACForOwner(tnt) + DeferCleanup(deleteNamespaceStatusRBACForOwner, tnt) + + owner := ownerClient(tnt.Spec.Owners[0].UserSpec) + patchStatus := func(value string) error { + patch := []byte(fmt.Sprintf( + `[{"op":"add","path":"/metadata/labels/pod-security.kubernetes.io~1enforce","value":%q}]`, + value, + )) + _, err := owner.CoreV1().Namespaces().Patch( + context.Background(), + ns.Name, + k8stypes.JSONPatchType, + patch, + metav1.PatchOptions{}, + "status", + ) + + return err + } + + By("allowing a compliant metadata update through namespaces/status") + Eventually(func() error { + return patchStatus("restricted") + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + By("rejecting a forbidden metadata update through namespaces/status") + Eventually(func() error { + err := patchStatus("privileged") + if err == nil { + return fmt.Errorf("expected namespace status patch to be denied") + } + if !strings.Contains(err.Error(), policyKey) || + !strings.Contains(err.Error(), "privileged") || + !strings.Contains(err.Error(), "not allowed") { + return err + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + + By("preserving the last allowed metadata value") + Eventually(func(g Gomega) { + current := &corev1.Namespace{} + g.Expect(k8sClient.Get(context.Background(), client.ObjectKey{Name: ns.Name}, current)).To(Succeed()) + g.Expect(current.Labels).To(HaveKeyWithValue(policyKey, "restricted")) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) + + It("ignores metadata enforcement for non-Namespace subresource updates", func() { + updateTenantRules([]*rules.NamespaceRuleBodyTenant{ + metadataRule( + rules.ActionTypeAllow, + "*", + []string{"*"}, + map[string]rules.MetadataValueRule{ + "example.corp/subresource-required": metadataValueRule(true, metadataByExact("true")), + }, + nil, + ), + }) + + ns := createNamespace(nil) + owner := ownerClient(tnt.Spec.Owners[0].UserSpec) + deploy := deployment( + "metadata-scale-subresource", + map[string]string{"example.corp/subresource-required": "true"}, + nil, + ) + deploy.Namespace = ns.Name + + EventuallyCreation(func() error { + _, err := owner.AppsV1().Deployments(ns.Name).Create(context.Background(), deploy, metav1.CreateOptions{}) + return err + }).Should(Succeed()) + + Eventually(func() error { + scale, err := owner.AppsV1().Deployments(ns.Name).GetScale(context.Background(), deploy.Name, metav1.GetOptions{}) + if err != nil { + return err + } + scale.Spec.Replicas = 2 + _, err = owner.AppsV1().Deployments(ns.Name).UpdateScale(context.Background(), deploy.Name, scale, metav1.UpdateOptions{}) + return err + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + }) }) diff --git a/hack/distro/capsule/example-setup/tenants.yaml b/hack/distro/capsule/example-setup/tenants.yaml index 20c22470..a876e16d 100644 --- a/hack/distro/capsule/example-setup/tenants.yaml +++ b/hack/distro/capsule/example-setup/tenants.yaml @@ -7,29 +7,43 @@ metadata: customer: a spec: owners: - - name: alice - kind: User + - name: alice + kind: User rules: - permissions: bindings: - clusterRoleName: 'custom:proxy-viewer' subjects: - - apiGroup: rbac.authorization.k8s.io - kind: User - name: joe + - apiGroup: rbac.authorization.k8s.io + kind: User + name: joe labels: reflection.proxy.projectcapsule.dev/enabled: "true" enforce: action: deny metadata: - apiGroups: - - "rbac.authorization.k8s.io/v1" + - "v1" kinds: - - "RoleBinding" + - "Namespace" labels: - reflection.proxy.projectcapsule.dev/enabled: + pod-security.kubernetes.io/enforce: + managed: "restricted" + - audience: + - kind: "Custom" + name: "CapsuleUser" + enforce: + action: deny + metadata: + - apiGroups: + - "v1" + kinds: + - "Namespace" + labels: + "openshift.io/*": + required: false values: - - exp: ".*" + - exp: "^.*$" - namespaceSelector: matchExpressions: - key: env @@ -39,17 +53,7 @@ spec: permissions: promotions: - clusterRoles: - - "secret-replicator" - - namespaceSelector: - matchExpressions: - - key: env - operator: In - values: - - "prod" - permissions: - promotions: - - clusterRoles: - - "sade-boi" + - "secret-replicator" permissions: matchOwners: - matchLabels: @@ -121,16 +125,6 @@ spec: to: 32767 - enforce: action: "allow" - # generic: - # - kinds: - # - "ConfigMap" - # path: ".data.key" - # entity: "Any/TenantOwner/CapsuleUser" - # operator: "Equals/Truthy" - # values: - # - exact: - # - "sad" - # from: ".status.classes.ingress" metadata: - kinds: - "ConfigMap" diff --git a/internal/controllers/cfg/invalidator/regex.go b/internal/controllers/cfg/invalidator/regex.go index f11b8904..0d97ed29 100644 --- a/internal/controllers/cfg/invalidator/regex.go +++ b/internal/controllers/cfg/invalidator/regex.go @@ -89,4 +89,16 @@ func collectRegexExpressionsFromNamespaceRule( set[cache.HashRegex(expr)] = expr } + + for _, metadataRule := range rule.Enforce.Metadata { + for selector := range metadataRule.Labels { + expr := rules.MetadataKeyExpression(selector) + set[cache.HashRegex(expr)] = expr + } + + for selector := range metadataRule.Annotations { + expr := rules.MetadataKeyExpression(selector) + set[cache.HashRegex(expr)] = expr + } + } } diff --git a/internal/controllers/rulestatus/managed_metadata.go b/internal/controllers/rulestatus/managed_metadata.go new file mode 100644 index 00000000..3fe96cf7 --- /dev/null +++ b/internal/controllers/rulestatus/managed_metadata.go @@ -0,0 +1,382 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rulestatus + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + k8smeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rules" + apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime" +) + +const managedMetadataListPageSize int64 = 500 + +type managedMetadataTarget struct { + gvr schema.GroupVersionResource + gvk schema.GroupVersionKind +} + +func (r Manager) reconcileManagedMetadata(ctx context.Context, instance *capsulev1beta2.RuleStatus, previous, current []*rules.NamespaceRuleBodyNamespace) error { + if r.RESTConfig == nil { + return fmt.Errorf("REST config is required for managed metadata reconciliation") + } + + dynamicClient, err := dynamic.NewForConfig(r.RESTConfig) + if err != nil { + return err + } + + manager := ruleStatusFieldManager(instance) + + namespaceGVK := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"} + previousLabels, previousAnnotations := managedMetadataForGVK(namespaceGVK, previous) + + labels, annotations := managedMetadataForGVK(namespaceGVK, current) + + if hasMetadata(previousLabels, previousAnnotations) || hasMetadata(labels, annotations) { + if err := reconcileObjectManagedMetadata(ctx, dynamicClient, schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}, namespaceGVK, "", instance.GetNamespace(), previousLabels, previousAnnotations, labels, annotations, manager); err != nil { + return err + } + } + + targets, err := managedMetadataTargets(r.RESTMapper, previous, current) + if err != nil { + return err + } + + for _, target := range targets { + previousLabels, previousAnnotations := managedMetadataForGVK(target.gvk, previous) + labels, annotations := managedMetadataForGVK(target.gvk, current) + + if err := reconcileManagedMetadataTarget( + ctx, + dynamicClient, + target, + instance.GetNamespace(), + previousLabels, + previousAnnotations, + labels, + annotations, + manager, + ); err != nil { + return err + } + } + + return nil +} + +func reconcileManagedMetadataTarget( + ctx context.Context, + dynamicClient dynamic.Interface, + target managedMetadataTarget, + namespace string, + previousLabels, previousAnnotations, labels, annotations map[string]string, + manager string, +) error { + continueToken := "" + + for { + items, err := dynamicClient.Resource(target.gvr).Namespace(namespace).List(ctx, metav1.ListOptions{ + Limit: managedMetadataListPageSize, + Continue: continueToken, + }) + if err != nil { + if isManagedMetadataObjectGone(err) { + return nil + } + + return fmt.Errorf("list %s in namespace %q: %w", target.gvr.String(), namespace, err) + } + + for i := range items.Items { + if err := reconcileObjectManagedMetadata( + ctx, + dynamicClient, + target.gvr, + target.gvk, + namespace, + items.Items[i].GetName(), + previousLabels, + previousAnnotations, + labels, + annotations, + manager, + ); err != nil { + return err + } + } + + continueToken = items.GetContinue() + if continueToken == "" { + return nil + } + } +} + +func managedMetadataTargets( + mapper k8smeta.RESTMapper, + ruleSets ...[]*rules.NamespaceRuleBodyNamespace, +) ([]managedMetadataTarget, error) { + targets := make(map[schema.GroupVersionResource]managedMetadataTarget) + + for _, bodies := range ruleSets { + for _, body := range bodies { + if body == nil || body.Enforce == nil { + continue + } + + for _, rule := range body.Enforce.Metadata { + if !metadataRuleHasManagedValues(rule) { + continue + } + + if rule.HasWildcard() { + return nil, fmt.Errorf("managed metadata requires concrete apiGroups and kinds") + } + + for _, kind := range rule.Kinds { + kind = strings.TrimSpace(kind) + for _, apiGroup := range rule.StatusAPIGroups() { + mapping, err := managedMetadataRESTMapping(mapper, apiGroup, kind) + if err != nil { + return nil, fmt.Errorf("resolve managed metadata target %q/%q: %w", apiGroup, kind, err) + } + + if mapping.GroupVersionKind.Group == "" && + mapping.GroupVersionKind.Version == apiruntime.CoreAPIVersion && + mapping.GroupVersionKind.Kind == "Namespace" { + continue + } + + if mapping.Scope.Name() != k8smeta.RESTScopeNameNamespace { + return nil, fmt.Errorf("managed metadata target %s is not namespaced", mapping.GroupVersionKind.String()) + } + + targets[mapping.Resource] = managedMetadataTarget{ + gvr: mapping.Resource, + gvk: mapping.GroupVersionKind, + } + } + } + } + } + } + + out := make([]managedMetadataTarget, 0, len(targets)) + for _, target := range targets { + out = append(out, target) + } + + sort.Slice(out, func(i, j int) bool { + return out[i].gvr.String() < out[j].gvr.String() + }) + + return out, nil +} + +func managedMetadataRESTMapping( + mapper k8smeta.RESTMapper, + apiGroup string, + kind string, +) (*k8smeta.RESTMapping, error) { + if mapper == nil { + return nil, fmt.Errorf("REST mapper is required for managed metadata reconciliation") + } + + apiGroup = strings.TrimSpace(apiGroup) + if apiGroup == "" || apiGroup == apiruntime.CoreAPIVersion { + return mapper.RESTMapping(schema.GroupKind{Kind: kind}, apiruntime.CoreAPIVersion) + } + + if gv, err := schema.ParseGroupVersion(apiGroup); err == nil && strings.Contains(apiGroup, "/") { + return mapper.RESTMapping(schema.GroupKind{Group: gv.Group, Kind: kind}, gv.Version) + } + + return mapper.RESTMapping(schema.GroupKind{Group: apiGroup, Kind: kind}) +} + +func reconcileObjectManagedMetadata(ctx context.Context, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, gvk schema.GroupVersionKind, namespace, name string, previousLabels, previousAnnotations, labels, annotations map[string]string, manager string) error { + removedLabels := removedMetadataKeys(previousLabels, labels) + + removedAnnotations := removedMetadataKeys(previousAnnotations, annotations) + + if hasRemovedMetadata(removedLabels, removedAnnotations) { + if err := removeManagedMetadata(ctx, dynamicClient, gvr, namespace, name, removedLabels, removedAnnotations); err != nil { + if isManagedMetadataObjectGone(err) { + return nil + } + + return err + } + } + + return applyManagedMetadata(ctx, dynamicClient, gvr, gvk, namespace, name, labels, annotations, manager) +} + +func removedMetadataKeys(previous, current map[string]string) map[string]any { + removed := map[string]any{} + + for key := range previous { + if _, ok := current[key]; !ok { + removed[key] = nil + } + } + + return removed +} + +func hasRemovedMetadata(labels, annotations map[string]any) bool { + return len(labels) > 0 || len(annotations) > 0 +} + +func removeManagedMetadata(ctx context.Context, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, namespace, name string, labels, annotations map[string]any) error { + metadata := map[string]any{} + if len(labels) > 0 { + metadata["labels"] = labels + } + + if len(annotations) > 0 { + metadata["annotations"] = annotations + } + + raw, err := json.Marshal(map[string]any{"metadata": metadata}) + if err != nil { + return err + } + + var resource dynamic.ResourceInterface = dynamicClient.Resource(gvr) + if namespace != "" { + resource = dynamicClient.Resource(gvr).Namespace(namespace) + } + + _, err = resource.Patch(ctx, name, types.MergePatchType, raw, metav1.PatchOptions{}) + + return err +} + +func hasMetadata(labels, annotations map[string]string) bool { + return len(labels) > 0 || len(annotations) > 0 +} + +func metadataRuleHasManagedValues(rule rules.MetadataRule) bool { + for _, policy := range rule.Labels { + if policy.Managed != nil { + return true + } + } + + for _, policy := range rule.Annotations { + if policy.Managed != nil { + return true + } + } + + return false +} + +func managedMetadataForGVK(gvk schema.GroupVersionKind, bodies []*rules.NamespaceRuleBodyNamespace) (map[string]string, map[string]string) { + labels, annotations := map[string]string{}, map[string]string{} + + for _, body := range bodies { + if body == nil || body.Enforce == nil { + continue + } + + for _, rule := range body.Enforce.Metadata { + if !rule.MatchesGroupVersionKind(gvk) { + continue + } + + for key, policy := range rule.Labels { + if policy.Managed != nil { + labels[key] = *policy.Managed + } + } + + for key, policy := range rule.Annotations { + if policy.Managed != nil { + annotations[key] = *policy.Managed + } + } + } + } + + return labels, annotations +} + +func hasManagedMetadata(bodies []*rules.NamespaceRuleBodyNamespace) bool { + for _, body := range bodies { + if body == nil || body.Enforce == nil { + continue + } + + for _, rule := range body.Enforce.Metadata { + for _, policy := range rule.Labels { + if policy.Managed != nil { + return true + } + } + + for _, policy := range rule.Annotations { + if policy.Managed != nil { + return true + } + } + } + } + + return false +} + +func applyManagedMetadata(ctx context.Context, dynamicClient dynamic.Interface, gvr schema.GroupVersionResource, gvk schema.GroupVersionKind, namespace, name string, labels, annotations map[string]string, manager string) error { + metadata := map[string]any{"name": name, "labels": labels, "annotations": annotations} + + var resource dynamic.ResourceInterface = dynamicClient.Resource(gvr) + + if namespace != "" { + metadata["namespace"] = namespace + resource = dynamicClient.Resource(gvr).Namespace(namespace) + } + + payload := map[string]any{"apiVersion": gvk.GroupVersion().String(), "kind": gvk.Kind, "metadata": metadata} + + raw, err := json.Marshal(payload) + if err != nil { + return err + } + + force := true + + _, err = resource.Patch(ctx, name, types.ApplyPatchType, raw, metav1.PatchOptions{FieldManager: manager, Force: &force}) + if isManagedMetadataObjectGone(err) { + return nil + } + + return err +} + +func isManagedMetadataObjectGone(err error) bool { + return apierrors.IsNotFound(err) +} + +func ruleStatusFieldManager(instance *capsulev1beta2.RuleStatus) string { + sum := sha256.Sum256([]byte(instance.GetNamespace() + "/" + instance.GetName() + "/" + string(instance.GetUID()))) + + return "projectcapsule.dev/rulestatus-" + hex.EncodeToString(sum[:8]) +} diff --git a/internal/controllers/rulestatus/managed_metadata_test.go b/internal/controllers/rulestatus/managed_metadata_test.go new file mode 100644 index 00000000..487d2c2b --- /dev/null +++ b/internal/controllers/rulestatus/managed_metadata_test.go @@ -0,0 +1,323 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rulestatus + +import ( + "context" + "errors" + "strings" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + k8smeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + k8stesting "k8s.io/client-go/testing" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rules" + apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime" +) + +func TestReconcileManagedMetadataRequiresRESTConfig(t *testing.T) { + t.Parallel() + + err := (Manager{}).reconcileManagedMetadata( + context.Background(), + &capsulev1beta2.RuleStatus{}, + nil, + nil, + ) + if err == nil || !strings.Contains(err.Error(), "REST config is required") { + t.Fatalf("expected missing REST config error, got %v", err) + } +} + +func TestRemovedMetadataKeys(t *testing.T) { + t.Parallel() + + removed := removedMetadataKeys( + map[string]string{"kept": "old", "removed": "value"}, + map[string]string{"kept": "new", "added": "value"}, + ) + if len(removed) != 1 { + t.Fatalf("expected one removed key, got %#v", removed) + } + if value, ok := removed["removed"]; !ok || value != nil { + t.Fatalf("expected removed key to have null merge-patch value, got %#v", removed) + } +} + +func TestRemoveManagedMetadata(t *testing.T) { + t.Parallel() + + gvk := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"} + gvr := schema.GroupVersionResource{Version: "v1", Resource: "namespaces"} + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + obj.SetName("solar-test") + obj.SetLabels(map[string]string{"kept": "value", "test": "true"}) + dynamicClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme(), obj) + + err := removeManagedMetadata( + context.Background(), + dynamicClient, + gvr, + "", + obj.GetName(), + map[string]any{"test": nil}, + nil, + ) + if err != nil { + t.Fatalf("removeManagedMetadata() error = %v", err) + } + + got, err := dynamicClient.Resource(gvr).Get(context.Background(), obj.GetName(), metav1.GetOptions{}) + if err != nil { + t.Fatalf("get patched Namespace: %v", err) + } + if _, ok := got.GetLabels()["test"]; ok { + t.Fatalf("managed label was not removed: %#v", got.GetLabels()) + } + if got.GetLabels()["kept"] != "value" { + t.Fatalf("unmanaged label changed: %#v", got.GetLabels()) + } +} + +func TestApplyManagedMetadataSkipsGoneObjects(t *testing.T) { + t.Parallel() + + dynamicClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) + dynamicClient.PrependReactor("patch", "configmaps", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, "settings") + }) + + err := applyManagedMetadata( + context.Background(), + dynamicClient, + schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, + schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + "solar-test", + "settings", + map[string]string{"managed": "true"}, + nil, + "test-manager", + ) + if err != nil { + t.Fatalf("applyManagedMetadata() error = %v", err) + } +} + +func TestApplyManagedMetadataReturnsNonNotFoundPatchErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + patchErr error + }{ + {name: "forbidden", patchErr: apierrors.NewForbidden(schema.GroupResource{Resource: "configmaps"}, "settings", errors.New("denied"))}, + {name: "other error", patchErr: errors.New("patch failed")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dynamicClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) + dynamicClient.PrependReactor("patch", "configmaps", func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, tt.patchErr + }) + + err := applyManagedMetadata( + context.Background(), + dynamicClient, + schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, + schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + "solar-test", + "settings", + map[string]string{"managed": "true"}, + nil, + "test-manager", + ) + if !errors.Is(err, tt.patchErr) { + t.Fatalf("applyManagedMetadata() error = %v, want %v", err, tt.patchErr) + } + }) + } +} + +func TestReconcileObjectManagedMetadataStopsAfterGoneRemoval(t *testing.T) { + t.Parallel() + + patches := 0 + dynamicClient := dynamicfake.NewSimpleDynamicClient(runtime.NewScheme()) + dynamicClient.PrependReactor("patch", "configmaps", func(k8stesting.Action) (bool, runtime.Object, error) { + patches++ + + return true, nil, apierrors.NewNotFound(schema.GroupResource{Resource: "configmaps"}, "settings") + }) + + err := reconcileObjectManagedMetadata( + context.Background(), + dynamicClient, + schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, + schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + "solar-test", + "settings", + map[string]string{"removed": "value"}, + nil, + nil, + nil, + "test-manager", + ) + if err != nil { + t.Fatalf("reconcileObjectManagedMetadata() error = %v", err) + } + if patches != 1 { + t.Fatalf("reconcileObjectManagedMetadata() made %d patches, want 1", patches) + } +} + +func TestManagedMetadataTargetsOnlyReferencedGVKs(t *testing.T) { + t.Parallel() + + mapper := k8smeta.NewDefaultRESTMapper([]schema.GroupVersion{ + {Version: "v1"}, + {Group: "apps", Version: "v1"}, + }) + mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, k8smeta.RESTScopeRoot) + mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, k8smeta.RESTScopeNamespace) + mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "Secret"}, k8smeta.RESTScopeNamespace) + mapper.Add(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, k8smeta.RESTScopeNamespace) + + managed := "controlled" + bodies := []*rules.NamespaceRuleBodyNamespace{{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{ + { + VersionKinds: apiruntime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"Namespace", "ConfigMap"}}, + Labels: map[string]rules.MetadataValueRule{"example.com/managed": {Managed: &managed}}, + }, + { + VersionKinds: apiruntime.VersionKinds{APIGroups: []string{"apps/v1"}, Kinds: []string{"Deployment"}}, + Annotations: map[string]rules.MetadataValueRule{"example.com/managed": {Managed: &managed}}, + }, + }, + }, + }} + + targets, err := managedMetadataTargets(mapper, bodies, bodies) + if err != nil { + t.Fatalf("managedMetadataTargets() error = %v", err) + } + + want := []managedMetadataTarget{ + { + gvr: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, + gvk: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + }, + { + gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, + gvk: schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, + }, + } + + if len(targets) != len(want) { + t.Fatalf("managedMetadataTargets() = %#v, want %#v", targets, want) + } + for i := range want { + if targets[i] != want[i] { + t.Fatalf("managedMetadataTargets()[%d] = %#v, want %#v", i, targets[i], want[i]) + } + } +} + +func TestManagedMetadataTargetsRejectWildcards(t *testing.T) { + t.Parallel() + + managed := "controlled" + bodies := []*rules.NamespaceRuleBodyNamespace{{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{{ + VersionKinds: apiruntime.VersionKinds{APIGroups: []string{"*"}, Kinds: []string{"ConfigMap"}}, + Labels: map[string]rules.MetadataValueRule{"example.com/managed": {Managed: &managed}}, + }}, + }, + }} + + _, err := managedMetadataTargets(nil, bodies) + if err == nil || !strings.Contains(err.Error(), "requires concrete apiGroups and kinds") { + t.Fatalf("managedMetadataTargets() error = %v", err) + } +} + +func TestReconcileManagedMetadataTargetUsesPagination(t *testing.T) { + t.Parallel() + + gvr := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} + dynamicClient := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( + runtime.NewScheme(), + map[schema.GroupVersionResource]string{gvr: "ConfigMapList"}, + ) + listCalls := 0 + patchCalls := 0 + dynamicClient.PrependReactor("list", "configmaps", func(action k8stesting.Action) (bool, runtime.Object, error) { + listCalls++ + + listAction, ok := action.(interface{ GetListOptions() metav1.ListOptions }) + if !ok { + t.Fatalf("list action does not expose list options: %T", action) + } + opts := listAction.GetListOptions() + if opts.Limit != managedMetadataListPageSize { + t.Fatalf("list limit = %d, want %d", opts.Limit, managedMetadataListPageSize) + } + + item := unstructured.Unstructured{} + item.SetName("settings") + items := &unstructured.UnstructuredList{Items: []unstructured.Unstructured{item}} + if listCalls == 1 { + if opts.Continue != "" { + t.Fatalf("first list continue token = %q, want empty", opts.Continue) + } + items.SetContinue("next-page") + } else if opts.Continue != "next-page" { + t.Fatalf("second list continue token = %q, want next-page", opts.Continue) + } + + return true, items, nil + }) + dynamicClient.PrependReactor("patch", "configmaps", func(k8stesting.Action) (bool, runtime.Object, error) { + patchCalls++ + + return true, &unstructured.Unstructured{}, nil + }) + + err := reconcileManagedMetadataTarget( + context.Background(), + dynamicClient, + managedMetadataTarget{ + gvr: gvr, + gvk: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + }, + "solar-test", + nil, + nil, + map[string]string{"managed": "true"}, + nil, + "test-manager", + ) + if err != nil { + t.Fatalf("reconcileManagedMetadataTarget() error = %v", err) + } + if listCalls != 2 { + t.Fatalf("list calls = %d, want 2", listCalls) + } + if patchCalls != 2 { + t.Fatalf("patch calls = %d, want 2", patchCalls) + } +} diff --git a/internal/controllers/rulestatus/manager.go b/internal/controllers/rulestatus/manager.go index f11d157d..8516c780 100644 --- a/internal/controllers/rulestatus/manager.go +++ b/internal/controllers/rulestatus/manager.go @@ -11,6 +11,7 @@ import ( "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + k8smeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" @@ -43,10 +44,12 @@ type Manager struct { Recorder events.EventRecorder Configuration configuration.Configuration RESTConfig *rest.Config + RESTMapper k8smeta.RESTMapper } func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) error { r.reader = mgr.GetAPIReader() + r.RESTMapper = mgr.GetRESTMapper() ctrlBuilder := ctrl.NewControllerManagedBy(mgr). Named("capsule/rule-status"). @@ -145,6 +148,8 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct } func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleStatus) error { + previousRules := instance.Status.Rules + hadManagedMetadata := hasManagedMetadata(previousRules) ruleStatus := make([]*rules.NamespaceRuleBodyNamespace, 0, len(instance.Spec)) for _, rule := range instance.Spec { @@ -158,19 +163,43 @@ func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleSta enforce.Metadata[i].APIGroups = enforce.Metadata[i].StatusAPIGroups() } - ruleStatus = append(ruleStatus, &rules.NamespaceRuleBodyNamespace{ - Enforce: enforce, - }) + statusRule := rule.DeepCopy() + statusRule.Enforce = enforce + ruleStatus = append(ruleStatus, statusRule) } instance.Status.Rules = ruleStatus - //nolint:staticcheck instance.Status.Rule = rules.NamespaceRuleBodyNamespace{} + if hadManagedMetadata || hasManagedMetadata(ruleStatus) { + if err := r.publishRulesStatus(ctx, instance); err != nil { + return fmt.Errorf("publish rules before managed metadata reconciliation: %w", err) + } + + if err := r.reconcileManagedMetadata(ctx, instance, previousRules, ruleStatus); err != nil { + return fmt.Errorf("reconcile managed metadata: %w", err) + } + } + return nil } +func (r *Manager) publishRulesStatus(ctx context.Context, instance *capsulev1beta2.RuleStatus) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() error { + latest := &capsulev1beta2.RuleStatus{} + if err := r.reader.Get(ctx, client.ObjectKeyFromObject(instance), latest); err != nil { + return err + } + + latest.Status.Rules = instance.Status.Rules + //nolint:staticcheck + latest.Status.Rule = instance.Status.Rule + + return r.Client.Status().Update(ctx, latest) + }) +} + func (r *Manager) updateStatus(ctx context.Context, instance *capsulev1beta2.RuleStatus, reconcileError error) error { return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) { latest := &capsulev1beta2.RuleStatus{} diff --git a/internal/webhook/namespace/mutation/rules_metadata.go b/internal/webhook/namespace/mutation/rules_metadata.go new file mode 100644 index 00000000..1e301ca5 --- /dev/null +++ b/internal/webhook/namespace/mutation/rules_metadata.go @@ -0,0 +1,70 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package mutation + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + rulesmutation "github.com/projectcapsule/capsule/internal/webhook/rules/generic/mutation" + "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" + "github.com/projectcapsule/capsule/pkg/tenant" + "github.com/projectcapsule/capsule/pkg/users" +) + +type rulesMetadataMutation struct{ configuration configuration.Configuration } + +func RulesMetadataHandler(cfg configuration.Configuration) handlers.TypedHandlerWithUser[*corev1.Namespace] { + return &rulesMetadataMutation{configuration: cfg} +} + +func (h *rulesMetadataMutation) OnCreate(c client.Client, reader client.Reader, _ users.AdmissionUser, ns *corev1.Namespace, _ admission.Decoder, _ events.EventRecorder) handlers.Func { + return mutateNamespaceRules(c, reader, h.configuration, ns) +} + +func (h *rulesMetadataMutation) OnUpdate(c client.Client, reader client.Reader, _ users.AdmissionUser, _ *corev1.Namespace, ns *corev1.Namespace, _ admission.Decoder, _ events.EventRecorder) handlers.Func { + return mutateNamespaceRules(c, reader, h.configuration, ns) +} + +func (*rulesMetadataMutation) OnDelete(client.Client, client.Reader, users.AdmissionUser, *corev1.Namespace, admission.Decoder, events.EventRecorder) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { return nil } +} + +func mutateNamespaceRules(c client.Client, reader client.Reader, cfg configuration.Configuration, ns *corev1.Namespace) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + if req.SubResource == "finalize" { + return nil + } + + tnt, err := tenant.GetTenantByLabels(ctx, reader, ns) + if err != nil { + return handlers.ErroredResponse(err) + } + + if tnt == nil { + return nil + } + + bodies, err := tenant.BuildNamespaceRuleBodyStatus(c.Scheme(), ns, tnt) + if err != nil { + return handlers.ErroredResponse(err) + } + + bodies, err = ruleengine.FilterNamespaceRulesByAudience(cfg, tnt, req, bodies) + if err != nil { + return handlers.ErroredResponse(err) + } + + rulesmutation.MutateMetadata(ns, schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, bodies) + + return nil + } +} diff --git a/internal/webhook/namespace/mutation/rules_metadata_test.go b/internal/webhook/namespace/mutation/rules_metadata_test.go new file mode 100644 index 00000000..65c25386 --- /dev/null +++ b/internal/webhook/namespace/mutation/rules_metadata_test.go @@ -0,0 +1,82 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package mutation + +import ( + "context" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rules" + apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime" +) + +func TestMutateNamespaceRulesSkipsFinalize(t *testing.T) { + t.Parallel() + + request := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + SubResource: "finalize", + }} + + if response := mutateNamespaceRules(nil, nil, nil, nil)(context.Background(), request); response != nil { + t.Fatalf("mutateNamespaceRules() response = %#v, want nil", response) + } +} + +func TestMutateNamespaceRules(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add core API to scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("add Capsule API to scheme: %v", err) + } + + tnt := &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{Name: "solar"}, + Spec: capsulev1beta2.TenantSpec{ + Rules: []*rules.NamespaceRuleBodyTenant{{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{{ + VersionKinds: apiruntime.VersionKinds{ + APIGroups: []string{"v1"}, + Kinds: []string{"Namespace"}, + }, + Labels: map[string]rules.MetadataValueRule{ + "rules.example.com/managed": {Managed: ptr.To("true")}, + }, + }}, + }, + }, + }}, + }, + } + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "solar-production", + Labels: map[string]string{meta.TenantLabel: tnt.Name}, + }} + + client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(tnt).Build() + response := mutateNamespaceRules(client, client, nil, ns)(context.Background(), admission.Request{}) + if response != nil { + t.Fatalf("mutateNamespaceRules() response = %#v", response) + } + + if got := ns.Labels["rules.example.com/managed"]; got != "true" { + t.Fatalf("managed namespace label = %q, want true", got) + } +} diff --git a/internal/webhook/namespace/validation/rules_metadata.go b/internal/webhook/namespace/validation/rules_metadata.go new file mode 100644 index 00000000..ce55d133 --- /dev/null +++ b/internal/webhook/namespace/validation/rules_metadata.go @@ -0,0 +1,114 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/cache" + genericvalidation "github.com/projectcapsule/capsule/internal/webhook/rules/generic/validation" + "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" + "github.com/projectcapsule/capsule/pkg/tenant" + "github.com/projectcapsule/capsule/pkg/users" +) + +type rulesMetadataHandler struct { + generic handlers.TypedHandlerWithTenantWithRuleset[*metav1.PartialObjectMetadata] + configuration configuration.Configuration +} + +func RulesMetadataHandler(regexCache *cache.RegexCache, cfg configuration.Configuration) handlers.TypedHandlerWithTenantUser[*corev1.Namespace] { + return &rulesMetadataHandler{generic: genericvalidation.GenericRules(regexCache), configuration: cfg} +} + +func (h *rulesMetadataHandler) OnCreate( + c client.Client, + reader client.Reader, + _ users.AdmissionUser, + ns *corev1.Namespace, + decoder admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + bodies, err := tenant.BuildNamespaceRuleBodyStatus(c.Scheme(), ns, tnt) + if err != nil { + return handlers.ErroredResponse(err) + } + + bodies, err = ruleengine.FilterNamespaceRulesByAudience(h.configuration, tnt, req, bodies) + if err != nil { + return handlers.ErroredResponse(err) + } + + return h.generic.OnCreate(c, reader, partialNamespaceMetadata(ns), decoder, recorder, tnt, bodies)(ctx, req) + } +} + +func (h *rulesMetadataHandler) OnUpdate( + c client.Client, + reader client.Reader, + _ users.AdmissionUser, + ns *corev1.Namespace, + old *corev1.Namespace, + decoder admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + if req.SubResource == "finalize" { + return nil + } + + bodies, err := tenant.BuildNamespaceRuleBodyStatus(c.Scheme(), ns, tnt) + if err != nil { + return handlers.ErroredResponse(err) + } + + bodies, err = ruleengine.FilterNamespaceRulesByAudience(h.configuration, tnt, req, bodies) + if err != nil { + return handlers.ErroredResponse(err) + } + + return h.generic.OnUpdate( + c, + reader, + partialNamespaceMetadata(old), + partialNamespaceMetadata(ns), + decoder, + recorder, + tnt, + bodies, + )(ctx, req) + } +} + +func (h *rulesMetadataHandler) OnDelete( + client.Client, + client.Reader, + users.AdmissionUser, + *corev1.Namespace, + admission.Decoder, + events.EventRecorder, + *capsulev1beta2.Tenant, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { return nil } +} + +func partialNamespaceMetadata(ns *corev1.Namespace) *metav1.PartialObjectMetadata { + if ns == nil { + return nil + } + + return &metav1.PartialObjectMetadata{ObjectMeta: *ns.ObjectMeta.DeepCopy()} +} diff --git a/internal/webhook/namespace/validation/rules_metadata_test.go b/internal/webhook/namespace/validation/rules_metadata_test.go new file mode 100644 index 00000000..05291be3 --- /dev/null +++ b/internal/webhook/namespace/validation/rules_metadata_test.go @@ -0,0 +1,108 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + "testing" + + "github.com/go-logr/logr" + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/api/rules" + apiruntime "github.com/projectcapsule/capsule/pkg/api/runtime" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/users" +) + +func TestRulesMetadataHandlerSkipsFinalize(t *testing.T) { + t.Parallel() + + handler := RulesMetadataHandler(nil, nil) + request := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Update, + SubResource: "finalize", + }} + + response := handler.OnUpdate( + nil, + nil, + users.AdmissionUser{}, + nil, + nil, + nil, + nil, + nil, + )(context.Background(), request) + if response != nil { + t.Fatalf("OnUpdate() response = %#v, want nil", response) + } +} + +func TestRulesMetadataHandlerValidatesStatusMetadata(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("add core API to scheme: %v", err) + } + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("add Capsule API to scheme: %v", err) + } + + tnt := &capsulev1beta2.Tenant{ObjectMeta: metav1.ObjectMeta{Name: "solar"}} + tnt.Spec.Rules = []*rules.NamespaceRuleBodyTenant{{ + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Metadata: []rules.MetadataRule{{ + VersionKinds: apiruntime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"Namespace"}}, + Labels: map[string]rules.MetadataValueRule{ + "pod-security.kubernetes.io/enforce": { + Required: true, + Values: []apiruntime.ExpressionMatch{{Exact: []string{"restricted", "baseline"}}}, + }, + }, + }}, + }, + }, + }} + + oldNs := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ + Name: "solar-system", + Labels: map[string]string{"pod-security.kubernetes.io/enforce": "baseline"}, + }} + newNs := oldNs.DeepCopy() + newNs.Labels["pod-security.kubernetes.io/enforce"] = "privileged" + + client := fake.NewClientBuilder().WithScheme(scheme).Build() + recorder := events.NewEventRecorder(nil, logr.Discard(), nil, nil) + handler := RulesMetadataHandler(cache.NewRegexCache(), nil) + request := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Kind: metav1.GroupVersionKind{Version: "v1", Kind: "Namespace"}, + Operation: admissionv1.Update, + SubResource: "status", + }} + + response := handler.OnUpdate( + client, + client, + users.AdmissionUser{}, + newNs, + oldNs, + nil, + recorder, + tnt, + )(context.Background(), request) + if response == nil || response.Allowed { + t.Fatalf("OnUpdate() response = %#v, want metadata injection denied", response) + } +} diff --git a/internal/webhook/pod/handler.go b/internal/webhook/pod/handler.go index a64f107e..2739e525 100644 --- a/internal/webhook/pod/handler.go +++ b/internal/webhook/pod/handler.go @@ -6,14 +6,16 @@ package pod import ( corev1 "k8s.io/api/core/v1" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) -func Handler(handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod]) handlers.Handler { +func Handler(cfg configuration.Configuration, handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod]) handlers.Handler { return &handlers.TypedTenantWithRulesetHandler[*corev1.Pod]{ Factory: func() *corev1.Pod { return &corev1.Pod{} }, - Handlers: handler, + Handlers: handler, + Configuration: cfg, } } diff --git a/internal/webhook/rules/generic/mutation/metadata.go b/internal/webhook/rules/generic/mutation/metadata.go new file mode 100644 index 00000000..2ab5ec6a --- /dev/null +++ b/internal/webhook/rules/generic/mutation/metadata.go @@ -0,0 +1,128 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package mutation + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +type metadataRules struct{} + +func MetadataRules() handlers.TypedHandlerWithTenantWithRuleset[*unstructured.Unstructured] { + return &metadataRules{} +} + +func (h *metadataRules) OnCreate(_ client.Client, _ client.Reader, obj *unstructured.Unstructured, _ admission.Decoder, _ events.EventRecorder, _ *capsulev1beta2.Tenant, bodies []*apirules.NamespaceRuleBodyNamespace) handlers.Func { + return h.mutate(obj, bodies) +} + +func (h *metadataRules) OnUpdate(_ client.Client, _ client.Reader, _ *unstructured.Unstructured, obj *unstructured.Unstructured, _ admission.Decoder, _ events.EventRecorder, _ *capsulev1beta2.Tenant, bodies []*apirules.NamespaceRuleBodyNamespace) handlers.Func { + return h.mutate(obj, bodies) +} + +func (*metadataRules) OnDelete(client.Client, client.Reader, *unstructured.Unstructured, admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, []*apirules.NamespaceRuleBodyNamespace) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { return nil } +} + +func (*metadataRules) mutate(obj *unstructured.Unstructured, bodies []*apirules.NamespaceRuleBodyNamespace) handlers.Func { + return func(_ context.Context, req admission.Request) *admission.Response { + gvk := schema.GroupVersionKind{Group: req.Kind.Group, Version: req.Kind.Version, Kind: req.Kind.Kind} + if gvk.Version == "" || gvk.Kind == "" { + response := admission.Errored(http.StatusBadRequest, fmt.Errorf("admission request kind is incomplete: %s", gvk.String())) + + return &response + } + + MutateMetadata(obj, gvk, bodies) + + marshaled, err := json.Marshal(obj) + if err != nil { + response := admission.Errored(http.StatusInternalServerError, err) + + return &response + } + + response := admission.PatchResponseFromRaw(req.Object.Raw, marshaled) + + return &response + } +} + +func MutateMetadata(obj metav1.Object, gvk schema.GroupVersionKind, bodies []*apirules.NamespaceRuleBodyNamespace) { + if obj == nil { + return + } + + labels, annotations := obj.GetLabels(), obj.GetAnnotations() + defaultLabels, managedLabels := map[string]string{}, map[string]string{} + defaultAnnotations, managedAnnotations := map[string]string{}, map[string]string{} + + for _, body := range bodies { + if body == nil || body.Enforce == nil { + continue + } + + for _, rule := range body.Enforce.Metadata { + if !rule.MatchesGroupVersionKind(gvk) { + continue + } + + collectMutation(rule.Labels, defaultLabels, managedLabels) + collectMutation(rule.Annotations, defaultAnnotations, managedAnnotations) + } + } + + labels = applyMutation(labels, defaultLabels, managedLabels) + annotations = applyMutation(annotations, defaultAnnotations, managedAnnotations) + + obj.SetLabels(labels) + obj.SetAnnotations(annotations) +} + +func collectMutation(policies map[string]apirules.MetadataValueRule, defaults, managed map[string]string) { + for key, policy := range policies { + if policy.Default != nil { + defaults[key] = *policy.Default + } + + if policy.Managed != nil { + managed[key] = *policy.Managed + } + } +} + +func applyMutation(current, defaults, managed map[string]string) map[string]string { + if len(defaults) == 0 && len(managed) == 0 { + return current + } + + if current == nil { + current = map[string]string{} + } + + for key, value := range defaults { + if _, ok := current[key]; !ok { + current[key] = value + } + } + + maps.Copy(current, managed) + + return current +} diff --git a/internal/webhook/rules/generic/mutation/metadata_test.go b/internal/webhook/rules/generic/mutation/metadata_test.go new file mode 100644 index 00000000..b424fe54 --- /dev/null +++ b/internal/webhook/rules/generic/mutation/metadata_test.go @@ -0,0 +1,55 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package mutation + +import ( + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" +) + +func TestMutateMetadataDefaultsAndManaged(t *testing.T) { + t.Parallel() + obj := &unstructured.Unstructured{} + obj.SetLabels(map[string]string{"default-present": "user", "managed": "user"}) + obj.Object["roleRef"] = map[string]any{ + "apiGroup": "rbac.authorization.k8s.io", + "kind": "ClusterRole", + "name": "admin", + } + obj.Object["subjects"] = []any{map[string]any{"kind": "User", "name": "alice"}} + bodies := []*rules.NamespaceRuleBodyNamespace{{Enforce: &rules.NamespaceRuleEnforceBody{ + Metadata: []rules.MetadataRule{{ + VersionKinds: runtime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"ConfigMap"}}, + Labels: map[string]rules.MetadataValueRule{ + "default-missing": {Default: ptr.To("fallback")}, + "default-present": {Default: ptr.To("fallback")}, + "managed": {Default: ptr.To("fallback"), Managed: ptr.To("controlled")}, + }, + }}, + }}} + + MutateMetadata(obj, schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, bodies) + if got := obj.GetLabels()["default-missing"]; got != "fallback" { + t.Fatalf("default = %q", got) + } + if got := obj.GetLabels()["default-present"]; got != "user" { + t.Fatalf("present default = %q", got) + } + if got := obj.GetLabels()["managed"]; got != "controlled" { + t.Fatalf("managed = %q", got) + } + roleRef, ok := obj.Object["roleRef"].(map[string]any) + if !ok || roleRef["kind"] != "ClusterRole" || roleRef["name"] != "admin" { + t.Fatalf("roleRef was changed: %#v", obj.Object["roleRef"]) + } + if _, ok := obj.Object["subjects"]; !ok { + t.Fatal("subjects were removed") + } +} diff --git a/internal/webhook/rules/generic/mutation/register.go b/internal/webhook/rules/generic/mutation/register.go new file mode 100644 index 00000000..a77a9a0b --- /dev/null +++ b/internal/webhook/rules/generic/mutation/register.go @@ -0,0 +1,33 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package mutation + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +const Path = "/rules/generic/mutating" + +type genericMutating struct{ configuration configuration.Configuration } + +func Register(cfg configuration.Configuration) handlers.Webhook { + return &genericMutating{configuration: cfg} +} + +func (g genericMutating) GetHandlers() []handlers.Handler { + return []handlers.Handler{genericHandler(g.configuration, MetadataRules())} +} + +func (genericMutating) GetPath() string { return Path } + +func genericHandler(cfg configuration.Configuration, handler ...handlers.TypedHandlerWithTenantWithRuleset[*unstructured.Unstructured]) handlers.Handler { + return &handlers.TypedTenantWithRulesetHandler[*unstructured.Unstructured]{ + Factory: func() *unstructured.Unstructured { return &unstructured.Unstructured{} }, + Handlers: handler, + Configuration: cfg, + } +} diff --git a/internal/webhook/rules/generic/validation/ingress.go b/internal/webhook/rules/generic/validation/ingress.go new file mode 100644 index 00000000..c66eb311 --- /dev/null +++ b/internal/webhook/rules/generic/validation/ingress.go @@ -0,0 +1,447 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + "fmt" + "slices" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/cache" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" + "github.com/projectcapsule/capsule/pkg/ruleengine" + ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +type ingressRules struct { + regexCache *cache.RegexCache +} + +func IngressRules(regexCache *cache.RegexCache) handlers.TypedHandlerWithTenantWithRuleset[*unstructured.Unstructured] { + if regexCache == nil { + regexCache = cache.NewRegexCache() + } + + return &ingressRules{regexCache: regexCache} +} + +func (h *ingressRules) OnCreate( + _ client.Client, + _ client.Reader, + obj *unstructured.Unstructured, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return h.validate(obj, recorder, tnt, bodies) +} + +func (h *ingressRules) OnUpdate( + _ client.Client, + _ client.Reader, + _ *unstructured.Unstructured, + obj *unstructured.Unstructured, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return h.validate(obj, recorder, tnt, bodies) +} + +func (*ingressRules) OnDelete( + client.Client, + client.Reader, + *unstructured.Unstructured, + admission.Decoder, + events.EventRecorder, + *capsulev1beta2.Tenant, + []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { return nil } +} + +func (h *ingressRules) validate( + obj *unstructured.Unstructured, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + gvk := schema.GroupVersionKind{Group: req.Kind.Group, Version: req.Kind.Version, Kind: req.Kind.Kind} + + resourceType, supported := ingressTypeForGVK(gvk) + if !supported { + return nil + } + + enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies) + + evaluation, err := h.evaluate(obj, resourceType, enforceBodies) + if err != nil { + return ad.Deny(err.Error()) + } + + if evaluation == nil { + return nil + } + + for _, audit := range evaluation.Audits { + recorder.LabeledEvent( + obj, + corev1.EventTypeNormal, + events.ReasonNamespaceRuleAudit, + events.ActionRuleAudit, + audit.Message, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + } + + if err := evaluation.BlockingError(); err != nil { + recorder.LabeledEvent( + obj, + corev1.EventTypeWarning, + events.ReasonForbiddenIngressHostname, + events.ActionValidationDenied, + err.Error(), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + + return ad.Deny(err.Error()) + } + + return nil + } +} + +func (h *ingressRules) evaluate( + obj *unstructured.Unstructured, + resourceType apirules.IngressType, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + if obj == nil || !hasIngressHostnameRules(resourceType, enforceBodies) { + return nil, nil + } + + values, err := ingressHostnameValues(obj, resourceType) + if err != nil { + return nil, err + } + + if len(values) == 0 { + values = []ruleengine.Value{{Path: hostnameRootPath(resourceType)}} + } + + evaluation := &ruleengine.Evaluation{} + hasAuditRules := hasIngressHostnameRulesForAction(resourceType, enforceBodies, apirules.ActionTypeAudit) + hasEnforcingRules := hasEnforcingIngressHostnameRules(resourceType, enforceBodies) + + for _, value := range values { + if strings.TrimSpace(value.Value) != "" { + continue + } + + if hasAuditRules { + evaluation.Audits = append(evaluation.Audits, missingHostnameAuditDecision(value, resourceType)) + } + + if hasEnforcingRules { + evaluation.Blocking = missingHostnameDecision(value, resourceType) + + return evaluation, nil + } + } + + hostnameEvaluation, err := ruleengine.EvaluateEnforce( + obj, + enforceBodies, + ruleengine.Set[runtime.ExpressionMatch, *unstructured.Unstructured]{ + Name: "ingress hostname", + EventReason: events.ReasonForbiddenIngressHostname, + Values: func(*unstructured.Unstructured) []ruleengine.Value { + return values + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch { + if enforce == nil || !containsIngressType(enforce.Ingress.Types, resourceType) { + return nil + } + + return enforce.Ingress.Hostnames + }, + Matches: func(match runtime.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { + matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value) + if err != nil { + return ruleengine.Match{}, err + } + + return ruleengine.Match{Matched: matched, MatchedValue: runtime.DescribeExpressionMatch(match)}, nil + }, + RuleDescription: runtime.DescribeExpressionMatch, + AllowedDescription: "Allowed hostnames", + }, + ) + if err != nil { + return nil, err + } + + evaluation.Append(hostnameEvaluation) + + return evaluation, nil +} + +//nolint:exhaustive +func ingressTypeForGVK(gvk schema.GroupVersionKind) (apirules.IngressType, bool) { + switch { + case gvk.Group == "networking.k8s.io" && gvk.Version == "v1" && gvk.Kind == string(apirules.IngressTypeIngress): + return apirules.IngressTypeIngress, true + case gvk.Group == "route.openshift.io" && gvk.Version == "v1" && gvk.Kind == string(apirules.IngressTypeRoute): + return apirules.IngressTypeRoute, true + case gvk.Group == "gateway.networking.k8s.io" && gvk.Version == "v1": + resourceType := apirules.IngressType(gvk.Kind) + switch resourceType { + case apirules.IngressTypeListenerSet, + apirules.IngressTypeHTTPRoute, + apirules.IngressTypeGateway, + apirules.IngressTypeTLSRoute, + apirules.IngressTypeGRPCRoute: + return resourceType, true + } + } + + return "", false +} + +func hasIngressHostnameRules(resourceType apirules.IngressType, bodies []*apirules.NamespaceRuleEnforceBody) bool { + for _, body := range bodies { + if body != nil && containsIngressType(body.Ingress.Types, resourceType) && len(body.Ingress.Hostnames) > 0 { + return true + } + } + + return false +} + +func hasIngressHostnameRulesForAction( + resourceType apirules.IngressType, + bodies []*apirules.NamespaceRuleEnforceBody, + action apirules.ActionType, +) bool { + for _, body := range bodies { + if body != nil && + body.Action.OrDefault() == action && + containsIngressType(body.Ingress.Types, resourceType) && + len(body.Ingress.Hostnames) > 0 { + return true + } + } + + return false +} + +func hasEnforcingIngressHostnameRules( + resourceType apirules.IngressType, + bodies []*apirules.NamespaceRuleEnforceBody, +) bool { + for _, body := range bodies { + if body != nil && + body.Action.OrDefault() != apirules.ActionTypeAudit && + containsIngressType(body.Ingress.Types, resourceType) && + len(body.Ingress.Hostnames) > 0 { + return true + } + } + + return false +} + +func containsIngressType(types []apirules.IngressType, expected apirules.IngressType) bool { + return slices.Contains(types, expected) +} + +func missingHostnameDecision(value ruleengine.Value, resourceType apirules.IngressType) *ruleengine.Decision { + return &ruleengine.Decision{ + SetName: "ingress hostname", + EventReason: events.ReasonForbiddenIngressHostname, + Action: apirules.ActionTypeDeny, + Value: value, + Message: fmt.Sprintf( + "hostname is required at %s because hostname rules target %s", + value.Path, + resourceType, + ), + } +} + +func missingHostnameAuditDecision(value ruleengine.Value, resourceType apirules.IngressType) *ruleengine.Decision { + return &ruleengine.Decision{ + SetName: "ingress hostname", + EventReason: events.ReasonNamespaceRuleAudit, + Action: apirules.ActionTypeAudit, + Value: value, + Message: fmt.Sprintf( + "empty hostname detected at %s for %s by audit namespace rule", + value.Path, + resourceType, + ), + } +} + +//nolint:exhaustive +func hostnameRootPath(resourceType apirules.IngressType) string { + switch resourceType { + case apirules.IngressTypeIngress: + return "spec.rules[].host" + case apirules.IngressTypeRoute: + return "spec.host" + case apirules.IngressTypeGateway, apirules.IngressTypeListenerSet: + return "spec.listeners[].hostname" + default: + return "spec.hostnames[]" + } +} + +func ingressHostnameValues(obj *unstructured.Unstructured, resourceType apirules.IngressType) ([]ruleengine.Value, error) { + switch resourceType { + case apirules.IngressTypeIngress: + return ingressValues(obj) + case apirules.IngressTypeRoute: + return routeObjectValues(obj) + case apirules.IngressTypeGateway, apirules.IngressTypeListenerSet: + return listenerValues(obj) + case apirules.IngressTypeHTTPRoute, apirules.IngressTypeTLSRoute, apirules.IngressTypeGRPCRoute: + return routeValues(obj) + default: + return nil, nil + } +} + +func routeObjectValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) { + host, _, err := unstructured.NestedString(obj.Object, "spec", "host") + if err != nil { + return nil, fmt.Errorf("read spec.host: %w", err) + } + + return []ruleengine.Value{{Value: strings.TrimSpace(host), Path: "spec.host"}}, nil +} + +func ingressValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) { + values := make([]ruleengine.Value, 0) + + rules, found, err := unstructured.NestedSlice(obj.Object, "spec", "rules") + if err != nil { + return nil, fmt.Errorf("read spec.rules: %w", err) + } + + if found { + for i, item := range rules { + rule, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("spec.rules[%d] is not an object", i) + } + + host, _, err := unstructured.NestedString(rule, "host") + if err != nil { + return nil, fmt.Errorf("read spec.rules[%d].host: %w", i, err) + } + + values = append(values, ruleengine.Value{Value: strings.TrimSpace(host), Path: fmt.Sprintf("spec.rules[%d].host", i)}) + } + } + + tls, found, err := unstructured.NestedSlice(obj.Object, "spec", "tls") + if err != nil { + return nil, fmt.Errorf("read spec.tls: %w", err) + } + + if found { + for i, item := range tls { + entry, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("spec.tls[%d] is not an object", i) + } + + hosts, hostsFound, err := unstructured.NestedStringSlice(entry, "hosts") + if err != nil { + return nil, fmt.Errorf("read spec.tls[%d].hosts: %w", i, err) + } + + if !hostsFound || len(hosts) == 0 { + values = append(values, ruleengine.Value{Path: fmt.Sprintf("spec.tls[%d].hosts", i)}) + + continue + } + + for j, host := range hosts { + values = append(values, ruleengine.Value{Value: strings.TrimSpace(host), Path: fmt.Sprintf("spec.tls[%d].hosts[%d]", i, j)}) + } + } + } + + return values, nil +} + +func listenerValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) { + listeners, found, err := unstructured.NestedSlice(obj.Object, "spec", "listeners") + if err != nil { + return nil, fmt.Errorf("read spec.listeners: %w", err) + } + + if !found { + return nil, nil + } + + values := make([]ruleengine.Value, 0, len(listeners)) + + for i, item := range listeners { + listener, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("spec.listeners[%d] is not an object", i) + } + + hostname, _, err := unstructured.NestedString(listener, "hostname") + if err != nil { + return nil, fmt.Errorf("read spec.listeners[%d].hostname: %w", i, err) + } + + values = append(values, ruleengine.Value{Value: strings.TrimSpace(hostname), Path: fmt.Sprintf("spec.listeners[%d].hostname", i)}) + } + + return values, nil +} + +func routeValues(obj *unstructured.Unstructured) ([]ruleengine.Value, error) { + hostnames, found, err := unstructured.NestedStringSlice(obj.Object, "spec", "hostnames") + if err != nil { + return nil, fmt.Errorf("read spec.hostnames: %w", err) + } + + if !found { + return nil, nil + } + + values := make([]ruleengine.Value, 0, len(hostnames)) + for i, hostname := range hostnames { + values = append(values, ruleengine.Value{Value: strings.TrimSpace(hostname), Path: fmt.Sprintf("spec.hostnames[%d]", i)}) + } + + return values, nil +} diff --git a/internal/webhook/rules/generic/validation/ingress_test.go b/internal/webhook/rules/generic/validation/ingress_test.go new file mode 100644 index 00000000..e92f234e --- /dev/null +++ b/internal/webhook/rules/generic/validation/ingress_test.go @@ -0,0 +1,246 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" +) + +func TestIngressTypeForGVK(t *testing.T) { + t.Parallel() + + tests := []struct { + gvk schema.GroupVersionKind + want rules.IngressType + ok bool + }{ + {schema.GroupVersionKind{Group: "networking.k8s.io", Version: "v1", Kind: "Ingress"}, rules.IngressTypeIngress, true}, + {schema.GroupVersionKind{Group: "route.openshift.io", Version: "v1", Kind: "Route"}, rules.IngressTypeRoute, true}, + {schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "Gateway"}, rules.IngressTypeGateway, true}, + {schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "ListenerSet"}, rules.IngressTypeListenerSet, true}, + {schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "HTTPRoute"}, rules.IngressTypeHTTPRoute, true}, + {schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "TLSRoute"}, rules.IngressTypeTLSRoute, true}, + {schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1", Kind: "GRPCRoute"}, rules.IngressTypeGRPCRoute, true}, + {schema.GroupVersionKind{Group: "gateway.networking.k8s.io", Version: "v1beta1", Kind: "Gateway"}, "", false}, + {schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "Ingress"}, "", false}, + } + + for _, tt := range tests { + got, ok := ingressTypeForGVK(tt.gvk) + if ok != tt.ok || got != tt.want { + t.Fatalf("ingressTypeForGVK(%s) = (%q, %v), want (%q, %v)", tt.gvk, got, ok, tt.want, tt.ok) + } + } +} + +func TestIngressHostnameEvaluationSupportsAllResourceShapes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resourceType rules.IngressType + spec map[string]any + }{ + {"Ingress", rules.IngressTypeIngress, map[string]any{ + "rules": []any{map[string]any{"host": "prod.example.com"}}, + "tls": []any{map[string]any{"hosts": []any{"test.example.com"}}}, + }}, + {"OpenShift Route", rules.IngressTypeRoute, map[string]any{"host": "prod.example.com"}}, + {"Gateway", rules.IngressTypeGateway, listenerSpec("prod.example.com")}, + {"ListenerSet", rules.IngressTypeListenerSet, listenerSpec("test.example.com")}, + {"HTTPRoute", rules.IngressTypeHTTPRoute, routeSpec("prod.example.com")}, + {"TLSRoute", rules.IngressTypeTLSRoute, routeSpec("test.example.com")}, + {"GRPCRoute", rules.IngressTypeGRPCRoute, routeSpec("prod.example.com")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + evaluation, err := testIngressRules().evaluate( + objectWithSpec(tt.spec), + tt.resourceType, + ingressRuleBodies(rules.ActionTypeAllow, tt.resourceType, runtime.ExpressionMatch{Exact: []string{"prod.example.com", "test.example.com"}}), + ) + if err != nil { + t.Fatalf("evaluate() error = %v", err) + } + if evaluation == nil || evaluation.Blocking != nil { + t.Fatalf("evaluate() = %#v, want allowed", evaluation) + } + }) + } +} + +func TestIngressHostnameEvaluationRegexAndAllowMiss(t *testing.T) { + t.Parallel() + + body := ingressRuleBodies( + rules.ActionTypeAllow, + rules.IngressTypeHTTPRoute, + runtime.ExpressionMatch{ExpressionRegex: runtime.ExpressionRegex{Expression: ".*\\.example\\.com"}}, + ) + + allowed, err := testIngressRules().evaluate(objectWithSpec(routeSpec("api.example.com")), rules.IngressTypeHTTPRoute, body) + if err != nil || allowed == nil || allowed.Blocking != nil { + t.Fatalf("allowed evaluation = %#v, err = %v", allowed, err) + } + + denied, err := testIngressRules().evaluate(objectWithSpec(routeSpec("api.example.net")), rules.IngressTypeHTTPRoute, body) + if err != nil { + t.Fatalf("denied evaluation error = %v", err) + } + if denied == nil || denied.Blocking == nil || !strings.Contains(denied.Blocking.Message, "not allowed") { + t.Fatalf("denied evaluation = %#v, want allow-list denial", denied) + } +} + +func TestIngressHostnameEvaluationRejectsMissingValues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resourceType rules.IngressType + spec map[string]any + wantPath string + }{ + {"Ingress without rules", rules.IngressTypeIngress, map[string]any{}, "spec.rules[].host"}, + {"Ingress rule without host", rules.IngressTypeIngress, map[string]any{"rules": []any{map[string]any{}}}, "spec.rules[0].host"}, + {"OpenShift Route without host", rules.IngressTypeRoute, map[string]any{}, "spec.host"}, + {"Gateway listener without hostname", rules.IngressTypeGateway, listenerSpec(""), "spec.listeners[0].hostname"}, + {"ListenerSet listener without hostname", rules.IngressTypeListenerSet, listenerSpec(""), "spec.listeners[0].hostname"}, + {"HTTPRoute without hostnames", rules.IngressTypeHTTPRoute, map[string]any{}, "spec.hostnames[]"}, + {"TLSRoute without hostnames", rules.IngressTypeTLSRoute, map[string]any{}, "spec.hostnames[]"}, + {"GRPCRoute without hostnames", rules.IngressTypeGRPCRoute, map[string]any{}, "spec.hostnames[]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + evaluation, err := testIngressRules().evaluate( + objectWithSpec(tt.spec), + tt.resourceType, + ingressRuleBodies(rules.ActionTypeDeny, tt.resourceType, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}}), + ) + if err != nil { + t.Fatalf("evaluate() error = %v", err) + } + if evaluation == nil || evaluation.Blocking == nil || !strings.Contains(evaluation.Blocking.Message, tt.wantPath) { + t.Fatalf("evaluation = %#v, want missing hostname at %q", evaluation, tt.wantPath) + } + }) + } +} + +func TestIngressHostnameEvaluationAuditsMissingValuesWithoutBlocking(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + resourceType rules.IngressType + spec map[string]any + wantPath string + }{ + {"Ingress without rules", rules.IngressTypeIngress, map[string]any{}, "spec.rules[].host"}, + {"Ingress rule without host", rules.IngressTypeIngress, map[string]any{"rules": []any{map[string]any{}}}, "spec.rules[0].host"}, + {"OpenShift Route without host", rules.IngressTypeRoute, map[string]any{}, "spec.host"}, + {"Gateway listener without hostname", rules.IngressTypeGateway, listenerSpec(""), "spec.listeners[0].hostname"}, + {"HTTPRoute without hostnames", rules.IngressTypeHTTPRoute, map[string]any{}, "spec.hostnames[]"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + evaluation, err := testIngressRules().evaluate( + objectWithSpec(tt.spec), + tt.resourceType, + ingressRuleBodies(rules.ActionTypeAudit, tt.resourceType, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}}), + ) + if err != nil { + t.Fatalf("evaluate() error = %v", err) + } + if evaluation == nil || evaluation.Blocking != nil { + t.Fatalf("evaluation = %#v, want non-blocking audit", evaluation) + } + if len(evaluation.Audits) != 1 || !strings.Contains(evaluation.Audits[0].Message, tt.wantPath) { + t.Fatalf("audits = %#v, want missing hostname audit at %q", evaluation.Audits, tt.wantPath) + } + }) + } +} + +func TestIngressHostnameEvaluationAuditsAndRejectsMissingValueWithEnforcingRule(t *testing.T) { + t.Parallel() + + bodies := append( + ingressRuleBodies(rules.ActionTypeAudit, rules.IngressTypeGateway, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}}), + ingressRuleBodies(rules.ActionTypeAllow, rules.IngressTypeGateway, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}})..., + ) + + evaluation, err := testIngressRules().evaluate( + objectWithSpec(listenerSpec("")), + rules.IngressTypeGateway, + bodies, + ) + if err != nil { + t.Fatalf("evaluate() error = %v", err) + } + if evaluation == nil || evaluation.Blocking == nil { + t.Fatalf("evaluation = %#v, want blocking decision", evaluation) + } + if len(evaluation.Audits) != 1 || !strings.Contains(evaluation.Audits[0].Message, "empty hostname detected") { + t.Fatalf("audits = %#v, want missing hostname audit", evaluation.Audits) + } +} + +func TestIngressHostnameEvaluationIgnoresUntargetedTypes(t *testing.T) { + t.Parallel() + + evaluation, err := testIngressRules().evaluate( + objectWithSpec(map[string]any{}), + rules.IngressTypeGateway, + ingressRuleBodies(rules.ActionTypeAllow, rules.IngressTypeIngress, runtime.ExpressionMatch{Exact: []string{"prod.example.com"}}), + ) + if err != nil || evaluation != nil { + t.Fatalf("evaluate() = %#v, err = %v, want nil", evaluation, err) + } +} + +func testIngressRules() *ingressRules { + return &ingressRules{regexCache: cache.NewRegexCache()} +} + +func objectWithSpec(spec map[string]any) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{"spec": spec}} +} + +func listenerSpec(hostname string) map[string]any { + listener := map[string]any{"name": "https"} + if hostname != "" { + listener["hostname"] = hostname + } + return map[string]any{"listeners": []any{listener}} +} + +func routeSpec(hostname string) map[string]any { + return map[string]any{"hostnames": []any{hostname}} +} + +func ingressRuleBodies(action rules.ActionType, resourceType rules.IngressType, hostnames ...runtime.ExpressionMatch) []*rules.NamespaceRuleEnforceBody { + return []*rules.NamespaceRuleEnforceBody{{ + Action: action, + Ingress: rules.NamespaceRuleEnforceIngressBody{ + Types: []rules.IngressType{resourceType}, + Hostnames: hostnames, + }, + }} +} diff --git a/internal/webhook/rules/generic/validation/metadata.go b/internal/webhook/rules/generic/validation/metadata.go index b80acb66..f026f3a6 100644 --- a/internal/webhook/rules/generic/validation/metadata.go +++ b/internal/webhook/rules/generic/validation/metadata.go @@ -1,6 +1,7 @@ // Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 +//nolint:dupl package validation import ( @@ -40,7 +41,11 @@ func (h *genericRules) validateMetadata( return nil, nil } - entries := h.controlledMetadataEntries(obj, gvk, enforceBodies) + entries, err := h.controlledMetadataEntries(obj, gvk, enforceBodies) + if err != nil { + return nil, err + } + if len(entries) == 0 { return nil, nil } @@ -115,9 +120,9 @@ func (h *genericRules) metadataSet( } }, - Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []runtime.ExpressionMatch { + RulesWithError: func(enforce *apirules.NamespaceRuleEnforceBody) ([]runtime.ExpressionMatch, error) { if enforce == nil || len(enforce.Metadata) == 0 { - return nil + return nil, nil } var out []runtime.ExpressionMatch @@ -130,19 +135,31 @@ func (h *genericRules) metadataSet( switch entry.Field { case metadataFieldLabel: - policy, ok := rule.Labels[entry.Key] - if ok { - out = append(out, policy.Values...) + for selector, policy := range rule.Labels { + matched, err := h.matchesMetadataKey(selector, entry.Key) + if err != nil { + return nil, fmt.Errorf("invalid label selector %q: %w", selector, err) + } + + if matched { + out = append(out, policy.Values...) + } } case metadataFieldAnnotation: - policy, ok := rule.Annotations[entry.Key] - if ok { - out = append(out, policy.Values...) + for selector, policy := range rule.Annotations { + matched, err := h.matchesMetadataKey(selector, entry.Key) + if err != nil { + return nil, fmt.Errorf("invalid annotation selector %q: %w", selector, err) + } + + if matched { + out = append(out, policy.Values...) + } } } } - return out + return out, nil }, Matches: func(match runtime.ExpressionMatch, value ruleengine.Value) (ruleengine.Match, error) { matched, err := match.MatchesWithExpressionMatcher(h.regexCache, value.Value) @@ -159,11 +176,12 @@ func (h *genericRules) metadataSet( } } +//nolint:gocognit func (h *genericRules) controlledMetadataEntries( obj genericObject, gvk schema.GroupVersionKind, enforceBodies []*apirules.NamespaceRuleEnforceBody, -) []metadataEntry { +) ([]metadataEntry, error) { labels := obj.GetLabels() annotations := obj.GetAnnotations() @@ -182,60 +200,74 @@ func (h *genericRules) controlledMetadataEntries( continue } - for key, policy := range rule.Labels { - if h.managedMetadata.HasLabel(key) { + for selector, policy := range rule.Labels { + if h.managedMetadata.HasLabel(selector) { continue } - value, exists := labels[key] required := action == apirules.ActionTypeAllow && policy.Required + matchedAny := false - if !exists && !required { - continue + for key, value := range labels { + matched, err := h.matchesMetadataKey(selector, key) + if err != nil { + return nil, fmt.Errorf("invalid label selector %q: %w", selector, err) + } + + if !matched { + continue + } + + if h.managedMetadata.HasLabel(key) { + continue + } + + matchedAny = true + + h.addMetadataEntry(seen, metadataFieldLabel, key, value, true, required) } - path := metadataLabelPath(key) - - entry := seen[path] - entry.Field = metadataFieldLabel - entry.Key = key - entry.Path = path - entry.Present = exists - entry.Value = value - entry.Required = entry.Required || required - - seen[path] = entry + if !matchedAny && required { + h.addMetadataEntry(seen, metadataFieldLabel, selector, "", false, true) + } } - for key, policy := range rule.Annotations { - if h.managedMetadata.HasAnnotation(key) { + for selector, policy := range rule.Annotations { + if h.managedMetadata.HasAnnotation(selector) { continue } - value, exists := annotations[key] required := action == apirules.ActionTypeAllow && policy.Required + matchedAny := false - if !exists && !required { - continue + for key, value := range annotations { + matched, err := h.matchesMetadataKey(selector, key) + if err != nil { + return nil, fmt.Errorf("invalid label selector %q: %w", selector, err) + } + + if !matched { + continue + } + + if h.managedMetadata.HasAnnotation(key) { + continue + } + + matchedAny = true + + h.addMetadataEntry(seen, metadataFieldAnnotation, key, value, true, required) } - path := metadataAnnotationPath(key) - - entry := seen[path] - entry.Field = metadataFieldAnnotation - entry.Key = key - entry.Path = path - entry.Present = exists - entry.Value = value - entry.Required = entry.Required || required - - seen[path] = entry + if !matchedAny && required { + h.addMetadataEntry(seen, metadataFieldAnnotation, selector, "", false, true) + } } } } if len(seen) == 0 { - return nil + return nil, nil } out := make([]metadataEntry, 0, len(seen)) @@ -247,7 +279,39 @@ func (h *genericRules) controlledMetadataEntries( return out[i].Path < out[j].Path }) - return out + return out, nil +} + +func (h *genericRules) matchesMetadataKey(selector, key string) (bool, error) { + return h.regexCache.MatchRegex(apirules.MetadataKeyExpression(selector), key) +} + +func (h *genericRules) addMetadataEntry( + seen map[string]metadataEntry, + field metadataField, + key string, + value string, + present bool, + required bool, +) { + path := metadataLabelPath(key) + if field == metadataFieldAnnotation { + path = metadataAnnotationPath(key) + } + + entry := seen[path] + entry.Field = field + entry.Key = key + entry.Path = path + + entry.Present = entry.Present || present + + if present { + entry.Value = value + } + + entry.Required = entry.Required || required + seen[path] = entry } func metadataSetName(field metadataField) string { diff --git a/internal/webhook/rules/generic/validation/metadata_test.go b/internal/webhook/rules/generic/validation/metadata_test.go index e6d95052..6dd76227 100644 --- a/internal/webhook/rules/generic/validation/metadata_test.go +++ b/internal/webhook/rules/generic/validation/metadata_test.go @@ -419,6 +419,41 @@ func TestValidateMetadata(t *testing.T) { } } +func TestControlledMetadataEntriesMatchesKeyPatternsWithRegexCache(t *testing.T) { + t.Parallel() + + h := newMetadataTestRules(nil, nil) + obj := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + "example.corp/cost-center": "INV-1234", + "unrelated": "ignored", + }, + }} + enforce := []*apirules.NamespaceRuleEnforceBody{{ + Metadata: []apirules.MetadataRule{{ + VersionKinds: runtime.VersionKinds{APIGroups: []string{"v1"}, Kinds: []string{"Namespace"}}, + Annotations: map[string]apirules.MetadataValueRule{ + "example.corp/*": {}, + }, + }}, + }} + + entries, err := h.controlledMetadataEntries( + obj, + schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, + enforce, + ) + if err != nil { + t.Fatalf("controlledMetadataEntries() error = %v", err) + } + if len(entries) != 1 || entries[0].Key != "example.corp/cost-center" { + t.Fatalf("unexpected pattern-matched entries: %#v", entries) + } + if h.regexCache.Stats() != 1 { + t.Fatalf("expected key expression to use regex cache, got %d entries", h.regexCache.Stats()) + } +} + func TestControlledMetadataEntries(t *testing.T) { t.Parallel() @@ -741,6 +776,45 @@ func TestControlledMetadataEntries(t *testing.T) { }, want: nil, }, + { + name: "managed metadata does not satisfy required selectors", + obj: metadataObject( + map[string]string{ + meta.TenantLabel: "tenant-a", + }, + map[string]string{ + meta.ReconcileAnnotation: "true", + }, + ), + gvk: coreGVK("ConfigMap"), + enforceBodies: []*apirules.NamespaceRuleEnforceBody{ + enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{ + "capsule.clastix.io/*": metadataPolicy(true, exact("tenant-a")), + }, + map[string]apirules.MetadataValueRule{ + "reconcile.projectcapsule.dev/*": metadataPolicy(true, exact("true")), + }, + ), + }, + want: []metadataEntry{ + { + Field: metadataFieldAnnotation, + Key: "reconcile.projectcapsule.dev/*", + Path: `metadata.annotations["reconcile.projectcapsule.dev/*"]`, + Required: true, + }, + { + Field: metadataFieldLabel, + Key: "capsule.clastix.io/*", + Path: `metadata.labels["capsule.clastix.io/*"]`, + Required: true, + }, + }, + }, { name: "custom parameters add label and annotation to managed metadata", managedLabels: []string{ @@ -892,7 +966,10 @@ func TestControlledMetadataEntries(t *testing.T) { h := newMetadataTestRules(tt.managedLabels, tt.managedAnnotations) - got := h.controlledMetadataEntries(tt.obj, tt.gvk, tt.enforceBodies) + got, err := h.controlledMetadataEntries(tt.obj, tt.gvk, tt.enforceBodies) + if err != nil { + t.Fatalf("controlledMetadataEntries() error = %v", err) + } if !reflect.DeepEqual(got, tt.want) { t.Fatalf("unexpected entries\nwant: %#v\n got: %#v", tt.want, got) } @@ -942,7 +1019,7 @@ func TestMetadataSet(t *testing.T) { set := h.metadataSet(coreGVK("ConfigMap"), entry) - got := set.Rules(enforceMetadata( + got, err := set.RulesWithError(enforceMetadata( apirules.ActionTypeAllow, []string{"*"}, []string{"ConfigMap"}, @@ -951,6 +1028,9 @@ func TestMetadataSet(t *testing.T) { }, nil, )) + if err != nil { + t.Fatalf("RulesWithError() error = %v", err) + } if len(got) != 2 { t.Fatalf("expected two matchers, got %d", len(got)) @@ -976,7 +1056,7 @@ func TestMetadataSet(t *testing.T) { set := h.metadataSet(coreGVK("ConfigMap"), entry) - got := set.Rules(enforceMetadata( + got, err := set.RulesWithError(enforceMetadata( apirules.ActionTypeAllow, []string{"*"}, []string{"ConfigMap"}, @@ -985,6 +1065,9 @@ func TestMetadataSet(t *testing.T) { "cost-center": metadataPolicy(true, expression("^INV-[0-9]{4}$")), }, )) + if err != nil { + t.Fatalf("RulesWithError() error = %v", err) + } if len(got) != 1 { t.Fatalf("expected one matcher, got %d", len(got)) @@ -1003,7 +1086,11 @@ func TestMetadataSet(t *testing.T) { Key: "env", }) - if got := set.Rules(nil); got != nil { + got, err := set.RulesWithError(nil) + if err != nil { + t.Fatalf("RulesWithError() error = %v", err) + } + if got != nil { t.Fatalf("expected nil, got %#v", got) } }) @@ -1017,7 +1104,7 @@ func TestMetadataSet(t *testing.T) { Key: "env", }) - got := set.Rules(enforceMetadata( + got, err := set.RulesWithError(enforceMetadata( apirules.ActionTypeAllow, []string{"*"}, []string{"ConfigMap"}, @@ -1026,12 +1113,59 @@ func TestMetadataSet(t *testing.T) { }, nil, )) + if err != nil { + t.Fatalf("RulesWithError() error = %v", err) + } if got != nil { t.Fatalf("expected nil, got %#v", got) } }) + t.Run("rules returns invalid key selector errors", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + entry metadataEntry + body *apirules.NamespaceRuleEnforceBody + }{ + { + name: "label", + entry: metadataEntry{Field: metadataFieldLabel, Key: "env"}, + body: enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + map[string]apirules.MetadataValueRule{"[": metadataPolicy(false, exact("prod"))}, + nil, + ), + }, + { + name: "annotation", + entry: metadataEntry{Field: metadataFieldAnnotation, Key: "env"}, + body: enforceMetadata( + apirules.ActionTypeAllow, + []string{"*"}, + []string{"ConfigMap"}, + nil, + map[string]apirules.MetadataValueRule{"[": metadataPolicy(false, exact("prod"))}, + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + set := newMetadataTestRules(nil, nil).metadataSet(coreGVK("ConfigMap"), tt.entry) + if _, err := set.RulesWithError(tt.body); err == nil { + t.Fatal("RulesWithError() error = nil, want invalid selector error") + } + }) + } + }) + t.Run("matches exact value", func(t *testing.T) { t.Parallel() diff --git a/internal/webhook/rules/generic/validation/register.go b/internal/webhook/rules/generic/validation/register.go index 8a64be2a..66eb8478 100644 --- a/internal/webhook/rules/generic/validation/register.go +++ b/internal/webhook/rules/generic/validation/register.go @@ -5,28 +5,35 @@ package validation import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) const Path = "/rules/generic/validating" type genericValidating struct { - regexCache *cache.RegexCache + regexCache *cache.RegexCache + configuration configuration.Configuration } -func Register(regexCache *cache.RegexCache) handlers.Webhook { +func Register(regexCache *cache.RegexCache, cfg configuration.Configuration) handlers.Webhook { return &genericValidating{ - regexCache: regexCache, + regexCache: regexCache, + configuration: cfg, } } func (w *genericValidating) GetHandlers() []handlers.Handler { return []handlers.Handler{ - genericHandler( + genericHandler(w.configuration, GenericRules(w.regexCache), ), + ingressHandler(w.configuration, + IngressRules(w.regexCache), + ), } } @@ -34,13 +41,24 @@ func (genericValidating) GetPath() string { return Path } -func genericHandler( +func ingressHandler(cfg configuration.Configuration, + handler ...handlers.TypedHandlerWithTenantWithRuleset[*unstructured.Unstructured], +) handlers.Handler { + return &handlers.TypedTenantWithRulesetHandler[*unstructured.Unstructured]{ + Factory: func() *unstructured.Unstructured { return &unstructured.Unstructured{} }, + Handlers: handler, + Configuration: cfg, + } +} + +func genericHandler(cfg configuration.Configuration, handler ...handlers.TypedHandlerWithTenantWithRuleset[*metav1.PartialObjectMetadata], ) handlers.Handler { return &handlers.TypedTenantWithRulesetHandler[*metav1.PartialObjectMetadata]{ Factory: func() *metav1.PartialObjectMetadata { return &metav1.PartialObjectMetadata{} }, - Handlers: handler, + Handlers: handler, + Configuration: cfg, } } diff --git a/internal/webhook/service/handler.go b/internal/webhook/service/handler.go index 0601e468..da31ab36 100644 --- a/internal/webhook/service/handler.go +++ b/internal/webhook/service/handler.go @@ -6,14 +6,16 @@ package service import ( corev1 "k8s.io/api/core/v1" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) -func Handler(handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service]) handlers.Handler { +func Handler(cfg configuration.Configuration, handler ...handlers.TypedHandlerWithTenantWithRuleset[*corev1.Service]) handlers.Handler { return &handlers.TypedTenantWithRulesetHandler[*corev1.Service]{ Factory: func() *corev1.Service { return &corev1.Service{} }, - Handlers: handler, + Handlers: handler, + Configuration: cfg, } } diff --git a/pkg/api/rules/enforce_ingress_types.go b/pkg/api/rules/enforce_ingress_types.go new file mode 100644 index 00000000..e9f8cbab --- /dev/null +++ b/pkg/api/rules/enforce_ingress_types.go @@ -0,0 +1,38 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import "github.com/projectcapsule/capsule/pkg/api/runtime" + +// +kubebuilder:validation:Enum=Ingress;Route;ListenerSet;HTTPRoute;Gateway;TLSRoute;GRPCRoute +type IngressType string + +const ( + IngressTypeIngress IngressType = "Ingress" + IngressTypeRoute IngressType = "Route" + IngressTypeListenerSet IngressType = "ListenerSet" + IngressTypeHTTPRoute IngressType = "HTTPRoute" + IngressTypeGateway IngressType = "Gateway" + IngressTypeTLSRoute IngressType = "TLSRoute" + IngressTypeGRPCRoute IngressType = "GRPCRoute" +) + +// NamespaceRuleEnforceIngressBody defines hostname enforcement for Kubernetes +// Ingress and Gateway API resources. +// +// +kubebuilder:object:generate=true +type NamespaceRuleEnforceIngressBody struct { + // Types defines the resource kinds to which hostname enforcement applies. + // + // +kubebuilder:validation:MinItems=1 + Types []IngressType `json:"types,omitempty"` + + // Hostnames defines allowed, denied, or audited hostname expressions. + // A resource targeted by an allow or deny rule must declare non-empty values + // in all hostname fields. Audit-only rules record missing hostnames without + // denying them. + // + // +kubebuilder:validation:MinItems=1 + Hostnames []runtime.ExpressionMatch `json:"hostnames,omitempty"` +} diff --git a/pkg/api/rules/enforce_metadata_types.go b/pkg/api/rules/enforce_metadata_types.go index 93629ced..3eca1280 100644 --- a/pkg/api/rules/enforce_metadata_types.go +++ b/pkg/api/rules/enforce_metadata_types.go @@ -4,6 +4,10 @@ package rules import ( + "strings" + + "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/projectcapsule/capsule/pkg/api/runtime" ) @@ -25,6 +29,29 @@ type MetadataRule struct { Annotations map[string]MetadataValueRule `json:"annotations,omitempty"` } +// MatchesGroupVersionKind matches metadata targets. Namespace is deliberately +// opt-in: wildcard kind selectors never include it, so cluster-scoped +// namespace admission cannot be enabled accidentally. +func (r MetadataRule) MatchesGroupVersionKind(gvk schema.GroupVersionKind) bool { + if gvk.Group == "" && gvk.Version == "v1" && gvk.Kind == "Namespace" { + explicit := false + + for _, kind := range r.Kinds { + if strings.TrimSpace(kind) == "Namespace" { + explicit = true + + break + } + } + + if !explicit { + return false + } + } + + return r.VersionKinds.MatchesGroupVersionKind(gvk) +} + // +kubebuilder:object:generate=true type MetadataValueRule struct { // Required enforces that the metadata key must be present. @@ -42,4 +69,37 @@ type MetadataValueRule struct { // // +optional Values []runtime.ExpressionMatch `json:"values,omitempty"` + + // Default is applied by admission mutation when the concrete metadata key is absent. + // It is not reconciled after admission. + // +optional + Default *string `json:"default,omitempty"` + + // Managed is enforced by admission mutation and reconciled by the RuleStatus + // controller using server-side apply when the rule configuration changes. + // +optional + Managed *string `json:"managed,omitempty"` +} + +// MetadataKeyExpression converts a metadata key selector into the regular +// expression used by admission validation and runtime matching. Asterisks are +// convenient wildcards, while the rest of the selector retains regexp syntax. +func MetadataKeyExpression(selector string) runtime.ExpressionRegex { + selector = strings.TrimSpace(selector) + + var expression strings.Builder + + for i, char := range selector { + if char == '*' && (i == 0 || selector[i-1] != '.') { + expression.WriteString(".*") + + continue + } + + expression.WriteRune(char) + } + + return runtime.ExpressionRegex{ + Expression: "^(?:" + expression.String() + ")$", + } } diff --git a/pkg/api/rules/enforce_metadata_types_test.go b/pkg/api/rules/enforce_metadata_types_test.go new file mode 100644 index 00000000..0e5e470d --- /dev/null +++ b/pkg/api/rules/enforce_metadata_types_test.go @@ -0,0 +1,41 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/projectcapsule/capsule/pkg/api/runtime" +) + +func TestMetadataRuleNamespaceRequiresExplicitKind(t *testing.T) { + t.Parallel() + + namespace := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"} + tests := []struct { + name string + kinds []string + want bool + }{ + {name: "explicit namespace", kinds: []string{"Namespace"}, want: true}, + {name: "wildcard only", kinds: []string{"*"}, want: false}, + {name: "partial wildcard only", kinds: []string{"Name*"}, want: false}, + {name: "explicit alongside wildcard", kinds: []string{"*", "Namespace"}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rule := MetadataRule{VersionKinds: runtime.VersionKinds{ + APIGroups: []string{"*"}, + Kinds: tt.kinds, + }} + if got := rule.MatchesGroupVersionKind(namespace); got != tt.want { + t.Fatalf("MatchesGroupVersionKind() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/api/rules/enforce_types.go b/pkg/api/rules/enforce_types.go index 64b4076f..efeada9b 100644 --- a/pkg/api/rules/enforce_types.go +++ b/pkg/api/rules/enforce_types.go @@ -3,6 +3,31 @@ package rules +type AudienceKind string + +const ( + AudienceKindUser AudienceKind = "User" + AudienceKindGroup AudienceKind = "Group" + AudienceKindServiceAccount AudienceKind = "ServiceAccount" + AudienceKindCustom AudienceKind = "Custom" +) + +type CustomAudience string + +const ( + CustomAudienceCapsuleUser CustomAudience = "CapsuleUser" + CustomAudienceAdministrator CustomAudience = "Administrator" + CustomAudienceTenantOwner CustomAudience = "TenantOwner" + CustomAudienceController CustomAudience = "Controller" +) + +// +kubebuilder:object:generate=true +type Audience struct { + // +kubebuilder:validation:Enum=User;Group;ServiceAccount;Custom + Kind AudienceKind `json:"kind"` + Name string `json:"name"` +} + // +kubebuilder:object:generate=true type NamespaceRuleEnforceBody struct { // Declare the action being performed on the enforcement rule: @@ -23,4 +48,8 @@ type NamespaceRuleEnforceBody struct { // // +optional Metadata []MetadataRule `json:"metadata,omitempty"` + + // Enforcement for Ingress and Gateway API resource hostnames. + // +optional + Ingress NamespaceRuleEnforceIngressBody `json:"ingress,omitempty"` } diff --git a/pkg/api/rules/rule_body_types.go b/pkg/api/rules/rule_body_types.go index fefe54d0..ffd58166 100644 --- a/pkg/api/rules/rule_body_types.go +++ b/pkg/api/rules/rule_body_types.go @@ -10,6 +10,11 @@ import ( // For future implementation where users might manage RuleStatus CRs themselves // +kubebuilder:object:generate=true type NamespaceRuleBodyNamespace struct { + // Audience limits this rule to matching request subjects. + // An empty audience matches every request. + // +optional + Audience []Audience `json:"audience,omitempty"` + // Enforcement for given rule //+optional Enforce *NamespaceRuleEnforceBody `json:"enforce,omitzero"` diff --git a/pkg/api/rules/zz_generated.deepcopy.go b/pkg/api/rules/zz_generated.deepcopy.go index a51b36df..ce64df1d 100644 --- a/pkg/api/rules/zz_generated.deepcopy.go +++ b/pkg/api/rules/zz_generated.deepcopy.go @@ -14,6 +14,21 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Audience) DeepCopyInto(out *Audience) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Audience. +func (in *Audience) DeepCopy() *Audience { + if in == nil { + return nil + } + out := new(Audience) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MetadataRule) DeepCopyInto(out *MetadataRule) { *out = *in @@ -54,6 +69,16 @@ func (in *MetadataValueRule) DeepCopyInto(out *MetadataValueRule) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Default != nil { + in, out := &in.Default, &out.Default + *out = new(string) + **out = **in + } + if in.Managed != nil { + in, out := &in.Managed, &out.Managed + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MetadataValueRule. @@ -69,6 +94,11 @@ func (in *MetadataValueRule) DeepCopy() *MetadataValueRule { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespaceRuleBodyNamespace) DeepCopyInto(out *NamespaceRuleBodyNamespace) { *out = *in + if in.Audience != nil { + in, out := &in.Audience, &out.Audience + *out = make([]Audience, len(*in)) + copy(*out, *in) + } if in.Enforce != nil { in, out := &in.Enforce, &out.Enforce *out = new(NamespaceRuleEnforceBody) @@ -124,6 +154,7 @@ func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody) (*in)[i].DeepCopyInto(&(*out)[i]) } } + in.Ingress.DeepCopyInto(&out.Ingress) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody. @@ -136,6 +167,33 @@ func (in *NamespaceRuleEnforceBody) DeepCopy() *NamespaceRuleEnforceBody { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceRuleEnforceIngressBody) DeepCopyInto(out *NamespaceRuleEnforceIngressBody) { + *out = *in + if in.Types != nil { + in, out := &in.Types, &out.Types + *out = make([]IngressType, len(*in)) + copy(*out, *in) + } + if in.Hostnames != nil { + in, out := &in.Hostnames, &out.Hostnames + *out = make([]runtime.ExpressionMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceIngressBody. +func (in *NamespaceRuleEnforceIngressBody) DeepCopy() *NamespaceRuleEnforceIngressBody { + if in == nil { + return nil + } + out := new(NamespaceRuleEnforceIngressBody) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespaceRuleEnforceServicesBody) DeepCopyInto(out *NamespaceRuleEnforceServicesBody) { *out = *in diff --git a/pkg/api/runtime/version_kind_types.go b/pkg/api/runtime/version_kind_types.go index e96150c7..25226065 100644 --- a/pkg/api/runtime/version_kind_types.go +++ b/pkg/api/runtime/version_kind_types.go @@ -182,6 +182,17 @@ func (s VersionKinds) HasWildcard() bool { // Wildcard API groups or wildcard kinds are intentionally skipped because they are selectors, // not concrete Kubernetes resources. func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath string) error { + return s.ValidateKnownKindsWithScope(mapper, fieldPath, nil) +} + +// ValidateKnownKindsWithScope validates concrete targets and optionally their +// REST scope. Wildcard selectors are skipped because discovery cannot enumerate +// their complete set reliably. +func (s VersionKinds) ValidateKnownKindsWithScope( + mapper apimeta.RESTMapper, + fieldPath string, + allowScope func(schema.GroupVersionKind, apimeta.RESTScope) bool, +) error { if mapper == nil { return nil } @@ -199,7 +210,8 @@ func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath st continue } - if err := validateKnownKindForAPIGroup(mapper, apiGroup, kind); err != nil { + mapping, err := restMappingForAPIGroup(mapper, apiGroup, kind) + if err != nil { return fmt.Errorf( "%s.kinds[%d] %q for apiGroups[%d] %q is invalid: %w", fieldPath, @@ -210,12 +222,40 @@ func (s VersionKinds) ValidateKnownKinds(mapper apimeta.RESTMapper, fieldPath st err, ) } + + if allowScope != nil && !allowScope(mapping.GroupVersionKind, mapping.Scope) { + return fmt.Errorf( + "%s.kinds[%d] %q for apiGroups[%d] %q is invalid: GVK %s has unsupported scope %q", + fieldPath, kindIndex, kind, apiGroupIndex, apiGroup, + mapping.GroupVersionKind.String(), mapping.Scope.Name(), + ) + } } } return nil } +func restMappingForAPIGroup( + mapper apimeta.RESTMapper, + apiGroup string, + kind string, +) (*apimeta.RESTMapping, error) { + apiGroup = strings.TrimSpace(apiGroup) + + apiGroup = normalizeAPIVersion(apiGroup) + + if apiGroup == CoreAPIVersion { + return mapper.RESTMapping(schema.GroupKind{Kind: kind}, CoreAPIVersion) + } + + if gv, err := schema.ParseGroupVersion(apiGroup); err == nil && strings.Contains(apiGroup, "/") { + return mapper.RESTMapping(schema.GroupKind{Group: gv.Group, Kind: kind}, gv.Version) + } + + return mapper.RESTMapping(schema.GroupKind{Group: apiGroup, Kind: kind}) +} + func (s VersionKinds) StatusAPIGroups() []string { apiGroups := s.NormalizedAPIGroups() if len(apiGroups) == 0 { @@ -247,52 +287,6 @@ func (s VersionKinds) StatusAPIGroups() []string { return out } -func validateKnownKindForAPIGroup( - mapper apimeta.RESTMapper, - apiGroup string, - kind string, -) error { - apiGroup = normalizeAPIVersion(apiGroup) - - if apiGroup == CoreAPIVersion { - _, err := mapper.RESTMapping( - schema.GroupKind{ - Group: "", - Kind: kind, - }, - CoreAPIVersion, - ) - - return err - } - - if strings.Contains(apiGroup, "/") { - gv, err := schema.ParseGroupVersion(apiGroup) - if err != nil { - return err - } - - _, err = mapper.RESTMapping( - schema.GroupKind{ - Group: gv.Group, - Kind: kind, - }, - gv.Version, - ) - - return err - } - - _, err := mapper.RESTMapping( - schema.GroupKind{ - Group: apiGroup, - Kind: kind, - }, - ) - - return err -} - func (s VersionKinds) NormalizedAPIGroups() []string { if len(s.APIGroups) == 0 { return []string{CoreAPIVersion} diff --git a/pkg/api/runtime/version_kind_types_test.go b/pkg/api/runtime/version_kind_types_test.go index 6a45475c..311c1dc1 100644 --- a/pkg/api/runtime/version_kind_types_test.go +++ b/pkg/api/runtime/version_kind_types_test.go @@ -1818,109 +1818,6 @@ func TestVersionKindsValidateKnownKinds(t *testing.T) { } } -func TestValidateKnownKindForAPIGroup(t *testing.T) { - t.Parallel() - - mapper := newVersionKindTestRESTMapper() - - tests := []struct { - name string - apiGroup string - kind string - wantErr bool - }{ - { - name: "core v1 kind", - apiGroup: "", - kind: "ConfigMap", - }, - { - name: "explicit core v1 kind", - apiGroup: "v1", - kind: "Service", - }, - { - name: "group only kind", - apiGroup: "apps", - kind: "Deployment", - }, - { - name: "exact group version kind", - apiGroup: "apps/v1", - kind: "Deployment", - }, - { - name: "batch exact group version kind", - apiGroup: "batch/v1", - kind: "Job", - }, - { - name: "unknown core kind", - apiGroup: "", - kind: "NotAThing", - wantErr: true, - }, - { - name: "wrong group kind", - apiGroup: "batch/v1", - kind: "Deployment", - wantErr: true, - }, - { - name: "wrong exact version", - apiGroup: "apps/v1beta1", - kind: "StatefulSet", - wantErr: true, - }, - { - name: "unknown group", - apiGroup: "example.corp", - kind: "Widget", - wantErr: true, - }, - { - name: "invalid group version", - apiGroup: "apps/v1/extra", - kind: "Deployment", - wantErr: true, - }, - { - name: "empty kind fails", - apiGroup: "v1", - kind: "", - wantErr: true, - }, - { - name: "case-sensitive kind fails", - apiGroup: "v1", - kind: "configmap", - wantErr: true, - }, - { - name: "case-sensitive group fails", - apiGroup: "Apps", - kind: "Deployment", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - err := validateKnownKindForAPIGroup(mapper, tt.apiGroup, tt.kind) - - if tt.wantErr && err == nil { - t.Fatalf("expected error") - } - - if !tt.wantErr && err != nil { - t.Fatalf("expected no error, got %v", err) - } - }) - } -} - func TestVersionKindsNormalizedAPIGroups(t *testing.T) { t.Parallel() diff --git a/pkg/ruleengine/audience.go b/pkg/ruleengine/audience.go new file mode 100644 index 00000000..17c2265e --- /dev/null +++ b/pkg/ruleengine/audience.go @@ -0,0 +1,101 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ruleengine + +import ( + "fmt" + "slices" + + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/users" +) + +func FilterNamespaceRulesByAudience( + cfg configuration.Configuration, + tnt *capsulev1beta2.Tenant, + req admission.Request, + bodies []*rules.NamespaceRuleBodyNamespace, +) ([]*rules.NamespaceRuleBodyNamespace, error) { + out := make([]*rules.NamespaceRuleBodyNamespace, 0, len(bodies)) + + for _, body := range bodies { + if body == nil || len(body.Audience) == 0 { + out = append(out, body) + + continue + } + + matched, err := matchesAudience(cfg, tnt, req, body.Audience) + if err != nil { + return nil, err + } + + if matched { + out = append(out, body) + } + } + + return out, nil +} + +func matchesAudience(cfg configuration.Configuration, tnt *capsulev1beta2.Tenant, req admission.Request, audience []rules.Audience) (bool, error) { + for _, subject := range audience { + switch subject.Kind { + case rules.AudienceKindUser: + if req.UserInfo.Username == subject.Name { + return true, nil + } + case rules.AudienceKindGroup: + if slices.Contains(req.UserInfo.Groups, subject.Name) { + return true, nil + } + case rules.AudienceKindServiceAccount: + if (rbac.UserListSpec{{Kind: rbac.ServiceAccountOwner, Name: subject.Name}}).IsPresent(req.UserInfo.Username, req.UserInfo.Groups) { + return true, nil + } + case rules.AudienceKindCustom: + if cfg == nil { + return false, fmt.Errorf("configuration is required for custom audience %q", subject.Name) + } + + matched, err := matchesCustomAudience(cfg, tnt, req, rules.CustomAudience(subject.Name)) + if err != nil { + return false, err + } + + if matched { + return true, nil + } + default: + return false, fmt.Errorf("unsupported audience kind %q", subject.Kind) + } + } + + return false, nil +} + +func matchesCustomAudience(cfg configuration.Configuration, tnt *capsulev1beta2.Tenant, req admission.Request, custom rules.CustomAudience) (bool, error) { + switch custom { + case rules.CustomAudienceCapsuleUser: + return cfg.Users().IsPresent(req.UserInfo.Username, req.UserInfo.Groups), nil + case rules.CustomAudienceAdministrator: + return cfg.Administrators().IsPresent(req.UserInfo.Username, req.UserInfo.Groups), nil + case rules.CustomAudienceTenantOwner: + if tnt == nil { + return false, nil + } + + return tnt.Spec.Owners.IsOwner(req.UserInfo.Username, req.UserInfo.Groups) || + tnt.Status.Owners.IsOwner(req.UserInfo.Username, req.UserInfo.Groups), nil + case rules.CustomAudienceController: + return users.IsControllerServiceAccount(req.UserInfo.Username), nil + default: + return false, fmt.Errorf("unsupported custom audience %q", custom) + } +} diff --git a/pkg/ruleengine/audience_test.go b/pkg/ruleengine/audience_test.go new file mode 100644 index 00000000..fa534a26 --- /dev/null +++ b/pkg/ruleengine/audience_test.go @@ -0,0 +1,95 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ruleengine + +import ( + "testing" + + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" +) + +func TestMatchesAudience(t *testing.T) { + t.Parallel() + + cfg := audienceConfiguration(t) + req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{UserInfo: authenticationv1.UserInfo{Username: "alice", Groups: []string{"developers"}}}} + + tests := []struct { + name string + tnt *capsulev1beta2.Tenant + audience []rules.Audience + want bool + }{ + {name: "user", audience: []rules.Audience{{Kind: rules.AudienceKindUser, Name: "alice"}}, want: true}, + {name: "group", audience: []rules.Audience{{Kind: rules.AudienceKindGroup, Name: "developers"}}, want: true}, + {name: "no match", audience: []rules.Audience{{Kind: rules.AudienceKindUser, Name: "bob"}}}, + {name: "tenant owner", tnt: &capsulev1beta2.Tenant{Spec: capsulev1beta2.TenantSpec{Owners: rbac.OwnerListSpec{{CoreOwnerSpec: rbac.CoreOwnerSpec{UserSpec: rbac.UserSpec{Kind: rbac.UserOwner, Name: "alice"}}}}}}, audience: []rules.Audience{{Kind: rules.AudienceKindCustom, Name: string(rules.CustomAudienceTenantOwner)}}, want: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matched, err := matchesAudience(cfg, tt.tnt, req, tt.audience) + if err != nil { + t.Fatalf("matchesAudience() error = %v", err) + } + if matched != tt.want { + t.Fatalf("matchesAudience() = %v, want %v", matched, tt.want) + } + }) + } +} + +func TestFilterNamespaceRulesUsesRootAudience(t *testing.T) { + t.Parallel() + + cfg := audienceConfiguration(t) + req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + UserInfo: authenticationv1.UserInfo{Username: "alice", Groups: []string{"developers"}}, + }} + matching := &rules.NamespaceRuleBodyNamespace{ + Audience: []rules.Audience{{Kind: rules.AudienceKindGroup, Name: "developers"}}, + Enforce: &rules.NamespaceRuleEnforceBody{}, + } + nonMatching := &rules.NamespaceRuleBodyNamespace{ + Audience: []rules.Audience{{Kind: rules.AudienceKindUser, Name: "bob"}}, + Enforce: &rules.NamespaceRuleEnforceBody{}, + } + unscoped := &rules.NamespaceRuleBodyNamespace{Enforce: &rules.NamespaceRuleEnforceBody{}} + + got, err := FilterNamespaceRulesByAudience(cfg, nil, req, []*rules.NamespaceRuleBodyNamespace{matching, nonMatching, unscoped}) + if err != nil { + t.Fatalf("FilterNamespaceRulesByAudience() error = %v", err) + } + if len(got) != 2 || got[0] != matching || got[1] != unscoped { + t.Fatalf("unexpected filtered rules: %#v", got) + } +} + +func audienceConfiguration(t *testing.T) configuration.Configuration { + t.Helper() + + scheme := runtime.NewScheme() + if err := capsulev1beta2.AddToScheme(scheme); err != nil { + t.Fatalf("adding capsule scheme: %v", err) + } + + config := &capsulev1beta2.CapsuleConfiguration{ + ObjectMeta: metav1.ObjectMeta{Name: "capsule"}, + Spec: configuration.DefaultCapsuleConfiguration(), + } + cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(config).Build() + + return configuration.NewCapsuleConfiguration(t.Context(), cl, cl, &rest.Config{}, config.Name) +} diff --git a/pkg/ruleengine/enforce_evaluator.go b/pkg/ruleengine/enforce_evaluator.go index 7692d06e..2f91d89c 100644 --- a/pkg/ruleengine/enforce_evaluator.go +++ b/pkg/ruleengine/enforce_evaluator.go @@ -94,9 +94,13 @@ type Set[R any, O any] struct { Name string EventReason string - Values func(O) []Value - Rules func(*api.NamespaceRuleEnforceBody) []R - Matches func(R, Value) (Match, error) + Values func(O) []Value + // Rules extracts rules when extraction cannot fail. + Rules func(*api.NamespaceRuleEnforceBody) []R + // RulesWithError extracts rules that require runtime parsing or matching. + // When set, it takes precedence over Rules. + RulesWithError func(*api.NamespaceRuleEnforceBody) ([]R, error) + Matches func(R, Value) (Match, error) // Message can fully override the default message. // Prefer leaving this nil unless a rule requires very specific wording. @@ -124,7 +128,7 @@ func EvaluateEnforce[R any, T any]( return nil, fmt.Errorf("%s: values extractor is nil", set.Name) } - if set.Rules == nil { + if set.Rules == nil && set.RulesWithError == nil { return nil, fmt.Errorf("%s: rules extractor is nil", set.Name) } @@ -154,7 +158,19 @@ func EvaluateEnforce[R any, T any]( continue } - items := set.Rules(enforce) + var items []R + + if set.RulesWithError != nil { + var err error + + items, err = set.RulesWithError(enforce) + if err != nil { + return evaluation, fmt.Errorf("%s: invalid rules: %w", set.Name, err) + } + } else { + items = set.Rules(enforce) + } + if len(items) == 0 { continue } diff --git a/pkg/ruleengine/enforce_evaluator_test.go b/pkg/ruleengine/enforce_evaluator_test.go index 4daa2b8b..a7482a1e 100644 --- a/pkg/ruleengine/enforce_evaluator_test.go +++ b/pkg/ruleengine/enforce_evaluator_test.go @@ -108,6 +108,34 @@ func TestEvaluateEnforce_ValidationErrors(t *testing.T) { } } +func TestEvaluateEnforce_RulesWithError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("invalid selector") + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "prod", Path: "metadata.labels[env]"}}}, + []*api.NamespaceRuleEnforceBody{{Action: api.ActionTypeAllow}}, + Set[testRule, testObject]{ + Name: "metadata labels", + Values: func(obj testObject) []Value { + return obj.Values + }, + RulesWithError: func(*api.NamespaceRuleEnforceBody) ([]testRule, error) { + return nil, wantErr + }, + Matches: func(testRule, Value) (Match, error) { + return Match{}, nil + }, + }, + ) + if !errors.Is(err, wantErr) { + t.Fatalf("EvaluateEnforce() error = %v, want %v", err, wantErr) + } + if evaluation == nil { + t.Fatal("EvaluateEnforce() evaluation = nil, want partial evaluation") + } +} + func TestEvaluateEnforce_EmptyInputs(t *testing.T) { t.Parallel() diff --git a/pkg/ruleengine/validate.go b/pkg/ruleengine/validate.go index 2496d4b7..fee92b7b 100644 --- a/pkg/ruleengine/validate.go +++ b/pkg/ruleengine/validate.go @@ -10,6 +10,7 @@ import ( "strings" k8smeta "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" k8svalidation "k8s.io/apimachinery/pkg/util/validation" "github.com/projectcapsule/capsule/pkg/api/rules" @@ -21,7 +22,15 @@ func ValidateRuleStatusBody( bodies []*rules.NamespaceRuleBodyNamespace, ) error { for i, rule := range bodies { - if rule == nil || rule.Enforce == nil { + if rule == nil { + continue + } + + if err := validateAudience(i, rule.Audience); err != nil { + return err + } + + if rule.Enforce == nil { continue } @@ -33,6 +42,10 @@ func ValidateRuleStatusBody( return err } + if err := validateIngressRules(i, rule.Enforce.Ingress); err != nil { + return err + } + if err := validateMetadataRules(i, rule.Enforce.Metadata, mapper); err != nil { return err } @@ -41,6 +54,77 @@ func ValidateRuleStatusBody( return nil } +func validateIngressRules( + ruleIndex int, + ingress rules.NamespaceRuleEnforceIngressBody, +) error { + if len(ingress.Types) == 0 && len(ingress.Hostnames) > 0 { + return fmt.Errorf( + "rules[%d].enforce.ingress.types is invalid: types must be configured when hostnames are configured", + ruleIndex, + ) + } + + if len(ingress.Types) > 0 && len(ingress.Hostnames) == 0 { + return fmt.Errorf( + "rules[%d].enforce.ingress.hostnames is invalid: hostnames must be configured when types are configured", + ruleIndex, + ) + } + + for i, resourceType := range ingress.Types { + switch resourceType { + case rules.IngressTypeIngress, rules.IngressTypeRoute, + rules.IngressTypeListenerSet, + rules.IngressTypeHTTPRoute, + rules.IngressTypeGateway, + rules.IngressTypeTLSRoute, + rules.IngressTypeGRPCRoute: + default: + return fmt.Errorf( + "rules[%d].enforce.ingress.types[%d] %q is invalid: unsupported ingress resource type", + ruleIndex, + i, + resourceType, + ) + } + } + + for i, hostname := range ingress.Hostnames { + if err := validateExpressionMatch( + hostname, + fmt.Sprintf("rules[%d].enforce.ingress.hostnames[%d]", ruleIndex, i), + ); err != nil { + return err + } + } + + return nil +} + +func validateAudience(ruleIndex int, audience []rules.Audience) error { + for i, subject := range audience { + path := fmt.Sprintf("rules[%d].audience[%d]", ruleIndex, i) + if strings.TrimSpace(subject.Name) == "" { + return fmt.Errorf("%s.name is invalid: name is empty", path) + } + + switch subject.Kind { + case rules.AudienceKindUser, rules.AudienceKindGroup, rules.AudienceKindServiceAccount: + case rules.AudienceKindCustom: + switch rules.CustomAudience(subject.Name) { + case rules.CustomAudienceCapsuleUser, rules.CustomAudienceAdministrator, rules.CustomAudienceTenantOwner, rules.CustomAudienceController: + default: + return fmt.Errorf("%s.name %q is invalid: unsupported custom audience", path, subject.Name) + } + default: + return fmt.Errorf("%s.kind %q is invalid: unsupported audience kind", path, subject.Kind) + } + } + + return nil +} + func validateWorkloadRules( ruleIndex int, workloads rules.NamespaceRuleEnforceWorkloadsBody, @@ -131,6 +215,10 @@ func validateMetadataRules( for j, rule := range metadata { fieldPath := fmt.Sprintf("rules[%d].enforce.metadata[%d]", ruleIndex, j) + if rule.HasWildcard() && metadataRuleHasManagedValues(rule) { + return fmt.Errorf("%s is invalid: managed metadata requires concrete apiGroups and kinds", fieldPath) + } + if err := validateMetadataTargets(fieldPath, rule, mapper); err != nil { return err } @@ -145,6 +233,10 @@ func validateMetadataRules( ) } + if err := validateMutableMetadataKey(key, policy); err != nil { + return fmt.Errorf("%s.labels[%q] is invalid: %w", fieldPath, key, err) + } + for k, matcher := range policy.Values { if err := validateExpressionMatch( matcher, @@ -165,6 +257,10 @@ func validateMetadataRules( ) } + if err := validateMutableMetadataKey(key, policy); err != nil { + return fmt.Errorf("%s.annotations[%q] is invalid: %w", fieldPath, key, err) + } + for k, matcher := range policy.Values { if err := validateExpressionMatch( matcher, @@ -179,14 +275,49 @@ func validateMetadataRules( return nil } +func metadataRuleHasManagedValues(rule rules.MetadataRule) bool { + for _, policy := range rule.Labels { + if policy.Managed != nil { + return true + } + } + + for _, policy := range rule.Annotations { + if policy.Managed != nil { + return true + } + } + + return false +} + +func validateMutableMetadataKey(key string, policy rules.MetadataValueRule) error { + if policy.Default == nil && policy.Managed == nil { + return nil + } + + if errs := k8svalidation.IsQualifiedName(strings.TrimSpace(key)); len(errs) > 0 { + return errors.New("default and managed require a concrete metadata key") + } + + return nil +} + func validateMetadataKey(key string) error { key = strings.TrimSpace(key) if key == "" { return errors.New("key is empty") } - if errs := k8svalidation.IsQualifiedName(key); len(errs) > 0 { - return errors.New(strings.Join(errs, ", ")) + if !strings.ContainsAny(key, "*[](){}+?|^$\\") { + if errs := k8svalidation.IsQualifiedName(key); len(errs) > 0 { + return errors.New(strings.Join(errs, ", ")) + } + } + + expression := rules.MetadataKeyExpression(key) + if _, err := regexp.Compile(expression.Expression); err != nil { + return fmt.Errorf("invalid key expression %q: %w", key, err) } return nil @@ -282,7 +413,13 @@ func validateMetadataTargets( return nil } - if err := rule.ValidateKnownKinds(mapper, fieldPath); err != nil { + if err := rule.ValidateKnownKindsWithScope(mapper, fieldPath, func( + gvk schema.GroupVersionKind, + scope k8smeta.RESTScope, + ) bool { + return scope.Name() == k8smeta.RESTScopeNameNamespace || + (gvk.Group == "" && gvk.Version == "v1" && gvk.Kind == "Namespace") + }); err != nil { return err } diff --git a/pkg/ruleengine/validate_ingress_test.go b/pkg/ruleengine/validate_ingress_test.go new file mode 100644 index 00000000..d543e43a --- /dev/null +++ b/pkg/ruleengine/validate_ingress_test.go @@ -0,0 +1,66 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ruleengine + +import ( + "strings" + "testing" + + "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/api/runtime" +) + +func TestValidateIngressRules(t *testing.T) { + t.Parallel() + + valid := []*rules.NamespaceRuleBodyNamespace{{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Ingress: rules.NamespaceRuleEnforceIngressBody{ + Types: []rules.IngressType{ + rules.IngressTypeIngress, + rules.IngressTypeListenerSet, + rules.IngressTypeHTTPRoute, + rules.IngressTypeGateway, + rules.IngressTypeTLSRoute, + rules.IngressTypeGRPCRoute, + }, + Hostnames: []runtime.ExpressionMatch{{ + Exact: []string{"prod", "test"}, + ExpressionRegex: runtime.ExpressionRegex{ + Expression: ".*\\.example\\.com", + }, + }}, + }, + }, + }} + if err := ValidateRuleStatusBody(nil, valid); err != nil { + t.Fatalf("ValidateRuleStatusBody(valid) error = %v", err) + } + + invalidType := valid[0].DeepCopy() + invalidType.Enforce.Ingress.Types = []rules.IngressType{"TCPRoute"} + if err := ValidateRuleStatusBody(nil, []*rules.NamespaceRuleBodyNamespace{invalidType}); err == nil || !strings.Contains(err.Error(), "unsupported ingress resource type") { + t.Fatalf("ValidateRuleStatusBody(invalid type) error = %v", err) + } + + invalidRegex := valid[0].DeepCopy() + invalidRegex.Enforce.Ingress.Hostnames = []runtime.ExpressionMatch{{ + ExpressionRegex: runtime.ExpressionRegex{Expression: "("}, + }} + if err := ValidateRuleStatusBody(nil, []*rules.NamespaceRuleBodyNamespace{invalidRegex}); err == nil || !strings.Contains(err.Error(), "ingress.hostnames[0].exp") { + t.Fatalf("ValidateRuleStatusBody(invalid regex) error = %v", err) + } + + missingTypes := valid[0].DeepCopy() + missingTypes.Enforce.Ingress.Types = nil + if err := ValidateRuleStatusBody(nil, []*rules.NamespaceRuleBodyNamespace{missingTypes}); err == nil || !strings.Contains(err.Error(), "ingress.types") { + t.Fatalf("ValidateRuleStatusBody(missing types) error = %v", err) + } + + missingHostnames := valid[0].DeepCopy() + missingHostnames.Enforce.Ingress.Hostnames = nil + if err := ValidateRuleStatusBody(nil, []*rules.NamespaceRuleBodyNamespace{missingHostnames}); err == nil || !strings.Contains(err.Error(), "ingress.hostnames") { + t.Fatalf("ValidateRuleStatusBody(missing hostnames) error = %v", err) + } +} diff --git a/pkg/ruleengine/validate_test.go b/pkg/ruleengine/validate_test.go index 3fcbe757..ec2b0fc3 100644 --- a/pkg/ruleengine/validate_test.go +++ b/pkg/ruleengine/validate_test.go @@ -9,6 +9,7 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" "github.com/projectcapsule/capsule/pkg/api/rules" "github.com/projectcapsule/capsule/pkg/api/runtime" @@ -745,6 +746,42 @@ func TestValidateRuleStatusBody(t *testing.T) { } } +func TestValidateMetadataKeyPatterns(t *testing.T) { + t.Parallel() + + if err := validateMetadataKey("example.corp/*"); err != nil { + t.Fatalf("expected wildcard key selector to be valid: %v", err) + } + if err := validateMetadataKey(`example\.corp/.*`); err != nil { + t.Fatalf("expected regexp key selector to be valid: %v", err) + } + if err := validateMetadataKey("example.corp/["); err == nil { + t.Fatal("expected invalid regexp key selector to be rejected") + } +} + +func TestMutableMetadataRequiresConcreteKey(t *testing.T) { + t.Parallel() + policy := rules.MetadataValueRule{Managed: ptr.To("controlled")} + if err := validateMutableMetadataKey("example.corp/key", policy); err != nil { + t.Fatalf("concrete key rejected: %v", err) + } + if err := validateMutableMetadataKey("example.corp/*", policy); err == nil { + t.Fatal("wildcard managed key was accepted") + } + if err := validateMutableMetadataKey(`example\.corp/.*`, policy); err == nil { + t.Fatal("regexp managed key was accepted") + } + + rule := rules.MetadataRule{ + VersionKinds: runtime.VersionKinds{APIGroups: []string{"*"}, Kinds: []string{"ConfigMap"}}, + Labels: map[string]rules.MetadataValueRule{"example.corp/key": policy}, + } + if err := validateMetadataRules(0, []rules.MetadataRule{rule}, nil); err == nil || !strings.Contains(err.Error(), "managed metadata requires concrete apiGroups and kinds") { + t.Fatalf("wildcard managed target error = %v", err) + } +} + func TestValidateRuleStatusBodyWithRESTMapper(t *testing.T) { t.Parallel() @@ -819,6 +856,18 @@ func TestValidateRuleStatusBodyWithRESTMapper(t *testing.T) { }, }, }, + { + name: "core namespace is a valid metadata target", + mapper: mapper, + bodies: []*rules.NamespaceRuleBodyNamespace{{ + Enforce: &rules.NamespaceRuleEnforceBody{Metadata: []rules.MetadataRule{{ + VersionKinds: runtime.VersionKinds{APIGroups: []string{""}, Kinds: []string{"Namespace"}}, + Annotations: map[string]rules.MetadataValueRule{ + "example.corp/*": {Values: []runtime.ExpressionMatch{{Exact: []string{"allowed"}}}}, + }, + }}}, + }}, + }, { name: "known grouped apiVersion kind is valid", mapper: mapper, @@ -1146,6 +1195,15 @@ func newRuleValidationRESTMapper() apimeta.RESTMapper { }, }) + mapper.Add( + schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Namespace", + }, + apimeta.RESTScopeRoot, + ) + mapper.Add( schema.GroupVersionKind{ Group: "", diff --git a/pkg/runtime/events/reasons.go b/pkg/runtime/events/reasons.go index cdefef89..fddbb52e 100644 --- a/pkg/runtime/events/reasons.go +++ b/pkg/runtime/events/reasons.go @@ -55,6 +55,7 @@ const ( ReasonIngressHostnameNotValid string = "IngressHostnameNotValid" ReasonIngressHostnameEmpty string = "IngressHostnameEmpty" ReasonIngressHostnameCollision string = "IngressHostnameCollision" + ReasonForbiddenIngressHostname string = "ForbiddenIngressHostname" // Services. ReasonForbiddenExternalServiceIP string = "ForbiddenExternalServiceIP" diff --git a/pkg/runtime/handlers/typed_tenant_ruleset.go b/pkg/runtime/handlers/typed_tenant_ruleset.go index 46b232b1..ca59cd09 100644 --- a/pkg/runtime/handlers/typed_tenant_ruleset.go +++ b/pkg/runtime/handlers/typed_tenant_ruleset.go @@ -1,7 +1,6 @@ // Copyright 2020-2026 Project Capsule Authors // SPDX-License-Identifier: Apache-2.0 -//nolint:dupl package handlers import ( @@ -16,6 +15,8 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/tenant" ) @@ -54,8 +55,9 @@ type TypedHandlerWithTenantWithRuleset[T client.Object] interface { } type TypedTenantWithRulesetHandler[T client.Object] struct { - Factory NewObjectFunc[T] - Handlers []TypedHandlerWithTenantWithRuleset[T] + Factory NewObjectFunc[T] + Handlers []TypedHandlerWithTenantWithRuleset[T] + Configuration configuration.Configuration } func (h *TypedTenantWithRulesetHandler[T]) OnCreate( @@ -79,7 +81,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnCreate( return ErroredResponse(err) } - ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt) + ruleBlocks, err := h.resolveRuleset(ctx, c, reader, req, req.Namespace, tnt) + if err != nil { + return ErroredResponse(err) + } + + ruleBlocks, err = ruleengine.FilterNamespaceRulesByAudience(h.Configuration, tnt, req, ruleBlocks) if err != nil { return ErroredResponse(err) } @@ -120,7 +127,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnUpdate( return ErroredResponse(err) } - ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt) + ruleBlocks, err := h.resolveRuleset(ctx, c, reader, req, req.Namespace, tnt) + if err != nil { + return ErroredResponse(err) + } + + ruleBlocks, err = ruleengine.FilterNamespaceRulesByAudience(h.Configuration, tnt, req, ruleBlocks) if err != nil { return ErroredResponse(err) } @@ -136,37 +148,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnUpdate( } func (h *TypedTenantWithRulesetHandler[T]) OnDelete( - c client.Client, - reader client.Reader, - decoder admission.Decoder, - recorder events.EventRecorder, + client.Client, + client.Reader, + admission.Decoder, + events.EventRecorder, ) Func { - return func(ctx context.Context, req admission.Request) *admission.Response { - tnt, err := h.resolveTenant(ctx, reader, req) - if err != nil { - return ErroredResponse(err) - } - - if tnt == nil { - return nil - } - - obj := h.Factory() - if err := decoder.Decode(req, obj); err != nil { - return ErroredResponse(err) - } - - ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt) - if err != nil { - return ErroredResponse(err) - } - - for _, hndl := range h.Handlers { - if response := hndl.OnDelete(c, reader, obj, decoder, recorder, tnt, ruleBlocks)(ctx, req); response != nil { - return response - } - } - + return func(context.Context, admission.Request) *admission.Response { return nil } } @@ -188,6 +175,7 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveTenant( func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset( ctx context.Context, c client.Client, + reader client.Reader, req admission.Request, namespace string, tnt *capsulev1beta2.Tenant, @@ -198,7 +186,7 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset( Name: meta.NameForManagedRuleStatus(), } - if err := c.Get(ctx, key, rs); err == nil { + if err := reader.Get(ctx, key, rs); err == nil { return rs.Status.Rules, nil } else if !apierrors.IsNotFound(err) { return nil, err diff --git a/pkg/runtime/handlers/typed_tenant_ruleset_test.go b/pkg/runtime/handlers/typed_tenant_ruleset_test.go new file mode 100644 index 00000000..442b872a --- /dev/null +++ b/pkg/runtime/handlers/typed_tenant_ruleset_test.go @@ -0,0 +1,31 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package handlers_test + +import ( + "context" + "testing" + + admissionv1 "k8s.io/api/admission/v1" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/projectcapsule/capsule/pkg/runtime/handlers" +) + +func TestTypedTenantWithRulesetHandlerSkipsDelete(t *testing.T) { + t.Parallel() + + handler := &handlers.TypedTenantWithRulesetHandler[*corev1.ConfigMap]{ + Factory: func() *corev1.ConfigMap { return &corev1.ConfigMap{} }, + } + request := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{ + Operation: admissionv1.Delete, + Namespace: "solar-system", + }} + + if response := handler.OnDelete(nil, nil, nil, nil)(context.Background(), request); response != nil { + t.Fatalf("OnDelete() response = %#v, want nil", response) + } +}