diff --git a/api/v1beta2/capsuleconfiguration_types.go b/api/v1beta2/capsuleconfiguration_types.go index 5480f0d6..42d76161 100644 --- a/api/v1beta2/capsuleconfiguration_types.go +++ b/api/v1beta2/capsuleconfiguration_types.go @@ -59,6 +59,9 @@ type CapsuleConfigurationSpec struct { // Service Account Client configuration for impersonation properties // +optional Impersonation ServiceAccountClient `json:"impersonation,omitzero"` + // Event (Audit) Configuration + // +kubebuilder:default={namespace:default} + Events EventsConfiguration `json:"events,omitempty"` // Deprecated: use users property instead (https://projectcapsule.dev/docs/operating/setup/configuration/#users) // @@ -85,6 +88,12 @@ type RBACConfiguration struct { ProvisionerClusterRole string `json:"provisioner,omitempty"` } +type EventsConfiguration struct { + // Namespace where the events are logged for cluster scoped resources or deny events (default namespace) + // +kubebuilder:default=default + ClusterEventNamespace string `json:"namespace,omitempty"` +} + type DynamicAdmission struct { // Service Name of the Admission Service // +kubebuilder:default=capsule-webhook-service diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index ce1c495c..a5d24bc4 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -132,6 +132,7 @@ func (in *CapsuleConfigurationSpec) DeepCopyInto(out *CapsuleConfigurationSpec) } out.CacheInvalidation = in.CacheInvalidation out.Impersonation = in.Impersonation + out.Events = in.Events if in.UserNames != nil { in, out := &in.UserNames, &out.UserNames *out = make([]string, len(*in)) @@ -517,6 +518,21 @@ func (in *DynamicValidatingAdmissionConfig) DeepCopy() *DynamicValidatingAdmissi return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventsConfiguration) DeepCopyInto(out *EventsConfiguration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventsConfiguration. +func (in *EventsConfiguration) DeepCopy() *EventsConfiguration { + if in == nil { + return nil + } + out := new(EventsConfiguration) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GatewayOptions) DeepCopyInto(out *GatewayOptions) { *out = *in diff --git a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml index 17fbd4e1..9eeb2032 100644 --- a/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml +++ b/charts/capsule/crds/capsule.clastix.io_capsuleconfigurations.yaml @@ -1053,6 +1053,17 @@ spec: Toggles the TLS reconciler, the controller that is able to generate CA and certificates for the webhooks when not using an already provided CA and certificate, or when these are managed externally with Vault, or cert-manager. type: boolean + events: + default: + namespace: default + description: Event (Audit) Configuration + properties: + namespace: + default: default + description: Namespace where the events are logged for cluster + scoped resources or deny events (default namespace) + type: string + type: object forceTenantPrefix: default: false description: |- diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml index b5ac6d42..0045207c 100644 --- a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -86,8 +86,16 @@ spec: The rules are aggregated, since you can use Regular Expressions the match registry endpoints items: properties: + exact: + description: Exact matches one of the provided values + exactly. + items: + type: string + minItems: 1 + type: array exp: - description: Expression used to evaluate regex + description: Exp matches regular expression. + minLength: 1 type: string negate: default: false @@ -102,6 +110,40 @@ spec: type: string type: array type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + type: array + schedulers: + description: |- + Schedulers defines schedulerName matchers for Pod admission. + + The rule is evaluated against pod.spec.schedulerName. + Empty schedulerName is ignored and is not normalized to default-scheduler. + 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) type: array targets: description: |- @@ -223,8 +265,16 @@ spec: The rules are aggregated, since you can use Regular Expressions the match registry endpoints items: properties: + exact: + description: Exact matches one of the provided values + exactly. + items: + type: string + minItems: 1 + type: array exp: - description: Expression used to evaluate regex + description: Exp matches regular expression. + minLength: 1 type: string negate: default: false @@ -239,6 +289,40 @@ spec: type: string type: array type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + type: array + schedulers: + description: |- + Schedulers defines schedulerName matchers for Pod admission. + + The rule is evaluated against pod.spec.schedulerName. + Empty schedulerName is ignored and is not normalized to default-scheduler. + 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) type: array targets: description: |- @@ -296,8 +380,16 @@ spec: The rules are aggregated, since you can use Regular Expressions the match registry endpoints items: properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array exp: - description: Expression used to evaluate regex + description: Exp matches regular expression. + minLength: 1 type: string negate: default: false @@ -312,6 +404,40 @@ spec: type: string type: array type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + type: array + schedulers: + description: |- + Schedulers defines schedulerName matchers for Pod admission. + + The rule is evaluated against pod.spec.schedulerName. + Empty schedulerName is ignored and is not normalized to default-scheduler. + 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) type: array targets: description: |- diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index a81ec8f3..41737437 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -2533,8 +2533,16 @@ spec: The rules are aggregated, since you can use Regular Expressions the match registry endpoints items: properties: + exact: + description: Exact matches one of the provided + values exactly. + items: + type: string + minItems: 1 + type: array exp: - description: Expression used to evaluate regex + description: Exp matches regular expression. + minLength: 1 type: string negate: default: false @@ -2549,6 +2557,40 @@ spec: type: string type: array type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) + type: array + schedulers: + description: |- + Schedulers defines schedulerName matchers for Pod admission. + + The rule is evaluated against pod.spec.schedulerName. + Empty schedulerName is ignored and is not normalized to default-scheduler. + 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) type: array targets: description: |- @@ -3118,8 +3160,16 @@ spec: description: Registries which are allowed within this namespace items: properties: + exact: + description: Exact matches one of the provided values + exactly. + items: + type: string + minItems: 1 + type: array exp: - description: Expression used to evaluate regex + description: Exp matches regular expression. + minLength: 1 type: string negate: default: false @@ -3134,6 +3184,9 @@ spec: type: string type: array type: object + x-kubernetes-validations: + - message: at least one of exact or exp must be set + rule: has(self.exact) || has(self.exp) type: array type: object metadata: diff --git a/cmd/controller/main.go b/cmd/controller/main.go index a926cdec..47884a22 100644 --- a/cmd/controller/main.go +++ b/cmd/controller/main.go @@ -74,11 +74,13 @@ import ( "github.com/projectcapsule/capsule/internal/webhook/pvc" "github.com/projectcapsule/capsule/internal/webhook/resourcepool" "github.com/projectcapsule/capsule/internal/webhook/route" + podrules "github.com/projectcapsule/capsule/internal/webhook/rules/pods/validation" "github.com/projectcapsule/capsule/internal/webhook/service" "github.com/projectcapsule/capsule/internal/webhook/serviceaccounts" tenantmutation "github.com/projectcapsule/capsule/internal/webhook/tenant/mutation" tenantvalidation "github.com/projectcapsule/capsule/internal/webhook/tenant/validation" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + evt "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/runtime/indexers" "github.com/projectcapsule/capsule/pkg/utils" @@ -294,7 +296,7 @@ func main() { os.Exit(1) } - directCfg := configuration.NewCapsuleConfiguration(ctx, directClient, restConfig, controllerConfig.ConfigurationName) + directCfg := configuration.NewCapsuleConfiguration(ctx, directClient, directClient, restConfig, controllerConfig.ConfigurationName) tlsReconciler := &tlscontroller.Reconciler{} @@ -495,7 +497,7 @@ func main() { setupLog.Info("initializing capsule configuration") - cfg := configuration.NewCapsuleConfiguration(ctx, manager.GetClient(), manager.GetConfig(), controllerConfig.ConfigurationName) + cfg := configuration.NewCapsuleConfiguration(ctx, manager.GetClient(), manager.GetAPIReader(), manager.GetConfig(), controllerConfig.ConfigurationName) setupLog.Info("initializing caches") @@ -560,12 +562,11 @@ func main() { route.GenericManagedHandler(cfg), route.Pod( pod.Handler( + podrules.PodRules(regexCache, registryCache), pod.ImagePullPolicy(), pod.ContainerRegistryLegacy(cfg), - pod.ContainerRegistry(cfg, registryCache), pod.PriorityClass(), pod.RuntimeClass(), - pod.QoSClass(cfg), ), ), route.Ingress(ingress.Class(cfg, kubeVersion), ingress.Hostnames(cfg), ingress.Collision(cfg), ingress.Wildcard()), @@ -675,7 +676,14 @@ func main() { setupLog.Info("disabling node labels verification webhook as current Kubernetes version doesn't have fix for CVE-2021-25735") } - if err = webhook.Register(manager, webhooksList...); err != nil { + if err = webhook.Register( + manager, + *evt.NewEventRecorder( + manager.GetClient(), + ctrl.Log.WithName("capsule.ctrl").WithName("events"), + manager.GetEventRecorder("tenant-controller"), + cfg, + ), webhooksList...); err != nil { setupLog.Error(err, "unable to setup webhooks") os.Exit(1) } diff --git a/e2e/config_client_test.go b/e2e/config_client_test.go index bd14d7ec..14366e56 100644 --- a/e2e/config_client_test.go +++ b/e2e/config_client_test.go @@ -36,7 +36,7 @@ var _ = Describe("CapsuleConfiguration - ServiceAccountClient", Ordered, Label(" } }) - capsuleCfg := configuration.NewCapsuleConfiguration(context.TODO(), k8sClient, cfg, defaultConfigurationName) + capsuleCfg := configuration.NewCapsuleConfiguration(context.TODO(), k8sClient, k8sClient, cfg, defaultConfigurationName) clientCfg, err := capsuleCfg.ServiceAccountClient(context.TODO()) Expect(err).NotTo(HaveOccurred()) Expect(clientCfg.TLSClientConfig.Insecure).To(BeTrue()) @@ -82,7 +82,7 @@ var _ = Describe("CapsuleConfiguration - ServiceAccountClient", Ordered, Label(" } }) - cfg := configuration.NewCapsuleConfiguration(context.TODO(), k8sClient, cfg, defaultConfigurationName) + cfg := configuration.NewCapsuleConfiguration(context.TODO(), k8sClient, k8sClient, cfg, defaultConfigurationName) clientCfg, err := cfg.ServiceAccountClient(context.TODO()) Expect(err).NotTo(HaveOccurred()) Expect(clientCfg.TLSClientConfig.CAData).To(Equal(caData)) diff --git a/e2e/customquota_global_test.go b/e2e/customquota_global_test.go index c4845219..78d77d9d 100644 --- a/e2e/customquota_global_test.go +++ b/e2e/customquota_global_test.go @@ -1738,7 +1738,7 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, Spec: capsulev1beta2.GlobalCustomQuotaSpec{ CustomQuotaSpec: capsulev1beta2.CustomQuotaSpec{ - Limit: resource.MustParse("10"), + Limit: resource.MustParse("1000"), Sources: []capsulev1beta2.CustomQuotaSpecSource{ { VersionKind: gvk.VersionKind{ @@ -1754,8 +1754,8 @@ var _ = Describe("when GlobalCustomQuota uses ledger-backed reconciliation", Ord }, } - extraA := NewNamespace("gq-nsstatus-all-a", map[string]string{"purpose": "e2e"}) - extraB := NewNamespace("gq-nsstatus-all-b", map[string]string{"purpose": "e2e"}) + extraA := NewNamespace("gq-nsstatus-all-a", map[string]string{"purpose": "e2e-global-custom-quota"}) + extraB := NewNamespace("gq-nsstatus-all-b", map[string]string{"purpose": "e2e-global-custom-quota"}) EventuallyCreation(func() error { return k8sClient.Create(ctx, quota) }).Should(Succeed()) awaitGlobalQuotaReady(ctx, quota.GetName()) diff --git a/e2e/rules_enforce_qos_test.go b/e2e/rules_enforce_qos_test.go index a6fcbf51..0ac1fce6 100644 --- a/e2e/rules_enforce_qos_test.go +++ b/e2e/rules_enforce_qos_test.go @@ -202,17 +202,21 @@ var _ = Describe("enforcing pod QoS namespace rules", Ordered, Label("tenant", " expectAuditEvent := func(cs kubernetes.Interface, nsName string, podName string, substrings ...string) { Eventually(func() error { - events, err := cs.CoreV1().Events(nsName).List(context.Background(), metav1.ListOptions{}) + events, err := cs.EventsV1().Events(nsName).List(context.Background(), metav1.ListOptions{}) if err != nil { return err } for _, event := range events.Items { - if event.InvolvedObject.Name != podName { + if event.Regarding.Name != podName { continue } - msg := event.Message + if event.Reason != "NamespaceRuleAudit" { + continue + } + + msg := event.Note matched := true for _, substring := range substrings { @@ -470,9 +474,10 @@ var _ = Describe("enforcing pod QoS namespace rules", Ordered, Label("tenant", " createPodAndExpectAllowed(cs, ns.Name, pod) - expectAuditEvent(cs, ns.Name, pod.Name, - "Burstable", - "audit QoS rule", + expectAuditEvent(clusterAdminClient(), ns.Name, pod.Name, + `QoS class "Burstable"`, + "status.qosClass", + "matched audit namespace rule", ) }) @@ -525,9 +530,10 @@ var _ = Describe("enforcing pod QoS namespace rules", Ordered, Label("tenant", " createPodAndExpectAllowed(cs, ns.Name, pod) - expectAuditEvent(cs, ns.Name, pod.Name, - "Burstable", - "audit QoS rule", + expectAuditEvent(clusterAdminClient(), ns.Name, pod.Name, + `QoS class "Burstable"`, + "status.qosClass", + "matched audit namespace rule", ) }) diff --git a/e2e/rules_enforce_registry_test.go b/e2e/rules_enforce_registry_test.go index c585b3e9..6c012074 100644 --- a/e2e/rules_enforce_registry_test.go +++ b/e2e/rules_enforce_registry_test.go @@ -22,6 +22,7 @@ import ( "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/runtime/events" ) var ( @@ -44,6 +45,46 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( var tnt *capsulev1beta2.Tenant + registryByExpression := func(expression string) rules.OCIRegistry { + return rules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + }, + }, + } + } + + registryByNegatedExpression := func(expression string) rules.OCIRegistry { + return rules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + Negate: true, + }, + }, + } + } + + registryByExact := func(exact ...string) rules.OCIRegistry { + return rules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + Exact: exact, + }, + } + } + + registryByMatch := func(exact []string, expression string) rules.OCIRegistry { + return rules.OCIRegistry{ + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + }, + Exact: exact, + }, + } + } + newTenant := func() *capsulev1beta2.Tenant { return &capsulev1beta2.Tenant{ ObjectMeta: metav1.ObjectMeta{ @@ -70,11 +111,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Action: rules.ActionTypeAllow, Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Registries: []rules.OCIRegistry{ - { - RegExpression: api.RegExpression{ - Expression: "harbor/.*", - }, - }, + registryByExpression("harbor/.*"), }, }, }, @@ -87,11 +124,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Targets: targetContainers, Registries: []rules.OCIRegistry{ - { - RegExpression: api.RegExpression{ - Expression: "harbor/customer/containers/.*", - }, - }, + registryByExpression("harbor/customer/containers/.*"), }, }, }, @@ -104,11 +137,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Targets: targetInitContainers, Registries: []rules.OCIRegistry{ - { - RegExpression: api.RegExpression{ - Expression: "harbor/customer/init/.*", - }, - }, + registryByExpression("harbor/customer/init/.*"), }, }, }, @@ -121,11 +150,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Targets: targetEphemeralContainers, Registries: []rules.OCIRegistry{ - { - RegExpression: api.RegExpression{ - Expression: "harbor/customer/debug/.*", - }, - }, + registryByExpression("harbor/customer/debug/.*"), }, }, }, @@ -138,11 +163,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Targets: targetVolumes, Registries: []rules.OCIRegistry{ - { - RegExpression: api.RegExpression{ - Expression: "harbor/customer/volume/.*", - }, - }, + registryByExpression("harbor/customer/volume/.*"), }, }, }, @@ -155,11 +176,25 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Targets: targetContainers, Registries: []rules.OCIRegistry{ - { - RegExpression: api.RegExpression{ - Expression: "audit/containers/.*", - }, - }, + registryByExpression("audit/containers/.*"), + }, + }, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "allow-audit-containers": "true", + }, + }, + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Targets: targetContainers, + Registries: []rules.OCIRegistry{ + registryByExpression("audit/containers/.*"), }, }, }, @@ -172,11 +207,70 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Targets: targetVolumes, Registries: []rules.OCIRegistry{ - { - RegExpression: api.RegExpression{ - Expression: "audit/volumes/.*", - }, - }, + registryByExpression("audit/volumes/.*"), + }, + }, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "allow-audit-volumes": "true", + }, + }, + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Targets: targetVolumes, + Registries: []rules.OCIRegistry{ + registryByExpression("audit/volumes/.*"), + }, + }, + }, + }, + }, + { + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Targets: targetContainers, + Registries: []rules.OCIRegistry{ + registryByExact( + "exact/containers/app:1", + "exact/containers/app:2", + ), + }, + }, + }, + }, + }, + { + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Targets: targetContainers, + Registries: []rules.OCIRegistry{ + registryByExact("exact/containers/blocked:1"), + }, + }, + }, + }, + }, + { + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Targets: targetContainers, + Registries: []rules.OCIRegistry{ + registryByMatch( + []string{"combined/exact/app:1"}, + "combined/regex/.*", + ), }, }, }, @@ -190,8 +284,10 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Targets: targetContainers, Registries: []rules.OCIRegistry{ { - RegExpression: api.RegExpression{ - Expression: "policy/.*", + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "policy/.*", + }, }, Policy: []corev1.PullPolicy{ corev1.PullNever, @@ -214,11 +310,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Targets: targetContainers, Registries: []rules.OCIRegistry{ - { - RegExpression: api.RegExpression{ - Expression: "harbor/customer/containers/prod/.*", - }, - }, + registryByExpression("harbor/customer/containers/prod/.*"), }, }, }, @@ -236,12 +328,25 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ Targets: targetContainers, Registries: []rules.OCIRegistry{ - { - RegExpression: api.RegExpression{ - Expression: "trusted/.*", - Negate: true, - }, - }, + registryByNegatedExpression("trusted/.*"), + }, + }, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "negate": "true", + }, + }, + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Targets: targetContainers, + Registries: []rules.OCIRegistry{ + registryByExpression("trusted/.*"), }, }, }, @@ -256,6 +361,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( action rules.ActionType targets []rules.WorkloadValidationTarget expressions []string + exact [][]string negated []bool } @@ -287,23 +393,111 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( g.Expect(got.Enforce.Workloads.Targets).To(Equal(expected.targets)) } - g.Expect(got.Enforce.Workloads.Registries).To(HaveLen(len(expected.expressions))) + wantRegistries := len(expected.expressions) + if len(expected.exact) > wantRegistries { + wantRegistries = len(expected.exact) + } - for j, expectedExpression := range expected.expressions { - expr := got.Enforce.Workloads.Registries[j].Expression() + g.Expect(got.Enforce.Workloads.Registries).To(HaveLen(wantRegistries)) - g.Expect(expr.Expression).To(Equal(expectedExpression)) + for j := 0; j < wantRegistries; j++ { + match := got.Enforce.Workloads.Registries[j].ExpressionMatch + + if len(expected.expressions) > j { + g.Expect(match.Expression).To(Equal(expected.expressions[j])) + } else { + g.Expect(match.Expression).To(BeEmpty()) + } + + if len(expected.exact) > j { + g.Expect(match.Exact).To(Equal(expected.exact[j])) + } else { + g.Expect(match.Exact).To(BeEmpty()) + } if len(expected.negated) > j { - g.Expect(expr.Negate).To(Equal(expected.negated[j])) + g.Expect(match.Negate).To(Equal(expected.negated[j])) } else { - g.Expect(expr.Negate).To(BeFalse()) + g.Expect(match.Negate).To(BeFalse()) } } } }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } + baseStatusRules := func() []expectedStatusRule { + return []expectedStatusRule{ + { + action: rules.ActionTypeAllow, + expressions: []string{"harbor/.*"}, + }, + { + action: rules.ActionTypeDeny, + targets: targetContainers, + expressions: []string{"harbor/customer/containers/.*"}, + }, + { + action: rules.ActionTypeDeny, + targets: targetInitContainers, + expressions: []string{"harbor/customer/init/.*"}, + }, + { + action: rules.ActionTypeDeny, + targets: targetEphemeralContainers, + expressions: []string{"harbor/customer/debug/.*"}, + }, + { + action: rules.ActionTypeDeny, + targets: targetVolumes, + expressions: []string{"harbor/customer/volume/.*"}, + }, + { + action: rules.ActionTypeAudit, + targets: targetContainers, + expressions: []string{"audit/containers/.*"}, + }, + { + action: rules.ActionTypeAudit, + targets: targetVolumes, + expressions: []string{"audit/volumes/.*"}, + }, + { + action: rules.ActionTypeAllow, + targets: targetContainers, + exact: [][]string{ + { + "exact/containers/app:1", + "exact/containers/app:2", + }, + }, + }, + { + action: rules.ActionTypeDeny, + targets: targetContainers, + exact: [][]string{ + { + "exact/containers/blocked:1", + }, + }, + }, + { + action: rules.ActionTypeAllow, + targets: targetContainers, + expressions: []string{"combined/regex/.*"}, + exact: [][]string{ + { + "combined/exact/app:1", + }, + }, + }, + { + action: rules.ActionTypeAllow, + targets: targetContainers, + expressions: []string{"policy/.*"}, + }, + } + } + createPodAndExpectDenied := func(cs kubernetes.Interface, nsName string, pod *corev1.Pod, substrings ...string) { base := pod.DeepCopy() baseName := base.Name @@ -389,23 +583,37 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( } } - expectAuditEvent := func(cs kubernetes.Interface, nsName string, podName string, substrings ...string) { + expectAuditEvent := func( + cs kubernetes.Interface, + namespace string, + podName string, + substrings ...string, + ) { Eventually(func() error { - events, err := cs.CoreV1().Events(nsName).List(context.Background(), metav1.ListOptions{}) + evt, err := cs.CoreV1().Events(namespace).List(context.Background(), metav1.ListOptions{}) if err != nil { return err } - for _, event := range events.Items { - if event.InvolvedObject.Name != podName { + for _, e := range evt.Items { + if e.Reason != events.ReasonNamespaceRuleAudit { continue } - msg := event.Message - matched := true + if e.InvolvedObject.Kind != "Pod" { + continue + } + eventPodName := e.InvolvedObject.Name + if eventPodName != podName && !strings.HasPrefix(eventPodName, podName+"-") { + continue + } + + message := e.Message + + matched := true for _, substring := range substrings { - if !strings.Contains(msg, substring) { + if !strings.Contains(message, substring) { matched = false break @@ -417,7 +625,11 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( } } - return fmt.Errorf("expected audit event for pod %q containing %q", podName, substrings) + return fmt.Errorf( + "expected audit event for pod %q containing %q", + podName, + substrings, + ) }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } @@ -437,23 +649,26 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( EventuallyDeletion(tnt) }) - It("denies an allowed registry reference when its pull policy is not permitted", func() { + It("audits a matching image but still denies when no allow rule matches", func() { ns := NewNamespace("", map[string]string{ meta.TenantLabel: tnt.GetName(), }) - cs := ownerClient(tnt.Spec.Owners[0].UserSpec) - NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) - pod := restrictedPod("policy-denied", "policy/team/app:1", corev1.PullIfNotPresent) + pod := restrictedPod("audit-denied", "audit/containers/team/app:1", corev1.PullIfNotPresent) - createPodAndExpectDenied(cs, ns.Name, pod, + createPodAndExpectDenied(clusterAdminClient(), ns.Name, pod, "containers[0]", - "policy/team/app:1", - "pullPolicy=IfNotPresent", - "allowed: Never", + "audit/containers/team/app:1", + "not allowed", + "namespace rule", + ) + + expectAuditEvent(clusterAdminClient(), ns.Name, pod.Name, + "matched audit registry rule", + "audit/containers/.*", ) }) @@ -465,47 +680,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) - expectNamespaceStatusRules(ns.GetName(), []expectedStatusRule{ - { - action: rules.ActionTypeAllow, - expressions: []string{"harbor/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetContainers, - expressions: []string{"harbor/customer/containers/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetInitContainers, - expressions: []string{"harbor/customer/init/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetEphemeralContainers, - expressions: []string{"harbor/customer/debug/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetVolumes, - expressions: []string{"harbor/customer/volume/.*"}, - }, - { - action: rules.ActionTypeAudit, - targets: targetContainers, - expressions: []string{"audit/containers/.*"}, - }, - { - action: rules.ActionTypeAudit, - targets: targetVolumes, - expressions: []string{"audit/volumes/.*"}, - }, - { - action: rules.ActionTypeAllow, - targets: targetContainers, - expressions: []string{"policy/.*"}, - }, - }) + expectNamespaceStatusRules(ns.GetName(), baseStatusRules()) }) It("stores namespace-selector matched rules as additional independent status rule blocks", func() { @@ -517,55 +692,17 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) - expectNamespaceStatusRules(ns.GetName(), []expectedStatusRule{ - { - action: rules.ActionTypeAllow, - expressions: []string{"harbor/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetContainers, - expressions: []string{"harbor/customer/containers/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetInitContainers, - expressions: []string{"harbor/customer/init/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetEphemeralContainers, - expressions: []string{"harbor/customer/debug/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetVolumes, - expressions: []string{"harbor/customer/volume/.*"}, - }, - { - action: rules.ActionTypeAudit, - targets: targetContainers, - expressions: []string{"audit/containers/.*"}, - }, - { - action: rules.ActionTypeAudit, - targets: targetVolumes, - expressions: []string{"audit/volumes/.*"}, - }, - { - action: rules.ActionTypeAllow, - targets: targetContainers, - expressions: []string{"policy/.*"}, - }, - { - action: rules.ActionTypeAllow, - targets: targetContainers, - expressions: []string{"harbor/customer/containers/prod/.*"}, - }, + want := baseStatusRules() + want = append(want, expectedStatusRule{ + action: rules.ActionTypeAllow, + targets: targetContainers, + expressions: []string{"harbor/customer/containers/prod/.*"}, }) + + expectNamespaceStatusRules(ns.GetName(), want) }) - It("stores namespace-selector matched negated regex rules as independent status rule blocks", func() { + It("stores namespace-selector matched negated regex and explicit allow rules as independent status rule blocks", func() { ns := NewNamespace("", map[string]string{ "negate": "true", meta.TenantLabel: tnt.GetName(), @@ -574,53 +711,22 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) - expectNamespaceStatusRules(ns.GetName(), []expectedStatusRule{ - { - action: rules.ActionTypeAllow, - expressions: []string{"harbor/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetContainers, - expressions: []string{"harbor/customer/containers/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetInitContainers, - expressions: []string{"harbor/customer/init/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetEphemeralContainers, - expressions: []string{"harbor/customer/debug/.*"}, - }, - { - action: rules.ActionTypeDeny, - targets: targetVolumes, - expressions: []string{"harbor/customer/volume/.*"}, - }, - { - action: rules.ActionTypeAudit, - targets: targetContainers, - expressions: []string{"audit/containers/.*"}, - }, - { - action: rules.ActionTypeAudit, - targets: targetVolumes, - expressions: []string{"audit/volumes/.*"}, - }, - { - action: rules.ActionTypeAllow, - targets: targetContainers, - expressions: []string{"policy/.*"}, - }, - { + want := baseStatusRules() + want = append(want, + expectedStatusRule{ action: rules.ActionTypeDeny, targets: targetContainers, expressions: []string{"trusted/.*"}, negated: []bool{true}, }, - }) + expectedStatusRule{ + action: rules.ActionTypeAllow, + targets: targetContainers, + expressions: []string{"trusted/.*"}, + }, + ) + + expectNamespaceStatusRules(ns.GetName(), want) }) It("allows a broad matching allow rule", func() { @@ -638,6 +744,70 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( createPodAndExpectAllowed(cs, ns.Name, pod) }) + It("allows an exact array match", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + pod := restrictedPod("exact-array-allowed", "exact/containers/app:2", corev1.PullIfNotPresent) + + createPodAndExpectAllowed(cs, ns.Name, pod) + }) + + It("denies a later exact array deny rule", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + pod := restrictedPod("exact-array-denied", "exact/containers/blocked:1", corev1.PullIfNotPresent) + + createPodAndExpectDenied(cs, ns.Name, pod, + "containers[0]", + "exact/containers/blocked:1", + "denied", + ) + }) + + It("allows a combined exact and regex matcher through the exact branch", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + pod := restrictedPod("combined-exact-allowed", "combined/exact/app:1", corev1.PullIfNotPresent) + + createPodAndExpectAllowed(cs, ns.Name, pod) + }) + + It("allows a combined exact and regex matcher through the regex branch", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + pod := restrictedPod("combined-regex-allowed", "combined/regex/team/app:1", corev1.PullIfNotPresent) + + createPodAndExpectAllowed(cs, ns.Name, pod) + }) + It("denies a later more specific deny rule even when an earlier broad allow rule matched", func() { ns := NewNamespace("", map[string]string{ meta.TenantLabel: tnt.GetName(), @@ -705,26 +875,6 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( createPodAndExpectAllowed(cs, ns.Name, allowed) }) - It("audits a matching image by allowing admission and emitting an event", func() { - ns := NewNamespace("", map[string]string{ - meta.TenantLabel: tnt.GetName(), - }) - - cs := ownerClient(tnt.Spec.Owners[0].UserSpec) - - NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) - NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) - - pod := restrictedPod("audit-allowed", "audit/containers/team/app:1", corev1.PullIfNotPresent) - - createPodAndExpectAllowed(cs, ns.Name, pod) - - expectAuditEvent(cs, ns.Name, pod.Name, - "matched audit registry rule", - "audit/containers/.*", - ) - }) - It("evaluates init containers with the same multi-rule action semantics", func() { ns := NewNamespace("", map[string]string{ meta.TenantLabel: tnt.GetName(), @@ -814,7 +964,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( ) }) - It("audits image volumes independently from container decisions", Label("skip-on-openshift"), func() { + It("audits image volumes independently but still denies when no allow rule matches", Label("skip-on-openshift"), func() { ns := NewNamespace("", map[string]string{ meta.TenantLabel: tnt.GetName(), }) @@ -826,7 +976,7 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "volume-audit-allowed", + Name: "volume-audit-denied", }, Spec: corev1.PodSpec{ SecurityContext: nobodyPodSecurityContext(), @@ -852,9 +1002,14 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( }, } - createPodAndExpectAllowed(cs, ns.Name, pod) + createPodAndExpectDenied(cs, ns.Name, pod, + "volumes[0](imgvol)", + "audit/volumes/team/app:1", + "not allowed", + "namespace rule", + ) - expectAuditEvent(cs, ns.Name, pod.Name, + expectAuditEvent(clusterAdminClient(), ns.Name, pod.Name, "matched audit registry rule", "audit/volumes/.*", ) @@ -934,6 +1089,26 @@ var _ = Describe("enforcing container registry namespace rules", Ordered, Label( createPodAndExpectAllowed(cs, ns.Name, pod) }) + It("denies an allowed registry reference when its pull policy is not permitted", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + pod := restrictedPod("policy-denied", "policy/team/app:1", corev1.PullIfNotPresent) + + createPodAndExpectDenied(cs, ns.Name, pod, + "containers[0]", + "policy/team/app:1", + "pullPolicy=IfNotPresent", + "allowed: Never", + ) + }) + It("applies namespace-selector matched negated regex rules after the base rules", func() { ns := NewNamespace("", map[string]string{ "negate": "true", diff --git a/e2e/rules_enforce_scheduler_test.go b/e2e/rules_enforce_scheduler_test.go new file mode 100644 index 00000000..bedc4008 --- /dev/null +++ b/e2e/rules_enforce_scheduler_test.go @@ -0,0 +1,553 @@ +// 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" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/controller-runtime/pkg/client" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" +) + +var _ = Describe("enforcing pod schedulerName namespace rules", Ordered, Label("tenant", "rules", "enforce", "workloads", "scheduler"), func() { + const ownerName = "e2e-rules-scheduler" + + var tnt *capsulev1beta2.Tenant + + newTenant := func() *capsulev1beta2.Tenant { + return &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-rule-scheduler", + Labels: map[string]string{ + "env": "e2e", + }, + }, + Spec: capsulev1beta2.TenantSpec{ + Owners: rbac.OwnerListSpec{ + { + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Name: ownerName, + Kind: "User", + }, + }, + }, + }, + Rules: []*rules.NamespaceRuleBodyTenant{ + { + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: []api.ExpressionMatch{ + { + Exact: []string{ + "forbidden-scheduler", + "legacy-scheduler", + }, + }, + }, + }, + }, + }, + }, + { + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAudit, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: []api.ExpressionMatch{ + { + Exact: []string{ + "audited-scheduler", + }, + }, + }, + }, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "allow-forbidden-scheduler": "true", + }, + }, + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: []api.ExpressionMatch{ + { + Exact: []string{ + "forbidden-scheduler", + }, + }, + }, + }, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "deny-audited-scheduler": "true", + }, + }, + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: []api.ExpressionMatch{ + { + Exact: []string{ + "audited-scheduler", + }, + }, + }, + }, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "allow-team-scheduler": "true", + }, + }, + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "^team-[a-z0-9-]+$", + }, + }, + }, + }, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "deny-team-scheduler": "true", + }, + }, + NamespaceRuleBodyNamespace: &rules.NamespaceRuleBodyNamespace{ + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Schedulers: []api.ExpressionMatch{ + { + ExpressionRegex: api.ExpressionRegex{ + Expression: "^team-blocked-[a-z0-9-]+$", + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + } + + type expectedSchedulerStatusRule struct { + action rules.ActionType + targets []rules.WorkloadValidationTarget + schedulers []api.ExpressionMatch + } + + expectNamespaceStatusRules := func(nsName string, want []expectedSchedulerStatusRule) { + 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 { + gotRule := nsStatus.Status.Rules[i] + g.Expect(gotRule).NotTo(BeNil()) + g.Expect(gotRule.Enforce.Action).To(Equal(expected.action)) + g.Expect(gotRule.Enforce.Workloads.Targets).To(Equal(expected.targets)) + g.Expect(gotRule.Enforce.Workloads.Schedulers).To(Equal(expected.schedulers)) + } + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + createPodAndExpectDenied := func(cs kubernetes.Interface, nsName string, pod *corev1.Pod, substrings ...string) { + base := pod.DeepCopy() + baseName := base.Name + if baseName == "" { + baseName = "pod" + } + + Eventually(func() error { + p := base.DeepCopy() + p.Name = fmt.Sprintf("%s-%d", baseName, time.Now().UnixNano()%1e6) + + _, err := cs.CoreV1().Pods(nsName).Create(context.Background(), p, metav1.CreateOptions{}) + if err == nil { + _ = cs.CoreV1().Pods(nsName).Delete(context.Background(), p.Name, metav1.DeleteOptions{}) + + return fmt.Errorf("expected create to be denied, but it succeeded") + } + + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("unexpected AlreadyExists: %v", err) + } + + 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()) + } + + createPodAndExpectAllowed := func(cs kubernetes.Interface, nsName string, pod *corev1.Pod) { + EventuallyCreation(func() error { + _, err := cs.CoreV1().Pods(nsName).Create(context.Background(), pod, metav1.CreateOptions{}) + + return err + }).Should(Succeed()) + } + + expectAuditEvent := func(cs kubernetes.Interface, nsName string, podName string, substrings ...string) { + Eventually(func() error { + events, err := cs.EventsV1().Events(nsName).List(context.Background(), metav1.ListOptions{}) + if err != nil { + return err + } + + for _, event := range events.Items { + if event.Regarding.Name != podName { + continue + } + + if event.Reason != "NamespaceRuleAudit" { + continue + } + + msg := event.Note + matched := true + + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + matched = false + + break + } + } + + if matched { + return nil + } + } + + return fmt.Errorf("expected audit event for pod %q containing %q", podName, substrings) + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + podWithScheduler := func(name string, schedulerName string) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.PodSpec{ + SchedulerName: schedulerName, + SecurityContext: nobodyPodSecurityContext(), + Containers: []corev1.Container{ + { + Name: "c", + Image: "registry.k8s.io/pause:3.9", + ImagePullPolicy: corev1.PullIfNotPresent, + SecurityContext: restrictedContainerSecurityContext(), + }, + }, + }, + } + } + + JustBeforeEach(func() { + tnt = newTenant() + + EventuallyCreation(func() error { + tnt.ResourceVersion = "" + + return k8sClient.Create(context.TODO(), tnt) + }).Should(Succeed()) + + TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval) + }) + + JustAfterEach(func() { + EventuallyDeletion(tnt) + }) + + It("stores scheduler workload rules as independent status rule blocks", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + expectNamespaceStatusRules(ns.GetName(), []expectedSchedulerStatusRule{ + { + action: rules.ActionTypeDeny, + schedulers: []api.ExpressionMatch{ + { + Exact: []string{ + "forbidden-scheduler", + "legacy-scheduler", + }, + }, + }, + }, + { + action: rules.ActionTypeAudit, + schedulers: []api.ExpressionMatch{ + { + Exact: []string{ + "audited-scheduler", + }, + }, + }, + }, + }) + }) + + It("stores namespace-selector matched scheduler rules as additional status rule blocks", func() { + ns := NewNamespace("", map[string]string{ + "allow-forbidden-scheduler": "true", + meta.TenantLabel: tnt.GetName(), + }) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + expectNamespaceStatusRules(ns.GetName(), []expectedSchedulerStatusRule{ + { + action: rules.ActionTypeDeny, + schedulers: []api.ExpressionMatch{ + { + Exact: []string{ + "forbidden-scheduler", + "legacy-scheduler", + }, + }, + }, + }, + { + action: rules.ActionTypeAudit, + schedulers: []api.ExpressionMatch{ + { + Exact: []string{ + "audited-scheduler", + }, + }, + }, + }, + { + action: rules.ActionTypeAllow, + schedulers: []api.ExpressionMatch{ + { + Exact: []string{ + "forbidden-scheduler", + }, + }, + }, + }, + }) + }) + + It("ignores pods without an explicit schedulerName", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + createPodAndExpectAllowed(cs, ns.Name, podWithScheduler("empty-scheduler-ignored", "")) + }) + + It("denies pods using an exact denied schedulerName", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + createPodAndExpectDenied(cs, ns.Name, podWithScheduler("forbidden-denied", "forbidden-scheduler"), + "forbidden-scheduler", + "spec.schedulerName", + "denied", + ) + }) + + It("denies pods using another schedulerName from the same exact list", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + createPodAndExpectDenied(cs, ns.Name, podWithScheduler("legacy-denied", "legacy-scheduler"), + "legacy-scheduler", + "spec.schedulerName", + "denied", + ) + }) + + It("allows pods when no scheduler rule matches them", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + createPodAndExpectAllowed(cs, ns.Name, podWithScheduler("unmatched-allowed", "neutral-scheduler")) + }) + + It("audits matching schedulerName rules by allowing admission and emitting an event", func() { + ns := NewNamespace("", map[string]string{ + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + pod := podWithScheduler("scheduler-audited", "audited-scheduler") + + createPodAndExpectAllowed(cs, ns.Name, pod) + + expectAuditEvent(clusterAdminClient(), ns.Name, pod.Name, + `scheduler "audited-scheduler"`, + "spec.schedulerName", + "matched audit namespace rule", + ) + }) + + It("allows a previously denied schedulerName when a later namespace-selected allow rule matches", func() { + ns := NewNamespace("", map[string]string{ + "allow-forbidden-scheduler": "true", + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + createPodAndExpectAllowed(cs, ns.Name, podWithScheduler("forbidden-allowed", "forbidden-scheduler")) + }) + + It("denies an audited schedulerName when a later namespace-selected deny rule matches", func() { + ns := NewNamespace("", map[string]string{ + "deny-audited-scheduler": "true", + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + createPodAndExpectDenied(cs, ns.Name, podWithScheduler("audited-denied", "audited-scheduler"), + "audited-scheduler", + "spec.schedulerName", + "denied", + ) + }) + + It("allows schedulerName values matching a namespace-selected regex allow rule", func() { + ns := NewNamespace("", map[string]string{ + "allow-team-scheduler": "true", + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + createPodAndExpectAllowed(cs, ns.Name, podWithScheduler("team-scheduler-allowed", "team-solar")) + }) + + It("denies schedulerName values that do not match a namespace-selected regex allow rule", func() { + ns := NewNamespace("", map[string]string{ + "allow-team-scheduler": "true", + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + createPodAndExpectDenied(cs, ns.Name, podWithScheduler("team-scheduler-denied", "team_Solar"), + "team_Solar", + "spec.schedulerName", + "not allowed", + ) + }) + + It("denies schedulerName values matching a namespace-selected regex deny rule", func() { + ns := NewNamespace("", map[string]string{ + "deny-team-scheduler": "true", + meta.TenantLabel: tnt.GetName(), + }) + + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) + + NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) + NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) + + createPodAndExpectDenied(cs, ns.Name, podWithScheduler("team-blocked-denied", "team-blocked-solar"), + "team-blocked-solar", + "spec.schedulerName", + "denied", + ) + }) +}) diff --git a/e2e/suite_test.go b/e2e/suite_test.go index 900025f9..099783b2 100644 --- a/e2e/suite_test.go +++ b/e2e/suite_test.go @@ -138,6 +138,17 @@ var _ = SynchronizedAfterSuite( }, ) +func clusterAdminClient() (cs kubernetes.Interface) { + c, err := config.GetConfig() + Expect(err).ToNot(HaveOccurred()) + tuneE2ERestConfig(c) + + cs, err = kubernetes.NewForConfig(c) + Expect(err).ToNot(HaveOccurred()) + + return cs +} + func ownerClient(owner rbac.UserSpec) (cs kubernetes.Interface) { c, err := config.GetConfig() Expect(err).ToNot(HaveOccurred()) diff --git a/hack/distro/capsule/example-setup/tenants.yaml b/hack/distro/capsule/example-setup/tenants.yaml index 26ef7b0b..a5205ce8 100644 --- a/hack/distro/capsule/example-setup/tenants.yaml +++ b/hack/distro/capsule/example-setup/tenants.yaml @@ -89,40 +89,12 @@ spec: kind: User name: alice rules: - - permissions: - promotions: - - clusterRoles: - - "admin" - selector: - matchLabels: - owner.projectcapsule.dev/role: "replication" - - - enforce: - action: "allow" - workloads: - registries: - - exp: ".*" - - enforce: - action: "deny" - workloads: - registries: - - exp: "harbor/.*" - - enforce: - action: "deny" - workloads: - registries: - - exp: "harbor/customer/.*" - - namespaceSelector: - matchExpressions: - - key: env - operator: In - values: - - "prod" - enforce: - action: "allow" - workloads: - registries: - - exp: "harbor/customer/prod-image/.*" + - enforce: + action: "allow" + workloads: + schedulers: + - exact: + - "{{ .tenant.metadata.name }}-scheduler" --- apiVersion: capsule.clastix.io/v1beta2 kind: Tenant diff --git a/internal/cache/regex.go b/internal/cache/regex.go index 3c3a0609..a5fcc577 100644 --- a/internal/cache/regex.go +++ b/internal/cache/regex.go @@ -46,7 +46,7 @@ func NewRegexCache() *RegexCache { } } -func (c *RegexCache) GetOrCompile(expr api.RegExpression) (*CompiledRegex, bool, error) { +func (c *RegexCache) GetOrCompile(expr api.ExpressionRegex) (*CompiledRegex, bool, error) { if c == nil { return nil, false, fmt.Errorf("regex cache is nil") } @@ -129,7 +129,16 @@ func (c *RegexCache) Reset() { c.re = make(map[string]*CompiledRegex) } -func HashRegex(expr api.RegExpression) string { +func (c *RegexCache) MatchRegex(expr api.ExpressionRegex, value string) (bool, error) { + compiled, _, err := c.GetOrCompile(expr) + if err != nil { + return false, err + } + + return compiled.MatchString(value), nil +} + +func HashRegex(expr api.ExpressionRegex) string { var b strings.Builder b.WriteString(strings.TrimSpace(expr.Expression)) diff --git a/internal/cache/regex_test.go b/internal/cache/regex_test.go index c25d5c2e..41bccede 100644 --- a/internal/cache/regex_test.go +++ b/internal/cache/regex_test.go @@ -14,13 +14,13 @@ func TestCompiledRegexMatchString(t *testing.T) { tests := []struct { name string - expression api.RegExpression + expression api.ExpressionRegex value string want bool }{ { name: "normal expression matches matching value", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: "trusted/.*", }, value: "trusted/team/app:1", @@ -28,7 +28,7 @@ func TestCompiledRegexMatchString(t *testing.T) { }, { name: "normal expression does not match non matching value", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: "trusted/.*", }, value: "docker.io/team/app:1", @@ -36,7 +36,7 @@ func TestCompiledRegexMatchString(t *testing.T) { }, { name: "negated expression does not match matching value", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, @@ -45,7 +45,7 @@ func TestCompiledRegexMatchString(t *testing.T) { }, { name: "negated expression matches non matching value", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, @@ -79,7 +79,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { tests := []struct { name string - expression api.RegExpression + expression api.ExpressionRegex value string wantMatch bool wantErr bool @@ -88,7 +88,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }{ { name: "compile matching regex", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, }, value: "ghcr.io/projectcapsule/capsule:latest", @@ -99,7 +99,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "compile non matching regex", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, }, value: "docker.io/library/nginx:latest", @@ -110,7 +110,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "compile negated matching regex", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, Negate: true, }, @@ -122,7 +122,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "compile negated non matching regex", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, Negate: true, }, @@ -134,7 +134,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "reject empty expression", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: "", }, value: "ghcr.io/projectcapsule/capsule:latest", @@ -143,7 +143,7 @@ func TestRegexCache_GetOrCompile(t *testing.T) { }, { name: "reject invalid regex", - expression: api.RegExpression{ + expression: api.ExpressionRegex{ Expression: `[`, }, value: "ghcr.io/projectcapsule/capsule:latest", @@ -207,7 +207,7 @@ func TestRegexCache_GetOrCompile_ReusesCachedRegex(t *testing.T) { c := NewRegexCache() - expr := api.RegExpression{ + expr := api.ExpressionRegex{ Expression: `^ghcr\.io/projectcapsule/.*`, } @@ -241,11 +241,11 @@ func TestRegexCache_GetOrCompile_ReusesCachedRegex(t *testing.T) { func TestRegexCache_HashRegex_UsesNegate(t *testing.T) { t.Parallel() - positive := HashRegex(api.RegExpression{ + positive := HashRegex(api.ExpressionRegex{ Expression: `^ghcr\.io/.*`, }) - negative := HashRegex(api.RegExpression{ + negative := HashRegex(api.ExpressionRegex{ Expression: `^ghcr\.io/.*`, Negate: true, }) @@ -260,7 +260,7 @@ func TestRegexCache_Reset(t *testing.T) { c := NewRegexCache() - compiled, _, err := c.GetOrCompile(api.RegExpression{ + compiled, _, err := c.GetOrCompile(api.ExpressionRegex{ Expression: `^ghcr\.io/.*`, }) if err != nil { diff --git a/internal/cache/registries.go b/internal/cache/registries.go index 552a6809..9a91ffc1 100644 --- a/internal/cache/registries.go +++ b/internal/cache/registries.go @@ -23,8 +23,10 @@ type RuleSet struct { } type CompiledRule struct { - Expression api.RegExpression - RegexID string + Match api.ExpressionMatch + + // RegexID is empty when Match.Expression is empty. + RegexID string AllowedPolicy map[corev1.PullPolicy]struct{} // nil/empty => allow any } @@ -97,7 +99,7 @@ func (c *RegistryRuleSetCache) GetOrBuild(specRules []rules.OCIRegistry) (rs *Ru return built, false, nil } -// Match matches reference, regex and pullPolicy. +// Match matches reference, expression and pullPolicy. // Admission evaluation should usually use MatchReference instead. func (c *RegistryRuleSetCache) Match( specRules []rules.OCIRegistry, @@ -140,12 +142,12 @@ func (c *RegistryRuleSetCache) MatchRuleSet( continue } - compiled, _, err := c.regexCache.GetOrCompile(rule.Expression) + matched, err := rule.Match.MatchesWithExpressionMatcher(c.regexCache, reference) if err != nil { return nil, err } - if compiled.MatchString(reference) { + if matched { return rule, nil } } @@ -153,7 +155,7 @@ func (c *RegistryRuleSetCache) MatchRuleSet( return nil, nil } -// MatchReference matches reference and regex only. +// MatchReference matches reference only. // It intentionally does not check pullPolicy. func (c *RegistryRuleSetCache) MatchReference( rs *RuleSet, @@ -174,12 +176,12 @@ func (c *RegistryRuleSetCache) MatchReference( for i := range rs.Compiled { rule := &rs.Compiled[i] - compiled, _, err := c.regexCache.GetOrCompile(rule.Expression) + matched, err := rule.Match.MatchesWithExpressionMatcher(c.regexCache, reference) if err != nil { return nil, err } - if compiled.MatchString(reference) { + if matched { return rule, nil } } @@ -224,7 +226,7 @@ func (c *RegistryRuleSetCache) PruneActive(activeIDs map[string]struct{}) int { func (c *RegistryRuleSetCache) HashRules(specRules []rules.OCIRegistry) string { var b strings.Builder - b.Grow(len(specRules) * 96) + b.Grow(len(specRules) * 160) const ( sepRule = "\n" @@ -233,7 +235,10 @@ func (c *RegistryRuleSetCache) HashRules(specRules []rules.OCIRegistry) string { ) for _, r := range specRules { - expr := r.Expression() + match := r.ExpressionMatch + + exact := append([]string(nil), match.Exact...) + sort.Strings(exact) policies := make([]string, 0, len(r.Policy)) for _, p := range r.Policy { @@ -242,15 +247,34 @@ func (c *RegistryRuleSetCache) HashRules(specRules []rules.OCIRegistry) string { sort.Strings(policies) - b.WriteString(strings.TrimSpace(expr.Expression)) + b.WriteString("exact") b.WriteString(sepField) - if expr.Negate { + for i, v := range exact { + if i > 0 { + b.WriteString(sepList) + } + + b.WriteString(v) + } + + b.WriteString(sepField) + b.WriteString("exp") + b.WriteString(sepField) + b.WriteString(strings.TrimSpace(match.Expression)) + + b.WriteString(sepField) + b.WriteString("negate") + b.WriteString(sepField) + + if match.Negate { b.WriteString("1") } else { b.WriteString("0") } + b.WriteString(sepField) + b.WriteString("policy") b.WriteString(sepField) for i, p := range policies { @@ -316,16 +340,23 @@ func (c *RegistryRuleSetCache) buildRuleSet(id string, specRules []rules.OCIRegi } for _, r := range specRules { - expression := r.Expression() + match := r.ExpressionMatch - compiled, _, err := c.regexCache.GetOrCompile(expression) - if err != nil { - return nil, err + if len(match.Exact) == 0 && strings.TrimSpace(match.Expression) == "" { + return nil, fmt.Errorf("registry rule must define at least one of exact or exp") } cr := CompiledRule{ - Expression: expression, - RegexID: compiled.ID, + Match: match, + } + + if strings.TrimSpace(match.Expression) != "" { + compiled, _, err := c.regexCache.GetOrCompile(match.ExpressionRegex) + if err != nil { + return nil, err + } + + cr.RegexID = compiled.ID } if len(r.Policy) > 0 { diff --git a/internal/cache/registries_test.go b/internal/cache/registries_test.go index b2908d83..506583ae 100644 --- a/internal/cache/registries_test.go +++ b/internal/cache/registries_test.go @@ -94,7 +94,7 @@ func TestRegistryRuleSetCacheGetOrBuild(t *testing.T) { { name: "registry with negated expression builds ruleset", rules: []rules.OCIRegistry{ - registryWithExpression(api.RegExpression{ + registryWithExpression(api.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -242,7 +242,7 @@ func TestRegistryRuleSetCacheBuildRuleSet(t *testing.T) { specRules := []rules.OCIRegistry{ registry("harbor/.*"), registryWithPolicy("ghcr.io/.*", corev1.PullAlways, corev1.PullIfNotPresent), - registryWithExpression(api.RegExpression{ + registryWithExpression(api.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -267,16 +267,16 @@ func TestRegistryRuleSetCacheBuildRuleSet(t *testing.T) { t.Fatalf("expected %d compiled rules, got %d", len(specRules), len(rs.Compiled)) } - if rs.Compiled[0].Expression.Expression != "harbor/.*" { - t.Fatalf("expected first expression harbor/.*, got %q", rs.Compiled[0].Expression.Expression) + if rs.Compiled[0].Match.Expression != "harbor/.*" { + t.Fatalf("expected first expression harbor/.*, got %q", rs.Compiled[0].Match.Expression) } if len(rs.Compiled[0].AllowedPolicy) != 0 { t.Fatal("expected first rule to allow any pull policy") } - if rs.Compiled[1].Expression.Expression != "ghcr.io/.*" { - t.Fatalf("expected second expression ghcr.io/.*, got %q", rs.Compiled[1].Expression.Expression) + if rs.Compiled[1].Match.Expression != "ghcr.io/.*" { + t.Fatalf("expected second expression ghcr.io/.*, got %q", rs.Compiled[1].Match.Expression) } if len(rs.Compiled[1].AllowedPolicy) != 2 { @@ -291,7 +291,7 @@ func TestRegistryRuleSetCacheBuildRuleSet(t *testing.T) { t.Fatal("expected PullIfNotPresent to be allowed") } - if !rs.Compiled[2].Expression.Negate { + if !rs.Compiled[2].Match.Negate { t.Fatal("expected third expression to be negated") } } @@ -363,7 +363,7 @@ func TestRegistryRuleSetCacheMatchReference(t *testing.T) { { name: "negated expression matches non-matching reference", rules: []rules.OCIRegistry{ - registryWithExpression(api.RegExpression{ + registryWithExpression(api.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -377,7 +377,7 @@ func TestRegistryRuleSetCacheMatchReference(t *testing.T) { { name: "negated expression does not match matching reference", rules: []rules.OCIRegistry{ - registryWithExpression(api.RegExpression{ + registryWithExpression(api.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -406,7 +406,7 @@ func TestRegistryRuleSetCacheMatchReference(t *testing.T) { { name: "nested regex expression wins over legacy url", rules: []rules.OCIRegistry{ - registryWithExpression(api.RegExpression{ + registryWithExpression(api.ExpressionRegex{ Expression: "nested/.*", }), }, @@ -417,7 +417,7 @@ func TestRegistryRuleSetCacheMatchReference(t *testing.T) { { name: "legacy url is ignored when nested regex expression is set", rules: []rules.OCIRegistry{ - registryWithExpression(api.RegExpression{ + registryWithExpression(api.ExpressionRegex{ Expression: "nested/.*", }), }, @@ -449,8 +449,8 @@ func TestRegistryRuleSetCacheMatchReference(t *testing.T) { t.Fatal("expected match, got nil") } - if got.Expression.Expression != tt.wantExpr { - t.Fatalf("expected expression %q, got %q", tt.wantExpr, got.Expression.Expression) + if got.Match.Expression != tt.wantExpr { + t.Fatalf("expected expression %q, got %q", tt.wantExpr, got.Match.Expression) } return @@ -526,7 +526,7 @@ func TestRegistryRuleSetCacheMatchRuleSetWithPullPolicy(t *testing.T) { { name: "negated expression respects pull policy", rules: []rules.OCIRegistry{ - registryWithExpressionAndPolicy(api.RegExpression{ + registryWithExpressionAndPolicy(api.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, corev1.PullIfNotPresent), @@ -541,7 +541,7 @@ func TestRegistryRuleSetCacheMatchRuleSetWithPullPolicy(t *testing.T) { { name: "negated expression still rejects forbidden pull policy", rules: []rules.OCIRegistry{ - registryWithExpressionAndPolicy(api.RegExpression{ + registryWithExpressionAndPolicy(api.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }, corev1.PullNever), @@ -577,8 +577,8 @@ func TestRegistryRuleSetCacheMatchRuleSetWithPullPolicy(t *testing.T) { t.Fatal("expected match, got nil") } - if got.Expression.Expression != tt.wantExpr { - t.Fatalf("expected expression %q, got %q", tt.wantExpr, got.Expression.Expression) + if got.Match.Expression != tt.wantExpr { + t.Fatalf("expected expression %q, got %q", tt.wantExpr, got.Match.Expression) } return @@ -611,8 +611,8 @@ func TestRegistryRuleSetCacheMatch(t *testing.T) { t.Fatal("expected match, got nil") } - if got.Expression.Expression != "harbor/.*" { - t.Fatalf("expected harbor/.*, got %q", got.Expression.Expression) + if got.Match.Expression != "harbor/.*" { + t.Fatalf("expected harbor/.*, got %q", got.Match.Expression) } } @@ -714,14 +714,14 @@ func TestRegistryRuleSetCacheHashRules(t *testing.T) { c := NewRegistryRuleSetCache(nil) hashA := c.HashRules([]rules.OCIRegistry{ - registryWithExpression(api.RegExpression{ + registryWithExpression(api.ExpressionRegex{ Expression: "trusted/.*", Negate: false, }), }) hashB := c.HashRules([]rules.OCIRegistry{ - registryWithExpression(api.RegExpression{ + registryWithExpression(api.ExpressionRegex{ Expression: "trusted/.*", Negate: true, }), @@ -924,35 +924,43 @@ func TestRegistryRuleSetCacheInsertForTest(t *testing.T) { func registry(expression string) rules.OCIRegistry { return rules.OCIRegistry{ - RegExpression: api.RegExpression{ - Expression: expression, - Negate: false, + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + Negate: false, + }, }, } } func registryWithPolicy(expression string, policies ...corev1.PullPolicy) rules.OCIRegistry { return rules.OCIRegistry{ - RegExpression: api.RegExpression{ - Expression: expression, - Negate: false, + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: expression, + Negate: false, + }, }, Policy: policies, } } -func registryWithExpression(expression api.RegExpression) rules.OCIRegistry { +func registryWithExpression(expression api.ExpressionRegex) rules.OCIRegistry { return rules.OCIRegistry{ - RegExpression: expression, + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: expression, + }, } } func registryWithExpressionAndPolicy( - expression api.RegExpression, + expression api.ExpressionRegex, policies ...corev1.PullPolicy, ) rules.OCIRegistry { return rules.OCIRegistry{ - RegExpression: expression, - Policy: policies, + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: expression, + }, + Policy: policies, } } diff --git a/internal/controllers/cfg/invalidator/manager.go b/internal/controllers/cfg/invalidator/manager.go index e0905122..10d782de 100644 --- a/internal/controllers/cfg/invalidator/manager.go +++ b/internal/controllers/cfg/invalidator/manager.go @@ -31,6 +31,8 @@ import ( type CacheInvalidator struct { client.Client + reader client.Reader + Rest *rest.Config Log logr.Logger @@ -63,6 +65,7 @@ func (r *CacheInvalidator) Start(ctx context.Context) error { func (r *CacheInvalidator) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) (err error) { r.configName = ctrlConfig.ConfigurationName + r.reader = mgr.GetAPIReader() err = ctrl.NewControllerManagedBy(mgr). Named("config/caches"). @@ -138,7 +141,7 @@ func (r *CacheInvalidator) Reconcile(ctx context.Context, request reconcile.Requ log.V(5).Info("invalidating and rebuilding caches") - cfg := configuration.NewCapsuleConfiguration(ctx, r.Client, r.Rest, request.Name) + cfg := configuration.NewCapsuleConfiguration(ctx, r.Client, r.reader, r.Rest, request.Name) instance := &capsulev1beta2.CapsuleConfiguration{} if err = r.Get(ctx, request.NamespacedName, instance); err != nil { diff --git a/internal/controllers/cfg/invalidator/regex.go b/internal/controllers/cfg/invalidator/regex.go index 7599eb45..8775f81f 100644 --- a/internal/controllers/cfg/invalidator/regex.go +++ b/internal/controllers/cfg/invalidator/regex.go @@ -28,7 +28,7 @@ func (r *CacheInvalidator) rebuildRegexCache(ctx context.Context, log logr.Logge r.RegexCache.Reset() - expressions := make(map[string]api.RegExpression) + expressions := make(map[string]api.ExpressionRegex) for i := range ruleStatuses.Items { rs := &ruleStatuses.Items[i] @@ -52,7 +52,7 @@ func (r *CacheInvalidator) rebuildRegexCache(ctx context.Context, log logr.Logge } func collectRegexExpressionsFromNamespaceRules( - set map[string]api.RegExpression, + set map[string]api.ExpressionRegex, r []*rules.NamespaceRuleBodyNamespace, ) { for _, rule := range r { @@ -61,7 +61,7 @@ func collectRegexExpressionsFromNamespaceRules( } func collectRegexExpressionsFromNamespaceRule( - set map[string]api.RegExpression, + set map[string]api.ExpressionRegex, rule *rules.NamespaceRuleBodyNamespace, ) { if rule == nil { @@ -73,7 +73,16 @@ func collectRegexExpressionsFromNamespaceRule( } for _, registry := range rule.Enforce.Workloads.Registries { - expr := registry.RegExpression + expr := registry.ExpressionRegex + if expr.Expression == "" { + continue + } + + set[cache.HashRegex(expr)] = expr + } + + for _, scheduler := range rule.Enforce.Workloads.Schedulers { + expr := scheduler.ExpressionRegex if expr.Expression == "" { continue } diff --git a/internal/controllers/cfg/status/manager.go b/internal/controllers/cfg/status/manager.go index 7cfed51a..f6d2e79a 100644 --- a/internal/controllers/cfg/status/manager.go +++ b/internal/controllers/cfg/status/manager.go @@ -42,6 +42,8 @@ const tenantEventMarker = "tenant-event" type Manager struct { client.Client + reader client.Reader + Rest *rest.Config configName string @@ -50,6 +52,7 @@ type Manager struct { func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.ControllerOptions) (err error) { r.configName = ctrlConfig.ConfigurationName + r.reader = mgr.GetAPIReader() return ctrl.NewControllerManagedBy(mgr). Named("capsule/configuration"). @@ -154,7 +157,7 @@ func (r *Manager) Reconcile(ctx context.Context, request reconcile.Request) (res // refresh overwrites latest.Status.Tenants. didRefreshTenants := false - cfg := configuration.NewCapsuleConfiguration(ctx, r.Client, r.Rest, request.Name) + cfg := configuration.NewCapsuleConfiguration(ctx, r.Client, r.reader, r.Rest, request.Name) instance := &capsulev1beta2.CapsuleConfiguration{} if err = r.Get(ctx, types.NamespacedName{Name: request.Name}, instance); err != nil { diff --git a/internal/controllers/rulestatus/manager.go b/internal/controllers/rulestatus/manager.go index ed310a18..bbe9166b 100644 --- a/internal/controllers/rulestatus/manager.go +++ b/internal/controllers/rulestatus/manager.go @@ -26,6 +26,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/internal/controllers/utils" "github.com/projectcapsule/capsule/internal/metrics" + "github.com/projectcapsule/capsule/pkg/api" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" meta "github.com/projectcapsule/capsule/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rules" @@ -168,6 +169,10 @@ func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleSta []corev1.PodQOSClass(nil), rule.Enforce.Workloads.QoSClasses..., ), + Schedulers: append( + []api.ExpressionMatch(nil), + rule.Enforce.Workloads.Schedulers..., + ), }, }, } diff --git a/internal/controllers/tenant/rulestatus.go b/internal/controllers/tenant/rulestatus.go index 24104aee..214968db 100644 --- a/internal/controllers/tenant/rulestatus.go +++ b/internal/controllers/tenant/rulestatus.go @@ -25,7 +25,7 @@ func (r *Manager) reconcileRuleStatus( ns *corev1.Namespace, ) error { // Collect Rules for namespace - ruleBody, err := tenant.BuildNamespaceRuleBodyStatus(ctx, r.Client, ns, tnt) + ruleBody, err := tenant.BuildNamespaceRuleBodyStatus(r.Scheme(), ns, tnt) if err != nil { return err } diff --git a/internal/webhook/cfg/handler.go b/internal/webhook/cfg/handler.go index 139c2a78..f87ccb4a 100644 --- a/internal/webhook/cfg/handler.go +++ b/internal/webhook/cfg/handler.go @@ -6,13 +6,13 @@ package cfg import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/cfg/owners.go b/internal/webhook/cfg/owners.go index cadbb800..580d971d 100644 --- a/internal/webhook/cfg/owners.go +++ b/internal/webhook/cfg/owners.go @@ -6,12 +6,12 @@ package cfg import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/tenant" ) diff --git a/internal/webhook/cfg/serviceaccount.go b/internal/webhook/cfg/serviceaccount.go index 421e320f..105c51a7 100644 --- a/internal/webhook/cfg/serviceaccount.go +++ b/internal/webhook/cfg/serviceaccount.go @@ -6,12 +6,12 @@ package cfg import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/cfg/warnings.go b/internal/webhook/cfg/warnings.go index 3132960f..6475f1df 100644 --- a/internal/webhook/cfg/warnings.go +++ b/internal/webhook/cfg/warnings.go @@ -7,11 +7,11 @@ import ( "context" admissionv1 "k8s.io/api/admission/v1" - "k8s.io/client-go/tools/events" "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/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/customquota/calculation.go b/internal/webhook/customquota/calculation.go index ded23d15..7e513283 100644 --- a/internal/webhook/customquota/calculation.go +++ b/internal/webhook/customquota/calculation.go @@ -19,7 +19,6 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" - "k8s.io/client-go/tools/events" "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -31,6 +30,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" index "github.com/projectcapsule/capsule/pkg/runtime/indexers/customquota" "github.com/projectcapsule/capsule/pkg/runtime/quota" diff --git a/internal/webhook/customquota/customquota_validating.go b/internal/webhook/customquota/customquota_validating.go index 3db38d92..d8c89c4c 100644 --- a/internal/webhook/customquota/customquota_validating.go +++ b/internal/webhook/customquota/customquota_validating.go @@ -7,7 +7,6 @@ import ( "context" "fmt" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -15,6 +14,7 @@ import ( "github.com/projectcapsule/capsule/internal/cache" controller "github.com/projectcapsule/capsule/internal/controllers/customquotas" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/runtime/quota" ) diff --git a/internal/webhook/customquota/globalcustomquota_validating.go b/internal/webhook/customquota/globalcustomquota_validating.go index 49c1f525..b2ca3067 100644 --- a/internal/webhook/customquota/globalcustomquota_validating.go +++ b/internal/webhook/customquota/globalcustomquota_validating.go @@ -7,7 +7,6 @@ import ( "context" "fmt" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -15,6 +14,7 @@ import ( "github.com/projectcapsule/capsule/internal/cache" controller "github.com/projectcapsule/capsule/internal/controllers/customquotas" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/runtime/quota" ) diff --git a/internal/webhook/defaults/handler.go b/internal/webhook/defaults/handler.go index d49d9c12..4f0606ed 100644 --- a/internal/webhook/defaults/handler.go +++ b/internal/webhook/defaults/handler.go @@ -8,11 +8,11 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/version" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/dra/validate.go b/internal/webhook/dra/validate.go index 069773c7..ee76715e 100644 --- a/internal/webhook/dra/validate.go +++ b/internal/webhook/dra/validate.go @@ -5,19 +5,19 @@ package dra import ( "context" + "fmt" "net/http" corev1 "k8s.io/api/core/v1" resources "k8s.io/api/resource/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/projectcapsule/capsule/internal/webhook/utils" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/tenant" ) @@ -120,7 +120,17 @@ func (h *deviceClass) validateResourceRequest( case allowed.Match(dc.Name) || selector: return nil default: - recorder.Eventf(obj, tnt, corev1.EventTypeWarning, evt.ReasonForbiddenDeviceClass, evt.ActionValidationDenied, "%s %s/%s DeviceClass %s is forbidden for the current Tenant", req.Kind.Kind, req.Namespace, req.Name, &dc) + recorder.LabeledEvent( + obj, + corev1.EventTypeWarning, + events.ReasonForbiddenDeviceClass, + events.ActionValidationDenied, + fmt.Sprintf("%s %s/%s DeviceClass %s is forbidden for the current tenant", req.Kind.Kind, req.Namespace, req.Name, dc), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewDeviceClassForbidden(dc.Name, *allowed).Error()) } diff --git a/internal/webhook/gateway/validate_class.go b/internal/webhook/gateway/validate_class.go index 36d4063e..c6167071 100644 --- a/internal/webhook/gateway/validate_class.go +++ b/internal/webhook/gateway/validate_class.go @@ -5,11 +5,11 @@ package gateway import ( "context" + "fmt" "net/http" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -19,7 +19,7 @@ import ( caperrors "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -101,7 +101,17 @@ func (r *class) validate( } if gatewayClass == nil { - recorder.Eventf(gatewayObj, tnt, corev1.EventTypeWarning, evt.ReasonMissingGatewayClass, evt.ActionValidationDenied, "Gateway %s/%s is missing GatewayClass", req.Namespace, req.Name) + recorder.LabeledEvent( + gatewayObj, + corev1.EventTypeWarning, + events.ReasonMissingGatewayClass, + events.ActionValidationDenied, + fmt.Sprintf("Gateway %s/%s is missing GatewayClass", req.Namespace, req.Name), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewGatewayClassUndefined(*allowed).Error()) } @@ -128,7 +138,17 @@ func (r *class) validate( case allowed.Match(gatewayClass.Name) || selector: return nil default: - recorder.Eventf(gatewayObj, tnt, corev1.EventTypeWarning, evt.ReasonForbiddenGatewayClass, evt.ActionValidationDenied, "Gateway %s/%s GatewayClass %s is forbidden for the current Tenant", req.Namespace, req.Name, &gatewayClass) + recorder.LabeledEvent( + gatewayObj, + corev1.EventTypeWarning, + events.ReasonForbiddenGatewayClass, + events.ActionValidationDenied, + fmt.Sprintf("Gateway %s/%s GatewayClass %s is forbidden for the current Tenant", req.Namespace, req.Name, gatewayClass.GetName()), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewGatewayClassForbidden(gatewayObj.Name, *allowed).Error()) } diff --git a/internal/webhook/generic/cordoning.go b/internal/webhook/generic/cordoning.go index 45a400eb..5e424e3c 100644 --- a/internal/webhook/generic/cordoning.go +++ b/internal/webhook/generic/cordoning.go @@ -6,12 +6,12 @@ package generic import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/generic/custom_resource_quota.go b/internal/webhook/generic/custom_resource_quota.go index f4fec307..194faf97 100644 --- a/internal/webhook/generic/custom_resource_quota.go +++ b/internal/webhook/generic/custom_resource_quota.go @@ -10,7 +10,6 @@ import ( "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/events" "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -18,7 +17,7 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/tenant" ) @@ -81,7 +80,17 @@ func (r *resourceCounterHandler) OnCreate( }) if err != nil { if errors.As(err, &caperrors.CustomResourceQuotaError{}) { - recorder.Eventf(tnt, nil, corev1.EventTypeWarning, evt.ReasonOverprovision, evt.ActionValidationDenied, "Resource %s/%s in API group %s cannot be created, limit usage of %d has been reached", req.Namespace, req.Name, kgv, limit) + recorder.LabeledEvent( + tnt, + corev1.EventTypeWarning, + events.ReasonOverprovision, + events.ActionValidationDenied, + fmt.Sprintf("Resource %s/%s in API group %s cannot be created, limit usage of %d has been reached", req.Namespace, req.Name, kgv, limit), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) } return ad.ErroredResponse(err) diff --git a/internal/webhook/generic/managed.go b/internal/webhook/generic/managed.go index 5cf7765e..bc5ff622 100644 --- a/internal/webhook/generic/managed.go +++ b/internal/webhook/generic/managed.go @@ -6,12 +6,12 @@ package generic import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/generic/metadata.go b/internal/webhook/generic/metadata.go index c69821ff..77cbe14a 100644 --- a/internal/webhook/generic/metadata.go +++ b/internal/webhook/generic/metadata.go @@ -9,13 +9,13 @@ import ( admissionv1 "k8s.io/api/admission/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/projectcapsule/capsule/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" clt "github.com/projectcapsule/capsule/pkg/runtime/client" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/tenant" ) diff --git a/internal/webhook/generic/replications.go b/internal/webhook/generic/replications.go index c69e1707..8fe44ead 100644 --- a/internal/webhook/generic/replications.go +++ b/internal/webhook/generic/replications.go @@ -8,13 +8,13 @@ import ( "k8s.io/apimachinery/pkg/fields" "k8s.io/apiserver/pkg/authentication/serviceaccount" - "k8s.io/client-go/tools/events" "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/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/gvk" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenantresource" diff --git a/internal/webhook/ingress/validate_class.go b/internal/webhook/ingress/validate_class.go index ac17fe19..59f2d198 100644 --- a/internal/webhook/ingress/validate_class.go +++ b/internal/webhook/ingress/validate_class.go @@ -5,12 +5,12 @@ package ingress import ( "context" + "fmt" "net/http" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/util/version" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -19,7 +19,7 @@ import ( caperrors "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -101,7 +101,17 @@ func (r *class) validate( ingressClass := ingress.IngressClass() if ingressClass == nil { - recorder.Eventf(ingress.GetClientObject(), tnt, corev1.EventTypeWarning, evt.ReasonMissingIngressClass, evt.ActionValidationDenied, "Ingress %s/%s is missing IngressClass", req.Namespace, req.Name) + recorder.LabeledEvent( + ingress.GetClientObject(), + corev1.EventTypeWarning, + events.ReasonMissingIngressClass, + events.ActionValidationDenied, + "ingress is missing ingressclass", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewIngressClassUndefined(*allowed).Error()) } @@ -129,7 +139,17 @@ func (r *class) validate( case allowed.Match(*ingressClass) || selector: return nil default: - recorder.Eventf(ingress.GetClientObject(), tnt, corev1.EventTypeWarning, evt.ReasonForbiddenIngressClass, evt.ActionValidationDenied, "Ingress %s/%s IngressClass %s is forbidden for the current Tenant", req.Namespace, req.Name, &ingressClass) + recorder.LabeledEvent( + ingress.GetClientObject(), + corev1.EventTypeWarning, + events.ReasonForbiddenIngressClass, + events.ActionValidationDenied, + fmt.Sprintf("ingressclass %s is forbidden for the elected tenant", *ingressClass), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewIngressClassForbidden(*ingressClass, *allowed).Error()) } diff --git a/internal/webhook/ingress/validate_collision.go b/internal/webhook/ingress/validate_collision.go index 03ae4591..bcf686de 100644 --- a/internal/webhook/ingress/validate_collision.go +++ b/internal/webhook/ingress/validate_collision.go @@ -13,7 +13,6 @@ import ( networkingv1 "k8s.io/api/networking/v1" networkingv1beta1 "k8s.io/api/networking/v1beta1" "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -22,7 +21,7 @@ import ( caperrors "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/runtime/indexers/ingress" ) @@ -97,7 +96,17 @@ func (r *collision) validate( var collisionErr *caperrors.IngressHostnameCollisionError if errors.As(err, &collisionErr) { - recorder.Eventf(ing.GetClientObject(), tnt, corev1.EventTypeWarning, evt.ReasonIngressHostnameCollision, evt.ActionValidationDenied, "Ingress %s/%s hostname is colliding", ing.Namespace(), ing.Name()) + recorder.LabeledEvent( + ing.GetClientObject(), + corev1.EventTypeWarning, + events.ReasonIngressHostnameCollision, + events.ActionValidationDenied, + "ingress hostname is colliding", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) } return ad.Deny(err.Error()) diff --git a/internal/webhook/ingress/validate_hostnames.go b/internal/webhook/ingress/validate_hostnames.go index 15366e32..b1205cfc 100644 --- a/internal/webhook/ingress/validate_hostnames.go +++ b/internal/webhook/ingress/validate_hostnames.go @@ -10,7 +10,6 @@ import ( "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/sets" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -18,7 +17,7 @@ import ( caperrors "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -90,7 +89,17 @@ func (r *hostnames) validate( for hostname := range ingress.HostnamePathsPairs() { if len(hostname) == 0 { - recorder.Eventf(ingress.GetClientObject(), tnt, corev1.EventTypeWarning, evt.ReasonIngressHostnameEmpty, evt.ActionValidationDenied, "Ingress %s/%s hostname is empty", ingress.Namespace(), ingress.Name()) + recorder.LabeledEvent( + ingress.GetClientObject(), + corev1.EventTypeWarning, + events.ReasonIngressHostnameEmpty, + events.ActionValidationDenied, + "ingress hostname is empty", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.ErroredResponse(caperrors.NewEmptyIngressHostname(*tnt.Spec.IngressOptions.AllowedHostnames)) } @@ -104,7 +113,17 @@ func (r *hostnames) validate( var hostnameNotValidErr *caperrors.IngressHostnameNotValidError if errors.As(err, &hostnameNotValidErr) { - recorder.Eventf(ingress.GetClientObject(), tnt, corev1.EventTypeWarning, evt.ReasonIngressHostnameNotValid, evt.ActionValidationDenied, "Ingress %s/%s hostname is not valid", ingress.Namespace(), ingress.Name()) + recorder.LabeledEvent( + ingress.GetClientObject(), + corev1.EventTypeWarning, + events.ReasonIngressHostnameNotValid, + events.ActionValidationDenied, + "ingress hostname is not valid", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(err.Error()) } diff --git a/internal/webhook/ingress/validate_wildcard.go b/internal/webhook/ingress/validate_wildcard.go index 40cd3a68..88780ff8 100644 --- a/internal/webhook/ingress/validate_wildcard.go +++ b/internal/webhook/ingress/validate_wildcard.go @@ -5,16 +5,16 @@ package ingress import ( "context" + "fmt" "strings" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" indexer "github.com/projectcapsule/capsule/pkg/runtime/indexers/tenant" ) @@ -88,8 +88,17 @@ func (h *wildcard) validate( for host := range ingress.HostnamePathsPairs() { // Check if one of the host has wildcard. if strings.HasPrefix(host, "*") { - // In case of wildcard, generate an event and then return. - recorder.Eventf(ingress.GetClientObject(), &tnt, corev1.EventTypeWarning, evt.ReasonWildcardDenied, evt.ActionValidationDenied, "%s %s/%s cannot be %s", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation))) + recorder.LabeledEvent( + ingress.GetClientObject(), + corev1.EventTypeWarning, + events.ReasonWildcardDenied, + events.ActionValidationDenied, + fmt.Sprintf("%s %s/%s cannot be %s", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation))), + ). + WithRelated(&tnt). + WithTenantLabel(&tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Denyf("Wildcard denied for tenant %s", tnt.GetName()) } diff --git a/internal/webhook/namespace/mutation/assignment.go b/internal/webhook/namespace/mutation/assignment.go index a1e10d61..ae0227f7 100644 --- a/internal/webhook/namespace/mutation/assignment.go +++ b/internal/webhook/namespace/mutation/assignment.go @@ -5,10 +5,10 @@ package mutation import ( "context" + "fmt" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -18,7 +18,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "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" @@ -67,7 +67,7 @@ func (h *ownerReferenceHandler) OnCreate( tenant.AddTenantNameLabel(labels, tnt) ns.SetLabels(labels) - if err := assignToTenant(c, tnt, ns, recorder); err != nil { + if err := assignToTenant(ctx, req, c, tnt, ns, recorder); err != nil { return ad.ErroredResponse(err) } @@ -119,7 +119,7 @@ func (h *ownerReferenceHandler) OnUpdate( newNs.OwnerReferences = refs - if err := assignToTenant(c, tnt, newNs, recorder); err != nil { + if err := assignToTenant(ctx, req, c, tnt, newNs, recorder); err != nil { return ad.ErroredResponse(err) } @@ -163,6 +163,8 @@ func resolveTenantForNamespaceUpdate( } func assignToTenant( + ctx context.Context, + req admission.Request, c client.Client, tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, @@ -178,12 +180,30 @@ func assignToTenant( } if err := controllerutil.SetOwnerReference(tnt, ns, c.Scheme()); err != nil { - recorder.Eventf(ns, nil, corev1.EventTypeWarning, evt.ReasonNamespaceHijack, evt.ActionValidationDenied, "Namespace %s cannot be assigned to the desired tenant %s", ns.GetName(), tnt.GetName()) + recorder.LabeledEvent( + ns, + corev1.EventTypeWarning, + events.ReasonAdmissionFailure, + events.ActionValidationDenied, + fmt.Sprintf("namespace cannot be assigned to the desired tenant %s", tnt.GetName()), + ). + WithRequestAnnotations(req). + Emit(ctx) return err } - recorder.Eventf(ns, nil, corev1.EventTypeNormal, evt.ReasonTenantAssigned, evt.ActionMutated, "Namespace %s has been assigned to the desired tenant %s", ns.GetName(), tnt.GetName()) + recorder.LabeledEvent( + ns, + corev1.EventTypeNormal, + events.ReasonTenantAssigned, + events.ActionMutated, + fmt.Sprintf("namespace has been assigned to the desired tenant %s", tnt.GetName()), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return nil } diff --git a/internal/webhook/namespace/mutation/guard.go b/internal/webhook/namespace/mutation/guard.go index b3cdd003..6671cb03 100644 --- a/internal/webhook/namespace/mutation/guard.go +++ b/internal/webhook/namespace/mutation/guard.go @@ -7,13 +7,12 @@ import ( "context" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "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" @@ -79,20 +78,20 @@ func (h *namespacePatchGuardHandler) OnUpdate( switch { case oldTenant == nil && newTenant == nil: - return denyNamespacePatch(oldNs, recorder, "namespace is not owned by any tenant") + return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace is not owned by any tenant") case oldTenant == nil && newTenant != nil: - return denyNamespacePatch(oldNs, recorder, "namespace can not be patched into a tenant") + return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be patched into a tenant") case oldTenant != nil && newTenant == nil: - return denyNamespacePatch(oldNs, recorder, "namespace can not remove tenant ownership") + return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not remove tenant ownership") case oldTenant.GetName() != newTenant.GetName() || oldTenant.GetUID() != newTenant.GetUID(): - return denyNamespacePatch(oldNs, recorder, "namespace can not be migrated between tenants") + return denyNamespacePatch(ctx, req, oldNs, recorder, "namespace can not be migrated between tenants") } if !tenant.NamespaceIsOwned(ctx, reader, h.cfg, oldNs, oldTenant, user) { - return denyNamespacePatch(oldNs, recorder, "denied patch request for this namespace") + return denyNamespacePatch(ctx, req, oldNs, recorder, "denied patch request for this namespace") } return nil @@ -100,21 +99,22 @@ func (h *namespacePatchGuardHandler) OnUpdate( } func denyNamespacePatch( + ctx context.Context, + req admission.Request, ns *corev1.Namespace, recorder events.EventRecorder, message string, ) *admission.Response { if ns != nil { - recorder.Eventf( + recorder.LabeledEvent( ns, - nil, corev1.EventTypeWarning, - "NamespacePatch", - evt.ActionValidationDenied, - "Namespace %s can not be patched: %s", - ns.GetName(), - message, - ) + events.ReasonNamespaceHijack, + events.ActionValidationDenied, + "namespace disallows patching relevant metadata", + ). + WithRequestAnnotations(req). + Emit(ctx) } return ad.Deny(message) diff --git a/internal/webhook/namespace/mutation/handler.go b/internal/webhook/namespace/mutation/handler.go index ca2401cf..453dc848 100644 --- a/internal/webhook/namespace/mutation/handler.go +++ b/internal/webhook/namespace/mutation/handler.go @@ -8,12 +8,12 @@ import ( "encoding/json" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "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" ) diff --git a/internal/webhook/namespace/mutation/metadata.go b/internal/webhook/namespace/mutation/metadata.go index f182a17c..63fa4461 100644 --- a/internal/webhook/namespace/mutation/metadata.go +++ b/internal/webhook/namespace/mutation/metadata.go @@ -9,7 +9,6 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -18,6 +17,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "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" diff --git a/internal/webhook/namespace/validation/cordoning.go b/internal/webhook/namespace/validation/cordoning.go index 0e4e1a13..022cc69f 100644 --- a/internal/webhook/namespace/validation/cordoning.go +++ b/internal/webhook/namespace/validation/cordoning.go @@ -7,14 +7,13 @@ import ( "context" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/users" ) @@ -37,13 +36,7 @@ func (h *cordoningHandler) OnCreate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - if tnt.Spec.Cordoned && user.IsCapsule() { - recorder.Eventf(ns, nil, corev1.EventTypeWarning, evt.ReasonCordoning, evt.ActionValidationDenied, "Namespace %s cannot be attached, the current Tenant is cordoned", ns.GetName()) - - return ad.Deny("the selected Tenant is cordoned") - } - - return nil + return h.validate(ctx, req, c, user, ns, recorder, tnt) } } @@ -57,13 +50,7 @@ func (h *cordoningHandler) OnDelete( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - if tnt.Spec.Cordoned && user.IsCapsule() { - recorder.Eventf(ns, tnt, corev1.EventTypeWarning, "TenantFreezed", "Denied", "Namespace %s cannot be deleted, the current Tenant is cordoned", req.Name) - - return ad.Deny("the selected Tenant is cordoned") - } - - return nil + return h.validate(ctx, req, c, user, ns, recorder, tnt) } } @@ -78,12 +65,34 @@ func (h *cordoningHandler) OnUpdate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - if tnt.Spec.Cordoned && user.IsCapsule() { - recorder.Eventf(ns, tnt, corev1.EventTypeWarning, "TenantFreezed", "Denied", "Namespace %s cannot be updated, the current Tenant is cordoned", ns.GetName()) - - return ad.Deny("the selected Tenant is cordoned") - } - - return nil + return h.validate(ctx, req, c, user, ns, recorder, tnt) } } + +func (h *cordoningHandler) validate( + ctx context.Context, + req admission.Request, + c client.Client, + user users.AdmissionUser, + ns *corev1.Namespace, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, +) *admission.Response { + if tnt.Spec.Cordoned && user.IsCapsule() { + recorder.LabeledEvent( + ns, + corev1.EventTypeWarning, + events.ReasonCordoning, + events.ActionValidationDenied, + "namespace can not be modified because the tenant is cordoned", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + + return ad.Deny("the selected tenant is cordoned") + } + + return nil +} diff --git a/internal/webhook/namespace/validation/handler.go b/internal/webhook/namespace/validation/handler.go index ce6c2e46..1d823b21 100644 --- a/internal/webhook/namespace/validation/handler.go +++ b/internal/webhook/namespace/validation/handler.go @@ -9,14 +9,13 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/tenant" ) @@ -169,15 +168,15 @@ func (h *handler) OnUpdate( } if user.IsCapsule() && !tenant.NamespaceIsOwned(ctx, c, h.cfg, oldNs, oldTenant, user) { - recorder.Eventf( - oldNs, - nil, + recorder.LabeledEvent( + ns, corev1.EventTypeWarning, - "NamespacePatch", - evt.ActionValidationDenied, - "Namespace %s can not be patched", - oldNs.GetName(), - ) + events.ReasonNamespaceHijack, + events.ActionValidationDenied, + "namespace can not be patched", + ). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny("denied patch request for this namespace") } diff --git a/internal/webhook/namespace/validation/prefix.go b/internal/webhook/namespace/validation/prefix.go index 96d5711a..e461390b 100644 --- a/internal/webhook/namespace/validation/prefix.go +++ b/internal/webhook/namespace/validation/prefix.go @@ -5,17 +5,17 @@ package validation import ( "context" + "fmt" "strings" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/users" ) @@ -60,15 +60,17 @@ func (h *prefixHandler) OnCreate( expectedPrefix := tnt.GetName() + "-" if !strings.HasPrefix(ns.GetName(), expectedPrefix) { - recorder.Eventf( + recorder.LabeledEvent( ns, - nil, corev1.EventTypeWarning, - evt.ReasonInvalidTenantPrefix, - evt.ActionValidationDenied, - "Namespace %s does not match the expected prefix for the current Tenant", - ns.GetName(), - ) + events.ReasonNamespaceHijack, + events.ActionValidationDenied, + fmt.Sprintf("namespace does not match the expected prefix for the elected tenant (%s)", expectedPrefix), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Denyf( "The namespace doesn't match the tenant prefix, expected prefix %q", diff --git a/internal/webhook/namespace/validation/quota.go b/internal/webhook/namespace/validation/quota.go index f3323cb0..452e5f77 100644 --- a/internal/webhook/namespace/validation/quota.go +++ b/internal/webhook/namespace/validation/quota.go @@ -8,14 +8,13 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/users" ) @@ -36,7 +35,7 @@ func (h *quotaHandler) OnCreate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.handle(ctx, reader, recorder, ns, tnt) + return h.handle(ctx, req, reader, recorder, ns, tnt) } } @@ -65,12 +64,13 @@ func (h *quotaHandler) OnUpdate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.handle(ctx, reader, recorder, ns, tnt) + return h.handle(ctx, req, reader, recorder, ns, tnt) } } func (h *quotaHandler) handle( ctx context.Context, + req admission.Request, c client.Reader, recorder events.EventRecorder, ns *corev1.Namespace, @@ -85,7 +85,17 @@ func (h *quotaHandler) handle( return nil } - recorder.Eventf(ns, nil, corev1.EventTypeWarning, evt.ReasonOverprovision, evt.ActionValidationDenied, "Namespace %s cannot be attached, quota exceeded for the current Tenant", ns.GetName()) + recorder.LabeledEvent( + ns, + corev1.EventTypeWarning, + events.ReasonOverprovision, + events.ActionValidationDenied, + "namespace cannot be attached, quota exceeded for the elected tenant", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewNamespaceQuotaExceededError().Error()) } diff --git a/internal/webhook/namespace/validation/required_metadata.go b/internal/webhook/namespace/validation/required_metadata.go index cac9b715..15779099 100644 --- a/internal/webhook/namespace/validation/required_metadata.go +++ b/internal/webhook/namespace/validation/required_metadata.go @@ -8,12 +8,12 @@ import ( "regexp" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/users" ) diff --git a/internal/webhook/namespace/validation/user_metadata.go b/internal/webhook/namespace/validation/user_metadata.go index 0357079e..167cd8e4 100644 --- a/internal/webhook/namespace/validation/user_metadata.go +++ b/internal/webhook/namespace/validation/user_metadata.go @@ -9,14 +9,13 @@ import ( "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "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/pkg/api" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "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" @@ -44,7 +43,16 @@ func (h *userMetadataHandler) OnCreate( return ad.ErroredResponse(err) } - if response := validateUserMetadata(ns, labels, annotations, tnt.Spec.NamespaceOptions, recorder); response != nil { + if response := validateUserMetadata( + ctx, + req, + tnt, + ns, + labels, + annotations, + tnt.Spec.NamespaceOptions, + recorder, + ); response != nil { return response } } @@ -67,17 +75,37 @@ func (h *userMetadataHandler) OnUpdate( if len(tnt.Spec.NodeSelector) > 0 { v, ok := newNs.GetAnnotations()["scheduler.alpha.kubernetes.io/node-selector"] if !ok { - msg := "the node-selector annotation is enforced, cannot be removed" + msg := "the annotation scheduler.alpha.kubernetes.io/node-selector is enforced via tenant, cannot be removed" - recorder.Eventf(oldNs, oldNs, corev1.EventTypeWarning, "ForbiddenNodeSelectorDeletion", "Denied", msg) + recorder.LabeledEvent( + oldNs, + corev1.EventTypeWarning, + events.ReasonForbiddenNodeSelectorUpdate, + events.ActionValidationDenied, + msg, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(msg) } if v != oldNs.GetAnnotations()["scheduler.alpha.kubernetes.io/node-selector"] { - msg := "the node-selector annotation is enforced, cannot be updated" + msg := "the annotation scheduler.alpha.kubernetes.io/node-selector is enforced via tenant, cannot be updated" - recorder.Eventf(oldNs, oldNs, corev1.EventTypeWarning, "ForbiddenNodeSelectorUpdate", "Denied", msg) + recorder.LabeledEvent( + oldNs, + corev1.EventTypeWarning, + events.ReasonForbiddenNodeSelectorUpdate, + events.ActionValidationDenied, + msg, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(msg) } @@ -122,7 +150,16 @@ func (h *userMetadataHandler) OnUpdate( return ad.ErroredResponse(err) } - if response := validateUserMetadata(oldNs, labels, annotations, tnt.Spec.NamespaceOptions, recorder); response != nil { + if response := validateUserMetadata( + ctx, + req, + tnt, + oldNs, + labels, + annotations, + tnt.Spec.NamespaceOptions, + recorder, + ); response != nil { return response } } @@ -146,6 +183,9 @@ func (h *userMetadataHandler) OnDelete( } func validateUserMetadata( + ctx context.Context, + req admission.Request, + tnt *capsulev1beta2.Tenant, ns *corev1.Namespace, labels map[string]string, annotations map[string]string, @@ -155,7 +195,18 @@ func validateUserMetadata( err := api.ValidateForbidden(annotations, options.ForbiddenAnnotations) if err != nil { err = errors.Wrap(err, "namespace annotations validation failed") - recorder.Eventf(ns, ns, corev1.EventTypeWarning, evt.ReasonForbiddenAnnotation, evt.ActionValidationDenied, err.Error()) + + recorder.LabeledEvent( + ns, + corev1.EventTypeWarning, + events.ReasonForbiddenAnnotation, + events.ActionValidationDenied, + err.Error(), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(err.Error()) } @@ -163,7 +214,18 @@ func validateUserMetadata( err = api.ValidateForbidden(labels, options.ForbiddenLabels) if err != nil { err = errors.Wrap(err, "namespace labels validation failed") - recorder.Eventf(ns, ns, corev1.EventTypeWarning, evt.ReasonForbiddenLabel, evt.ActionValidationDenied, err.Error()) + + recorder.LabeledEvent( + ns, + corev1.EventTypeWarning, + events.ReasonForbiddenLabel, + events.ActionValidationDenied, + err.Error(), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(err.Error()) } diff --git a/internal/webhook/node/user_metadata.go b/internal/webhook/node/user_metadata.go index fbfe25b5..a4a90578 100644 --- a/internal/webhook/node/user_metadata.go +++ b/internal/webhook/node/user_metadata.go @@ -9,14 +9,13 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/util/version" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" caputils "github.com/projectcapsule/capsule/pkg/utils" ) @@ -61,7 +60,7 @@ func (r *userMetadataHandler) OnUpdate( decoder admission.Decoder, recorder events.EventRecorder, ) handlers.Func { - return func(_ context.Context, req admission.Request) *admission.Response { + return func(ctx context.Context, req admission.Request) *admission.Response { nodeWebhookSupported, _ := caputils.NodeWebhookSupported(r.version) if !nodeWebhookSupported { @@ -83,7 +82,15 @@ func (r *userMetadataHandler) OnUpdate( newNodeForbiddenLabels := r.getForbiddenNodeLabels(newNode) if !reflect.DeepEqual(oldNodeForbiddenLabels, newNodeForbiddenLabels) { - recorder.Eventf(newNode, nil, corev1.EventTypeWarning, evt.ReasonForbiddenLabel, evt.ActionValidationDenied, "Denied modifying forbidden labels on node") + recorder.LabeledEvent( + newNode, + corev1.EventTypeWarning, + events.ReasonForbiddenLabel, + events.ActionValidationDenied, + "denied modifying forbidden labels on node", + ). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewNodeLabelForbiddenError(r.configuration.ForbiddenUserNodeLabels()).Error()) } @@ -94,7 +101,15 @@ func (r *userMetadataHandler) OnUpdate( newNodeForbiddenAnnotations := r.getForbiddenNodeAnnotations(newNode) if !reflect.DeepEqual(oldNodeForbiddenAnnotations, newNodeForbiddenAnnotations) { - recorder.Eventf(newNode, nil, corev1.EventTypeWarning, evt.ReasonForbiddenLabel, evt.ActionValidationDenied, "Denied modifying forbidden annotations on node") + recorder.LabeledEvent( + newNode, + corev1.EventTypeWarning, + events.ReasonForbiddenAnnotation, + events.ActionValidationDenied, + "denied modifying forbidden annotations on node", + ). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewNodeAnnotationForbiddenError(r.configuration.ForbiddenUserNodeAnnotations()).Error()) } diff --git a/internal/webhook/owners/subject_validation.go b/internal/webhook/owners/subject_validation.go index 7d686280..9755ca0b 100644 --- a/internal/webhook/owners/subject_validation.go +++ b/internal/webhook/owners/subject_validation.go @@ -6,12 +6,12 @@ package owners import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/tenant" ) diff --git a/internal/webhook/pod/containerregistry_legacy.go b/internal/webhook/pod/containerregistry_legacy.go index ca610b31..45b373c1 100644 --- a/internal/webhook/pod/containerregistry_legacy.go +++ b/internal/webhook/pod/containerregistry_legacy.go @@ -5,9 +5,9 @@ package pod import ( "context" + "fmt" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -16,7 +16,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/rules" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -40,7 +40,7 @@ func (h *containerRegistryLegacyHandler) OnCreate( _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(req, pod, tnt, recorder) + return h.validate(ctx, req, pod, tnt, recorder) } } @@ -55,7 +55,7 @@ func (h *containerRegistryLegacyHandler) OnUpdate( _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(req, pod, tnt, recorder) + return h.validate(ctx, req, pod, tnt, recorder) } } @@ -74,6 +74,7 @@ func (h *containerRegistryLegacyHandler) OnDelete( } func (h *containerRegistryLegacyHandler) validate( + ctx context.Context, req admission.Request, pod *corev1.Pod, tnt *capsulev1beta2.Tenant, @@ -85,19 +86,19 @@ func (h *containerRegistryLegacyHandler) validate( } for _, container := range pod.Spec.InitContainers { - if response := h.verifyContainerRegistry(recorder, pod, req, container.Image, tnt); response != nil { + if response := h.verifyContainerRegistry(ctx, recorder, pod, req, container.Image, tnt); response != nil { return response } } for _, container := range pod.Spec.EphemeralContainers { - if response := h.verifyContainerRegistry(recorder, pod, req, container.Image, tnt); response != nil { + if response := h.verifyContainerRegistry(ctx, recorder, pod, req, container.Image, tnt); response != nil { return response } } for _, container := range pod.Spec.Containers { - if response := h.verifyContainerRegistry(recorder, pod, req, container.Image, tnt); response != nil { + if response := h.verifyContainerRegistry(ctx, recorder, pod, req, container.Image, tnt); response != nil { return response } } @@ -106,6 +107,7 @@ func (h *containerRegistryLegacyHandler) validate( } func (h *containerRegistryLegacyHandler) verifyContainerRegistry( + ctx context.Context, recorder events.EventRecorder, pod *corev1.Pod, req admission.Request, @@ -117,14 +119,17 @@ func (h *containerRegistryLegacyHandler) verifyContainerRegistry( reg := NewRegistry(image, h.configuration) if len(reg.Registry()) == 0 { - recorder.Eventf( + recorder.LabeledEvent( pod, - tnt, corev1.EventTypeWarning, - evt.ReasonMissingFQCI, - evt.ActionValidationDenied, - "Using a fully qualified container image, cannot enforce registry for the tenant %s", reg.Registry(), tnt.GetName(), - ) + events.ReasonMissingFQCI, + events.ActionValidationDenied, + fmt.Sprintf("container image %q is not fully qualified (missing registry), cannot enforce tenant registry rules", image), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) //nolint:staticcheck return ad.Deny(caperrors.NewContainerRegistryForbidden(image, *tnt.Spec.ContainerRegistries).Error()) @@ -137,14 +142,17 @@ func (h *containerRegistryLegacyHandler) verifyContainerRegistry( matched = tnt.Spec.ContainerRegistries.RegexMatch(reg.Registry()) if !valid && !matched { - recorder.Eventf( + recorder.LabeledEvent( pod, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenContainerRegistry, - evt.ActionValidationDenied, - "Using a container hosted on registry %s that is forbidden for the tenant %s", reg.Registry(), tnt.GetName(), - ) + events.ReasonForbiddenContainerRegistry, + events.ActionValidationDenied, + fmt.Sprintf("using a container hosted on registry %s that is forbidden for the tenant", reg.Registry()), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) //nolint:staticcheck return ad.Deny(caperrors.NewContainerRegistryForbidden(reg.FQCI(), *tnt.Spec.ContainerRegistries).Error()) diff --git a/internal/webhook/pod/imagepullpolicy.go b/internal/webhook/pod/imagepullpolicy.go index b34a3acb..6ffdd626 100644 --- a/internal/webhook/pod/imagepullpolicy.go +++ b/internal/webhook/pod/imagepullpolicy.go @@ -5,9 +5,9 @@ package pod import ( "context" + "fmt" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -15,7 +15,7 @@ import ( caperrors "github.com/projectcapsule/capsule/pkg/api/errors" "github.com/projectcapsule/capsule/pkg/api/rules" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -35,7 +35,7 @@ func (h *imagePullPolicy) OnCreate( _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(req, pod, tnt, recorder) + return h.validate(ctx, req, pod, tnt, recorder) } } @@ -50,7 +50,7 @@ func (h *imagePullPolicy) OnUpdate( _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(req, pod, tnt, recorder) + return h.validate(ctx, req, pod, tnt, recorder) } } @@ -69,6 +69,7 @@ func (h *imagePullPolicy) OnDelete( } func (h *imagePullPolicy) validate( + ctx context.Context, req admission.Request, pod *corev1.Pod, tnt *capsulev1beta2.Tenant, @@ -80,19 +81,19 @@ func (h *imagePullPolicy) validate( } for _, container := range pod.Spec.InitContainers { - if response := h.verifyPullPolicy(recorder, pod, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil { + if response := h.verifyPullPolicy(ctx, recorder, pod, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil { return response } } for _, container := range pod.Spec.EphemeralContainers { - if response := h.verifyPullPolicy(recorder, pod, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil { + if response := h.verifyPullPolicy(ctx, recorder, pod, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil { return response } } for _, container := range pod.Spec.Containers { - if response := h.verifyPullPolicy(recorder, pod, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil { + if response := h.verifyPullPolicy(ctx, recorder, pod, req, policy, string(container.ImagePullPolicy), container.Name, tnt); response != nil { return response } } @@ -101,6 +102,7 @@ func (h *imagePullPolicy) validate( } func (h *imagePullPolicy) verifyPullPolicy( + ctx context.Context, recorder events.EventRecorder, pod *corev1.Pod, req admission.Request, @@ -110,14 +112,17 @@ func (h *imagePullPolicy) verifyPullPolicy( tnt *capsulev1beta2.Tenant, ) *admission.Response { if !policy.IsPolicySupported(usedPullPolicy) { - recorder.Eventf( + recorder.LabeledEvent( pod, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenPullPolicy, - evt.ActionValidationDenied, - "PullPolicy %s is forbidden for the tenant %s", usedPullPolicy, tnt.GetName(), - ) + events.ReasonForbiddenPullPolicy, + events.ActionValidationDenied, + fmt.Sprintf("using pullpolicy %s is forbidden for the tenant", usedPullPolicy), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewImagePullPolicyForbidden(usedPullPolicy, container, policy.AllowedPullPolicies()).Error()) } diff --git a/internal/webhook/pod/priorityclass.go b/internal/webhook/pod/priorityclass.go index f291c912..bda1440a 100644 --- a/internal/webhook/pod/priorityclass.go +++ b/internal/webhook/pod/priorityclass.go @@ -5,10 +5,10 @@ package pod import ( "context" + "fmt" "net/http" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -17,7 +17,7 @@ import ( caperrors "github.com/projectcapsule/capsule/pkg/api/errors" "github.com/projectcapsule/capsule/pkg/api/rules" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -74,14 +74,17 @@ func (h *priorityClass) OnCreate( case allowed.Match(priorityClassName) || selector: return nil default: - recorder.Eventf( + recorder.LabeledEvent( pod, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenPriorityClass, - evt.ActionValidationDenied, - "Using Priority Class %s is forbidden for the tenant %s", priorityClassName, tnt.GetName(), - ) + events.ReasonForbiddenPriorityClass, + events.ActionValidationDenied, + fmt.Sprintf("using priorityclass %s is forbidden for the tenant", priorityClassName), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewPodPriorityClassForbidden(priorityClassName, *allowed).Error()) } diff --git a/internal/webhook/pod/qos.go b/internal/webhook/pod/qos.go deleted file mode 100644 index 5ed2a148..00000000 --- a/internal/webhook/pod/qos.go +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package pod - -import ( - "context" - "fmt" - "net/http" - "slices" - - corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" - "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/pkg/api/rules" - ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" - "github.com/projectcapsule/capsule/pkg/runtime/handlers" - "github.com/projectcapsule/capsule/pkg/runtime/workloads" -) - -type qosHandler struct { - configuration configuration.Configuration -} - -func QoSClass(configuration configuration.Configuration) handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod] { - return &qosHandler{ - configuration: configuration, - } -} - -func (h *qosHandler) OnCreate( - _ client.Client, - _ client.Reader, - pod *corev1.Pod, - _ admission.Decoder, - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - ruleBlocks []*rules.NamespaceRuleBodyNamespace, -) handlers.Func { - return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(req, pod, tnt, recorder, ruleBlocks) - } -} - -func (h *qosHandler) OnUpdate( - _ client.Client, - _ client.Reader, - _ *corev1.Pod, - pod *corev1.Pod, - _ admission.Decoder, - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - ruleBlocks []*rules.NamespaceRuleBodyNamespace, -) handlers.Func { - return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(req, pod, tnt, recorder, ruleBlocks) - } -} - -func (h *qosHandler) OnDelete( - client.Client, - client.Reader, - *corev1.Pod, - admission.Decoder, - events.EventRecorder, - *capsulev1beta2.Tenant, - []*rules.NamespaceRuleBodyNamespace, -) handlers.Func { - return func(context.Context, admission.Request) *admission.Response { - return nil - } -} - -func (h *qosHandler) validate( - req admission.Request, - pod *corev1.Pod, - tnt *capsulev1beta2.Tenant, - recorder events.EventRecorder, - ruleBlocks []*rules.NamespaceRuleBodyNamespace, -) *admission.Response { - if pod == nil { - resp := admission.Errored(http.StatusInternalServerError, fmt.Errorf("pod is nil")) - - return &resp - } - - if len(ruleBlocks) == 0 { - return nil - } - - qosClass := workloads.GetPodQoSClass(pod) - - evaluation, err := evaluateQoSClass(ruleBlocks, qosClass) - if err != nil { - resp := admission.Errored(http.StatusInternalServerError, err) - - return &resp - } - - if evaluation == nil { - return nil - } - - warnings := make([]string, 0, len(evaluation.Audits)) - - for _, audit := range evaluation.Audits { - msg := fmt.Sprintf( - "pod %q uses QoS class %q and matched audit QoS rule", - pod.Name, - qosClass, - ) - - h.auditWithEvent(recorder, tnt, pod, msg) - warnings = append(warnings, msg) - - _ = audit - } - - if evaluation.Decision == nil { - if len(warnings) > 0 { - resp := admission.Allowed("QoS class audited") - resp.Warnings = append(resp.Warnings, warnings...) - - return &resp - } - - return nil - } - - switch evaluation.Decision.Action { - case rules.ActionTypeAllow: - if len(warnings) > 0 { - resp := admission.Allowed("QoS class allowed with warnings") - resp.Warnings = append(resp.Warnings, warnings...) - - return &resp - } - - return nil - - case rules.ActionTypeDeny: - msg := fmt.Sprintf( - "pod %q uses QoS class %q which is denied by namespace rule", - pod.Name, - qosClass, - ) - - return h.denyWithEvent( - recorder, - tnt, - pod, - evt.ReasonForbiddenPodQoSClass, - msg, - ) - - case rules.ActionTypeAudit: - msg := fmt.Sprintf( - "pod %q uses QoS class %q and matched audit QoS rule", - pod.Name, - qosClass, - ) - - h.auditWithEvent(recorder, tnt, pod, msg) - - resp := admission.Allowed("QoS class audited") - resp.Warnings = append(resp.Warnings, append(warnings, msg)...) - - return &resp - - default: - resp := admission.Errored( - http.StatusInternalServerError, - fmt.Errorf("unsupported namespace rule action %q", evaluation.Decision.Action), - ) - - return &resp - } -} - -type qosDecision struct { - Action rules.ActionType - Rule *rules.NamespaceRuleBodyNamespace - Class corev1.PodQOSClass -} - -type qosEvaluation struct { - Decision *qosDecision - Audits []*qosDecision -} - -func evaluateQoSClass( - ruleBlocks []*rules.NamespaceRuleBodyNamespace, - qosClass corev1.PodQOSClass, -) (*qosEvaluation, error) { - evaluation := &qosEvaluation{} - - for _, rule := range ruleBlocks { - if rule == nil || rule.Enforce == nil { - continue - } - - if len(rule.Enforce.Workloads.QoSClasses) == 0 { - continue - } - - if !rule.Enforce.WorkloadTargetsAny( - rules.ValidateInitContainers, - rules.ValidateEphemeralContainers, - rules.ValidateContainers, - rules.ValidateVolumes, - ) { - continue - } - - if !qosClassMatches(rule.Enforce.Workloads.QoSClasses, qosClass) { - continue - } - - action := rule.Enforce.Action.OrDefault() - - decision := &qosDecision{ - Action: action, - Rule: rule, - Class: qosClass, - } - - switch action { - case rules.ActionTypeAllow, rules.ActionTypeDeny: - // Last matching allow/deny wins. - evaluation.Decision = decision - - case rules.ActionTypeAudit: - evaluation.Audits = append(evaluation.Audits, decision) - - default: - return nil, fmt.Errorf("unsupported namespace rule action %q", action) - } - } - - return evaluation, nil -} - -func qosClassMatches(classes []corev1.PodQOSClass, got corev1.PodQOSClass) bool { - return slices.Contains(classes, got) -} - -func (h *qosHandler) auditWithEvent( - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - pod *corev1.Pod, - msg string, -) { - recorder.Eventf( - pod, - tnt, - corev1.EventTypeWarning, - evt.ReasonForbiddenPodQoSClass, - evt.ActionValidationDenied, - msg, - ) -} - -func (h *qosHandler) denyWithEvent( - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - pod *corev1.Pod, - reason string, - msg string, -) *admission.Response { - recorder.Eventf( - pod, - tnt, - corev1.EventTypeWarning, - reason, - evt.ActionValidationDenied, - msg, - ) - - return ad.Deny(msg) -} diff --git a/internal/webhook/pod/registry.go b/internal/webhook/pod/registry.go deleted file mode 100644 index 1e05100d..00000000 --- a/internal/webhook/pod/registry.go +++ /dev/null @@ -1,539 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package pod - -import ( - "context" - "fmt" - "net/http" - "sort" - "strings" - - corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/log" - "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" - ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" - "github.com/projectcapsule/capsule/pkg/runtime/handlers" -) - -type registryHandler struct { - configuration configuration.Configuration - cache *cache.RegistryRuleSetCache -} - -func ContainerRegistry( - configuration configuration.Configuration, - cache *cache.RegistryRuleSetCache, -) handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod] { - return ®istryHandler{ - configuration: configuration, - cache: cache, - } -} - -func (h *registryHandler) OnCreate( - _ client.Client, - _ client.Reader, - pod *corev1.Pod, - _ admission.Decoder, - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - ruleBlocks []*rules.NamespaceRuleBodyNamespace, -) handlers.Func { - return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(ctx, req, pod, tnt, recorder, ruleBlocks) - } -} - -func (h *registryHandler) OnUpdate( - _ client.Client, - _ client.Reader, - _ *corev1.Pod, - pod *corev1.Pod, - _ admission.Decoder, - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - ruleBlocks []*rules.NamespaceRuleBodyNamespace, -) handlers.Func { - return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(ctx, req, pod, tnt, recorder, ruleBlocks) - } -} - -func (h *registryHandler) OnDelete( - client.Client, - client.Reader, - *corev1.Pod, - admission.Decoder, - events.EventRecorder, - *capsulev1beta2.Tenant, - []*rules.NamespaceRuleBodyNamespace, -) handlers.Func { - return func(context.Context, admission.Request) *admission.Response { - return nil - } -} - -func (h *registryHandler) validate( - ctx context.Context, - req admission.Request, - pod *corev1.Pod, - tnt *capsulev1beta2.Tenant, - recorder events.EventRecorder, - ruleBlocks []*rules.NamespaceRuleBodyNamespace, -) *admission.Response { - if h.cache == nil { - resp := admission.Errored( - http.StatusInternalServerError, - fmt.Errorf("registry rule set cache is nil"), - ) - - return &resp - } - - if pod == nil { - resp := admission.Errored( - http.StatusInternalServerError, - fmt.Errorf("pod is nil"), - ) - - return &resp - } - - log.FromContext(ctx).V(5).Info( - "handling pod registry rules", - "pod", pod.Name, - "namespace", pod.Namespace, - "rules", len(ruleBlocks), - ) - - if len(ruleBlocks) == 0 { - return nil - } - - warnings := make([]string, 0) - - if resp := h.validateContainers( - recorder, - tnt, - pod, - ruleBlocks, - &warnings, - ); resp != nil { - return resp - } - - if resp := h.validateVolumes( - recorder, - tnt, - pod, - ruleBlocks, - &warnings, - ); resp != nil { - return resp - } - - if len(warnings) > 0 { - resp := admission.Allowed("registry rules audited") - resp.Warnings = append(resp.Warnings, warnings...) - - return &resp - } - - return nil -} - -func (h *registryHandler) validateContainers( - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - pod *corev1.Pod, - ruleBlocks []*rules.NamespaceRuleBodyNamespace, - warnings *[]string, -) *admission.Response { - for i := range pod.Spec.InitContainers { - c := pod.Spec.InitContainers[i] - - if resp := h.verifyOCIReference( - recorder, - tnt, - pod, - ruleBlocks, - rules.ValidateInitContainers, - c.Image, - c.ImagePullPolicy, - fmt.Sprintf("initContainers[%d]", i), - warnings, - ); resp != nil { - return resp - } - } - - for i := range pod.Spec.Containers { - c := pod.Spec.Containers[i] - - if resp := h.verifyOCIReference( - recorder, - tnt, - pod, - ruleBlocks, - rules.ValidateContainers, - c.Image, - c.ImagePullPolicy, - fmt.Sprintf("containers[%d]", i), - warnings, - ); resp != nil { - return resp - } - } - - for i := range pod.Spec.EphemeralContainers { - c := pod.Spec.EphemeralContainers[i] - - if resp := h.verifyOCIReference( - recorder, - tnt, - pod, - ruleBlocks, - rules.ValidateEphemeralContainers, - c.Image, - c.ImagePullPolicy, - fmt.Sprintf("ephemeralContainers[%d]", i), - warnings, - ); resp != nil { - return resp - } - } - - return nil -} - -func (h *registryHandler) validateVolumes( - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - pod *corev1.Pod, - ruleBlocks []*rules.NamespaceRuleBodyNamespace, - warnings *[]string, -) *admission.Response { - for i := range pod.Spec.Volumes { - v := pod.Spec.Volumes[i] - if v.Image == nil { - continue - } - - if resp := h.verifyOCIReference( - recorder, - tnt, - pod, - ruleBlocks, - rules.ValidateVolumes, - v.Image.Reference, - v.Image.PullPolicy, - fmt.Sprintf("volumes[%d](%s)", i, v.Name), - warnings, - ); resp != nil { - return resp - } - } - - return nil -} - -func (h *registryHandler) verifyOCIReference( - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - pod *corev1.Pod, - ruleBlocks []*rules.NamespaceRuleBodyNamespace, - target rules.WorkloadValidationTarget, - reference string, - pullPolicy corev1.PullPolicy, - where string, - warnings *[]string, -) *admission.Response { - ref := strings.TrimSpace(reference) - if ref == "" { - return h.denyWithEvent( - recorder, - tnt, - pod, - evt.ReasonForbiddenContainerRegistry, - fmt.Sprintf("%s has empty reference", where), - ) - } - - evaluation, err := h.evaluateOCIReference(ruleBlocks, target, ref) - if err != nil { - resp := admission.Errored(http.StatusInternalServerError, err) - - return &resp - } - - if evaluation == nil { - return nil - } - - for _, audit := range evaluation.Audits { - msg := fmt.Sprintf( - "%s reference %q matched audit registry rule %q", - where, - ref, - audit.Matched.Expression.Expression, - ) - - h.auditWithEvent(recorder, tnt, pod, msg) - - if warnings != nil { - *warnings = append(*warnings, msg) - } - } - - if evaluation.Decision == nil { - return nil - } - - switch evaluation.Decision.Action { - case rules.ActionTypeAllow: - if resp := h.validateAllowedPullPolicy( - recorder, - tnt, - pod, - evaluation.Decision.Matched, - ref, - pullPolicy, - where, - ); resp != nil { - return resp - } - - return nil - - case rules.ActionTypeDeny: - msg := fmt.Sprintf( - "%s reference %q is denied by registry rule %q", - where, - ref, - evaluation.Decision.Matched.Expression.Expression, - ) - - return h.denyWithEvent( - recorder, - tnt, - pod, - evt.ReasonForbiddenContainerRegistry, - msg, - ) - - case rules.ActionTypeAudit: - msg := fmt.Sprintf( - "%s reference %q matched audit registry rule %q", - where, - ref, - evaluation.Decision.Matched.Expression.Expression, - ) - - h.auditWithEvent(recorder, tnt, pod, msg) - - if warnings != nil { - *warnings = append(*warnings, msg) - } - - return nil - - default: - resp := admission.Errored( - http.StatusInternalServerError, - fmt.Errorf("unsupported namespace rule action %q", evaluation.Decision.Action), - ) - - return &resp - } -} - -type registryDecision struct { - rules.RuleDecision - - Matched *cache.CompiledRule -} - -type registryEvaluation struct { - Decision *registryDecision - Audits []*registryDecision -} - -func (h *registryHandler) evaluateOCIReference( - ruleBlocks []*rules.NamespaceRuleBodyNamespace, - target rules.WorkloadValidationTarget, - ref string, -) (*registryEvaluation, error) { - evaluation := ®istryEvaluation{} - - for _, rule := range ruleBlocks { - if rule == nil || rule.Enforce == nil { - continue - } - - if len(rule.Enforce.Workloads.Registries) == 0 { - continue - } - - if !rule.Enforce.WorkloadTargetsAny(target) { - continue - } - - rs, _, err := h.cache.GetOrBuild(rule.Enforce.Workloads.Registries) - if err != nil { - return nil, err - } - - if rs == nil { - continue - } - - // Important: - // Match by image reference only. Pull policy is validated after the - // final allow decision has been selected. - matched, err := h.cache.MatchReference(rs, ref) - if err != nil { - return nil, err - } - - if matched == nil { - continue - } - - action := rule.Enforce.Action.OrDefault() - - decision := ®istryDecision{ - RuleDecision: rules.RuleDecision{ - Action: action, - Rule: rule, - }, - Matched: matched, - } - - switch action { - case rules.ActionTypeAllow, rules.ActionTypeDeny: - // Last matching allow/deny wins. - evaluation.Decision = decision - - case rules.ActionTypeAudit: - evaluation.Audits = append(evaluation.Audits, decision) - - default: - return nil, fmt.Errorf("unsupported namespace rule action %q", action) - } - } - - return evaluation, nil -} - -func (h *registryHandler) validateAllowedPullPolicy( - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - pod *corev1.Pod, - matched *cache.CompiledRule, - ref string, - pullPolicy corev1.PullPolicy, - where string, -) *admission.Response { - if matched == nil || len(matched.AllowedPolicy) == 0 { - return nil - } - - allowed := formatAllowedPullPolicies(matched.AllowedPolicy) - - if pullPolicy == "" { - msg := fmt.Sprintf( - "%s reference %q must explicitly set pullPolicy (allowed: %s)", - where, - ref, - allowed, - ) - - return h.denyWithEvent( - recorder, - tnt, - pod, - evt.ReasonForbiddenPullPolicy, - msg, - ) - } - - if _, ok := matched.AllowedPolicy[pullPolicy]; !ok { - msg := fmt.Sprintf( - "%s reference %q uses pullPolicy=%s which is not allowed (allowed: %s)", - where, - ref, - pullPolicy, - allowed, - ) - - return h.denyWithEvent( - recorder, - tnt, - pod, - evt.ReasonForbiddenPullPolicy, - msg, - ) - } - - return nil -} - -func (h *registryHandler) auditWithEvent( - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - pod *corev1.Pod, - msg string, -) { - recorder.Eventf( - pod, - tnt, - corev1.EventTypeWarning, - evt.ReasonForbiddenContainerRegistry, - evt.ActionValidationDenied, - msg, - ) -} - -func (h *registryHandler) denyWithEvent( - recorder events.EventRecorder, - tnt *capsulev1beta2.Tenant, - pod *corev1.Pod, - reason string, - msg string, -) *admission.Response { - recorder.Eventf( - pod, - tnt, - corev1.EventTypeWarning, - reason, - evt.ActionValidationDenied, - msg, - ) - - return ad.Deny(msg) -} - -func formatAllowedPullPolicies(policies map[corev1.PullPolicy]struct{}) string { - if len(policies) == 0 { - return "" - } - - out := make([]string, 0, len(policies)) - for p := range policies { - out = append(out, string(p)) - } - - sort.Strings(out) - - return strings.Join(out, ", ") -} diff --git a/internal/webhook/pod/runtimeclass.go b/internal/webhook/pod/runtimeclass.go index 01d6a85e..3dec2a49 100644 --- a/internal/webhook/pod/runtimeclass.go +++ b/internal/webhook/pod/runtimeclass.go @@ -5,12 +5,12 @@ package pod import ( "context" + "fmt" "net/http" corev1 "k8s.io/api/core/v1" nodev1 "k8s.io/api/node/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -18,7 +18,7 @@ import ( caperrors "github.com/projectcapsule/capsule/pkg/api/errors" "github.com/projectcapsule/capsule/pkg/api/rules" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -114,14 +114,17 @@ func (h *runtimeClass) validate( // Delegating mutating webhook to specify a default RuntimeClass return nil case !allowed.MatchSelectByName(class): - recorder.Eventf( + recorder.LabeledEvent( pod, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenRuntimeClass, - evt.ActionValidationDenied, - "Using Runtime Class %s is forbidden for the tenant %s", runtimeClassName, tnt.GetName(), - ) + events.ReasonForbiddenRuntimeClass, + events.ActionValidationDenied, + fmt.Sprintf("using runtimeclass %s is forbidden for the tenant", runtimeClassName), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewPodRuntimeClassForbidden(runtimeClassName, *allowed).Error()) default: diff --git a/internal/webhook/pvc/pvc_mutating_volume.go b/internal/webhook/pvc/pvc_mutating_volume.go index 7b9a0ee0..0b203004 100644 --- a/internal/webhook/pvc/pvc_mutating_volume.go +++ b/internal/webhook/pvc/pvc_mutating_volume.go @@ -10,12 +10,12 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/events" "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/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/pvc/pvc_validating_class.go b/internal/webhook/pvc/pvc_validating_class.go index 30006406..58e42bc5 100644 --- a/internal/webhook/pvc/pvc_validating_class.go +++ b/internal/webhook/pvc/pvc_validating_class.go @@ -9,7 +9,6 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -17,7 +16,7 @@ import ( "github.com/projectcapsule/capsule/internal/webhook/utils" "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -45,14 +44,17 @@ func (h *persistentVolumeValidatingClass) OnCreate( storageClass := pvc.Spec.StorageClassName if storageClass == nil { - recorder.Eventf( + recorder.LabeledEvent( pvc, - tnt, corev1.EventTypeWarning, - evt.ReasonMissingStorageClass, - evt.ActionValidationDenied, - "Requires a StorageClass", - ) + events.ReasonMissingStorageClass, + events.ActionValidationDenied, + "persistentvolume must provide a storageclass", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(errors.NewStorageClassNotValid(*tnt.Spec.StorageClasses).Error()) } @@ -84,8 +86,8 @@ func (h *persistentVolumeValidatingClass) OnCreate( pvc, tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenStorageClass, - evt.ActionValidationDenied, + events.ReasonForbiddenStorageClass, + events.ActionValidationDenied, "StorageClass %s is forbidden for the Tenant %s", *storageClass, tnt.GetName()) return ad.Deny(errors.NewStorageClassForbidden(*pvc.Spec.StorageClassName, *tnt.Spec.StorageClasses).Error()) diff --git a/internal/webhook/pvc/pvc_validating_volume.go b/internal/webhook/pvc/pvc_validating_volume.go index 9fad9b16..8c95c0df 100644 --- a/internal/webhook/pvc/pvc_validating_volume.go +++ b/internal/webhook/pvc/pvc_validating_volume.go @@ -11,7 +11,6 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -19,7 +18,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/errors" "github.com/projectcapsule/capsule/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -142,16 +141,16 @@ func validatePVCVolumeName( } if pv.GetLabels() == nil { - return ad.Deny(errors.NewMissingTenantPVLabelsError(pv.GetName(), evt.ActionValidationDenied).Error()) + return ad.Deny(errors.NewMissingTenantPVLabelsError(pv.GetName(), events.ActionValidationDenied).Error()) } value, ok := pv.GetLabels()[meta.TenantLabel] if !ok { - return ad.Deny(errors.NewMissingTenantPVLabelsError(pv.GetName(), evt.ActionValidationDenied).Error()) + return ad.Deny(errors.NewMissingTenantPVLabelsError(pv.GetName(), events.ActionValidationDenied).Error()) } if value != tnt.Name { - return ad.Deny(errors.NewCrossTenantPVMountError(pv.GetName(), evt.ActionValidationDenied).Error()) + return ad.Deny(errors.NewCrossTenantPVMountError(pv.GetName(), events.ActionValidationDenied).Error()) } return nil diff --git a/internal/webhook/resourcepool/claim_mutating.go b/internal/webhook/resourcepool/claim_mutating.go index f39051b1..c0851baa 100644 --- a/internal/webhook/resourcepool/claim_mutating.go +++ b/internal/webhook/resourcepool/claim_mutating.go @@ -10,13 +10,13 @@ import ( "net/http" "github.com/go-logr/logr" - "k8s.io/client-go/tools/events" "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/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/resourcepool/claim_validating.go b/internal/webhook/resourcepool/claim_validating.go index fab4aafa..c5923946 100644 --- a/internal/webhook/resourcepool/claim_validating.go +++ b/internal/webhook/resourcepool/claim_validating.go @@ -9,12 +9,12 @@ import ( "reflect" "github.com/go-logr/logr" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/resourcepool/pool_mutating.go b/internal/webhook/resourcepool/pool_mutating.go index 471c00c8..5878e5bb 100644 --- a/internal/webhook/resourcepool/pool_mutating.go +++ b/internal/webhook/resourcepool/pool_mutating.go @@ -12,12 +12,12 @@ import ( "github.com/go-logr/logr" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/resourcepool/pool_validation.go b/internal/webhook/resourcepool/pool_validation.go index 2731272b..f11dc45c 100644 --- a/internal/webhook/resourcepool/pool_validation.go +++ b/internal/webhook/resourcepool/pool_validation.go @@ -9,12 +9,12 @@ import ( "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/api/equality" "k8s.io/apimachinery/pkg/api/resource" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/router.go b/internal/webhook/router.go index 094aba52..61937bad 100644 --- a/internal/webhook/router.go +++ b/internal/webhook/router.go @@ -7,18 +7,16 @@ import ( "context" admissionv1 "k8s.io/api/admission/v1" - "k8s.io/client-go/tools/events" controllerruntime "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) -func Register(manager controllerruntime.Manager, webhookList ...handlers.Webhook) error { - recorder := manager.GetEventRecorder("admission") - +func Register(manager controllerruntime.Manager, recorder events.EventRecorder, webhookList ...handlers.Webhook) error { server := manager.GetWebhookServer() for _, wh := range webhookList { diff --git a/internal/webhook/rules/pods/validation/factory.go b/internal/webhook/rules/pods/validation/factory.go new file mode 100644 index 00000000..c4559ae2 --- /dev/null +++ b/internal/webhook/rules/pods/validation/factory.go @@ -0,0 +1,189 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "context" + "errors" + + corev1 "k8s.io/api/core/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" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "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 podRuleSet[R any] = ruleengine.Set[R, *corev1.Pod] + +func evaluatePodRules[R any]( + pod *corev1.Pod, + enforceBodies []*apirules.NamespaceRuleEnforceBody, + set podRuleSet[R], +) (*ruleengine.Evaluation, error) { + if pod == nil || len(enforceBodies) == 0 { + return nil, nil + } + + return ruleengine.EvaluateEnforce( + pod, + enforceBodies, + set, + ) +} + +type podRuleValidator func( + *corev1.Pod, + []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) + +type podRules struct { + rules []podRuleValidator + regexCache *cache.RegexCache + registryCache *cache.RegistryRuleSetCache +} + +func PodRules( + regexCache *cache.RegexCache, + registryCache *cache.RegistryRuleSetCache, +) handlers.TypedHandlerWithTenantWithRuleset[*corev1.Pod] { + if regexCache == nil { + regexCache = cache.NewRegexCache() + } + + if registryCache == nil { + registryCache = cache.NewRegistryRuleSetCache(regexCache) + } + + h := &podRules{ + regexCache: regexCache, + registryCache: registryCache, + } + + h.rules = []podRuleValidator{ + h.validateSchedulers, + h.validateQoSClasses, + h.validateRegistries, + } + + return h +} + +func (h *podRules) OnCreate( + _ client.Client, + _ client.Reader, + pod *corev1.Pod, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies) + + if err := h.validatePodRules(ctx, req, pod, tnt, recorder, enforceBodies); err != nil { + return ad.Deny(err.Error()) + } + + return nil + } +} + +func (h *podRules) OnUpdate( + _ client.Client, + _ client.Reader, + _ *corev1.Pod, + pod *corev1.Pod, + _ admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + bodies []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(ctx context.Context, req admission.Request) *admission.Response { + enforceBodies := ruleengine.EnforceBodiesFromNamespaceRules(bodies) + + if err := h.validatePodRules(ctx, req, pod, tnt, recorder, enforceBodies); err != nil { + return ad.Deny(err.Error()) + } + + return nil + } +} + +func (h *podRules) OnDelete( + _ client.Client, + _ client.Reader, + _ *corev1.Pod, + _ admission.Decoder, + _ events.EventRecorder, + _ *capsulev1beta2.Tenant, + _ []*apirules.NamespaceRuleBodyNamespace, +) handlers.Func { + return func(context.Context, admission.Request) *admission.Response { + return nil + } +} + +func (h *podRules) validatePodRules( + ctx context.Context, + req admission.Request, + pod *corev1.Pod, + tnt *capsulev1beta2.Tenant, + recorder events.EventRecorder, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) error { + for _, evaluate := range h.rules { + evaluation, err := evaluate(pod, enforceBodies) + if err != nil { + return err + } + + if evaluation == nil { + continue + } + + // Audit is observational only. It must always be emitted when matched, + // but it must never influence allow/deny decisions. + for _, audit := range evaluation.Audits { + recorder.LabeledEvent( + pod, + corev1.EventTypeNormal, + events.ReasonNamespaceRuleAudit, + events.ActionRuleAudit, + audit.Message, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + } + + if err := evaluation.BlockingError(); err != nil { + var decisionErr *ruleengine.DecisionError + + if errors.As(err, &decisionErr) && decisionErr.Decision != nil { + recorder.LabeledEvent( + pod, + corev1.EventTypeWarning, + decisionErr.Decision.EventReason, + events.ActionValidationDenied, + decisionErr.Decision.Message, + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) + } + + return err + } + } + + return nil +} diff --git a/internal/webhook/rules/pods/validation/qos.go b/internal/webhook/rules/pods/validation/qos.go new file mode 100644 index 00000000..962747d7 --- /dev/null +++ b/internal/webhook/rules/pods/validation/qos.go @@ -0,0 +1,47 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + corev1 "k8s.io/api/core/v1" + + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/workloads" +) + +func (h *podRules) validateQoSClasses( + pod *corev1.Pod, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + return evaluatePodRules[corev1.PodQOSClass]( + pod, + enforceBodies, + podRuleSet[corev1.PodQOSClass]{ + Name: "QoS class", + EventReason: events.ReasonForbiddenPodQoSClass, + Values: func(pod *corev1.Pod) []ruleengine.Value { + return []ruleengine.Value{ + { + Value: string(workloads.GetPodQoSClass(pod)), + Path: "status.qosClass", + }, + } + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []corev1.PodQOSClass { + if enforce == nil { + return nil + } + + return enforce.Workloads.QoSClasses + }, + Matches: func(match corev1.PodQOSClass, value ruleengine.Value) (ruleengine.Match, error) { + return ruleengine.Match{ + Matched: string(match) == value.Value, + }, nil + }, + }, + ) +} diff --git a/internal/webhook/rules/pods/validation/registry.go b/internal/webhook/rules/pods/validation/registry.go new file mode 100644 index 00000000..55134de9 --- /dev/null +++ b/internal/webhook/rules/pods/validation/registry.go @@ -0,0 +1,365 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "fmt" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/internal/cache" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +type registryReference struct { + Target apirules.WorkloadValidationTarget + Reference string + PullPolicy corev1.PullPolicy + Path string +} + +type registryRuleSet struct { + Registries []apirules.OCIRegistry +} + +func (h *podRules) validateRegistries( + pod *corev1.Pod, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + if h.registryCache == nil { + return nil, fmt.Errorf("registry rule set cache is nil") + } + + if pod == nil || len(enforceBodies) == 0 { + return nil, nil + } + + out := &ruleengine.Evaluation{} + + for _, ref := range registryReferencesFromPod(pod) { + if strings.TrimSpace(ref.Reference) == "" { + out.Blocking = &ruleengine.Decision{ + SetName: "registry", + EventReason: events.ReasonForbiddenContainerRegistry, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: ref.Reference, + Path: ref.Path, + }, + Message: fmt.Sprintf("%s has empty reference", ref.Path), + } + + return out, nil + } + + evaluation, err := h.evaluateRegistryReference(ref, enforceBodies) + if err != nil { + return out, err + } + + out.Append(evaluation) + + if evaluation == nil { + continue + } + + //nolint:nilerr + if err := evaluation.BlockingError(); err != nil { + return out, nil + } + + // Pull policy constraints are enforced only after the final registry + // decision is an explicit allow. Audit rules do not influence this. + if evaluation.Final == nil || evaluation.Final.Action != apirules.ActionTypeAllow { + continue + } + + matched, _ := evaluation.Final.MatchedValue.(*cache.CompiledRule) + if blocking := registryPullPolicyDecision(ref, matched); blocking != nil { + out.Blocking = blocking + + return out, nil + } + } + + return out, nil +} + +func (h *podRules) evaluateRegistryReference( + ref registryReference, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + return ruleengine.EvaluateEnforce[registryRuleSet]( + ref, + enforceBodies, + ruleengine.Set[registryRuleSet, registryReference]{ + Name: "registry", + EventReason: events.ReasonForbiddenContainerRegistry, + Values: func(ref registryReference) []ruleengine.Value { + return []ruleengine.Value{ + { + Value: strings.TrimSpace(ref.Reference), + Path: ref.Path, + }, + } + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []registryRuleSet { + if enforce == nil { + return nil + } + + if len(enforce.Workloads.Registries) == 0 { + return nil + } + + if !enforce.WorkloadTargetsAny(ref.Target) { + return nil + } + + return []registryRuleSet{ + { + Registries: enforce.Workloads.Registries, + }, + } + }, + Matches: func(set registryRuleSet, value ruleengine.Value) (ruleengine.Match, error) { + rs, _, err := h.registryCache.GetOrBuild(set.Registries) + if err != nil { + return ruleengine.Match{}, err + } + + if rs == nil { + return ruleengine.Match{}, nil + } + + // Match by OCI reference only. + // Pull policy is validated after the final allow decision wins. + matched, err := h.registryCache.MatchReference(rs, value.Value) + if err != nil { + return ruleengine.Match{}, err + } + + if matched == nil { + return ruleengine.Match{}, nil + } + + return ruleengine.Match{ + Matched: true, + MatchedValue: matched, + }, nil + }, + Message: registryDecisionMessage, + }, + ) +} + +func registryReferencesFromPod(pod *corev1.Pod) []registryReference { + if pod == nil { + return nil + } + + refs := make([]registryReference, 0, + len(pod.Spec.InitContainers)+ + len(pod.Spec.Containers)+ + len(pod.Spec.EphemeralContainers)+ + len(pod.Spec.Volumes), + ) + + for i := range pod.Spec.InitContainers { + c := pod.Spec.InitContainers[i] + + refs = append(refs, registryReference{ + Target: apirules.ValidateInitContainers, + Reference: c.Image, + PullPolicy: c.ImagePullPolicy, + Path: fmt.Sprintf("initContainers[%d]", i), + }) + } + + for i := range pod.Spec.Containers { + c := pod.Spec.Containers[i] + + refs = append(refs, registryReference{ + Target: apirules.ValidateContainers, + Reference: c.Image, + PullPolicy: c.ImagePullPolicy, + Path: fmt.Sprintf("containers[%d]", i), + }) + } + + for i := range pod.Spec.EphemeralContainers { + c := pod.Spec.EphemeralContainers[i] + + refs = append(refs, registryReference{ + Target: apirules.ValidateEphemeralContainers, + Reference: c.Image, + PullPolicy: c.ImagePullPolicy, + Path: fmt.Sprintf("ephemeralContainers[%d]", i), + }) + } + + for i := range pod.Spec.Volumes { + v := pod.Spec.Volumes[i] + if v.Image == nil { + continue + } + + refs = append(refs, registryReference{ + Target: apirules.ValidateVolumes, + Reference: v.Image.Reference, + PullPolicy: v.Image.PullPolicy, + Path: fmt.Sprintf("volumes[%d](%s)", i, v.Name), + }) + } + + return refs +} + +func registryDecisionMessage( + action apirules.ActionType, + value ruleengine.Value, + matchedValue any, +) string { + matched, _ := matchedValue.(*cache.CompiledRule) + + rule := "" + if matched != nil { + rule = registryRuleDescription(matched) + } + + switch action { + case apirules.ActionTypeAudit: + return fmt.Sprintf( + "%s reference %q matched audit registry rule %q", + value.Path, + value.Value, + rule, + ) + + case apirules.ActionTypeDeny: + return fmt.Sprintf( + "%s reference %q is denied by registry rule %q", + value.Path, + value.Value, + rule, + ) + + case apirules.ActionTypeAllow: + return fmt.Sprintf( + "%s reference %q is allowed by registry rule %q", + value.Path, + value.Value, + rule, + ) + + default: + return fmt.Sprintf( + "%s reference %q matched registry rule %q with action %q", + value.Path, + value.Value, + rule, + action, + ) + } +} + +func registryRuleDescription(matched *cache.CompiledRule) string { + if matched == nil { + return "" + } + + parts := make([]string, 0, 2) + + if len(matched.Match.Exact) > 0 { + exact := append([]string(nil), matched.Match.Exact...) + sort.Strings(exact) + + parts = append(parts, "exact="+strings.Join(exact, ",")) + } + + if matched.Match.Expression != "" { + if matched.Match.Negate { + parts = append(parts, "exp="+matched.Match.Expression+",negate=true") + } else { + parts = append(parts, "exp="+matched.Match.Expression) + } + } + + if len(parts) == 0 { + return "" + } + + return strings.Join(parts, ";") +} + +func registryPullPolicyDecision( + ref registryReference, + matched *cache.CompiledRule, +) *ruleengine.Decision { + if matched == nil || len(matched.AllowedPolicy) == 0 { + return nil + } + + allowed := formatAllowedPullPolicies(matched.AllowedPolicy) + + if ref.PullPolicy == "" { + return &ruleengine.Decision{ + SetName: "registry", + EventReason: events.ReasonForbiddenPullPolicy, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: ref.Reference, + Path: ref.Path, + }, + MatchedValue: matched, + Message: fmt.Sprintf( + "%s reference %q must explicitly set pullPolicy (allowed: %s)", + ref.Path, + ref.Reference, + allowed, + ), + } + } + + if _, ok := matched.AllowedPolicy[ref.PullPolicy]; !ok { + return &ruleengine.Decision{ + SetName: "registry", + EventReason: events.ReasonForbiddenPullPolicy, + Action: apirules.ActionTypeDeny, + Value: ruleengine.Value{ + Value: ref.Reference, + Path: ref.Path, + }, + MatchedValue: matched, + Message: fmt.Sprintf( + "%s reference %q uses pullPolicy=%s which is not allowed (allowed: %s)", + ref.Path, + ref.Reference, + ref.PullPolicy, + allowed, + ), + } + } + + return nil +} + +func formatAllowedPullPolicies(policies map[corev1.PullPolicy]struct{}) string { + if len(policies) == 0 { + return "" + } + + out := make([]string, 0, len(policies)) + for policy := range policies { + out = append(out, string(policy)) + } + + sort.Strings(out) + + return strings.Join(out, ", ") +} diff --git a/internal/webhook/rules/pods/validation/schedulers.go b/internal/webhook/rules/pods/validation/schedulers.go new file mode 100644 index 00000000..73a8f85d --- /dev/null +++ b/internal/webhook/rules/pods/validation/schedulers.go @@ -0,0 +1,54 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package validation + +import ( + "strings" + + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/pkg/api" + apirules "github.com/projectcapsule/capsule/pkg/api/rules" + ruleengine "github.com/projectcapsule/capsule/pkg/ruleengine" + "github.com/projectcapsule/capsule/pkg/runtime/events" +) + +func (h *podRules) validateSchedulers( + pod *corev1.Pod, + enforceBodies []*apirules.NamespaceRuleEnforceBody, +) (*ruleengine.Evaluation, error) { + return evaluatePodRules[api.ExpressionMatch]( + pod, + enforceBodies, + podRuleSet[api.ExpressionMatch]{ + Name: "scheduler", + EventReason: events.ReasonForbiddenPodScheduler, + Values: func(pod *corev1.Pod) []ruleengine.Value { + return []ruleengine.Value{ + { + Value: strings.TrimSpace(pod.Spec.SchedulerName), + Path: "spec.schedulerName", + }, + } + }, + Rules: func(enforce *apirules.NamespaceRuleEnforceBody) []api.ExpressionMatch { + if enforce == nil { + return nil + } + + return enforce.Workloads.Schedulers + }, + Matches: func(match api.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, + }, nil + }, + }, + ) +} diff --git a/internal/webhook/service/validating.go b/internal/webhook/service/validating.go index d38cf211..ff0f18ef 100644 --- a/internal/webhook/service/validating.go +++ b/internal/webhook/service/validating.go @@ -5,12 +5,12 @@ package service import ( "context" + "fmt" "net" "strings" "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -18,7 +18,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -37,7 +37,7 @@ func (h *validating) OnCreate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.handle(req, recorder, svc, tnt) + return h.handle(ctx, req, recorder, svc, tnt) } } @@ -51,7 +51,7 @@ func (h *validating) OnUpdate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.handle(req, recorder, svc, tnt) + return h.handle(ctx, req, recorder, svc, tnt) } } @@ -69,46 +69,56 @@ func (h *validating) OnDelete( } func (h *validating) handle( + ctx context.Context, req admission.Request, recorder events.EventRecorder, svc *corev1.Service, tnt *capsulev1beta2.Tenant, ) *admission.Response { if svc.Spec.Type == corev1.ServiceTypeNodePort && tnt.Spec.ServiceOptions != nil && tnt.Spec.ServiceOptions.AllowedServices != nil && !*tnt.Spec.ServiceOptions.AllowedServices.NodePort { - recorder.Eventf( + recorder.LabeledEvent( svc, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenNodePort, - evt.ActionValidationDenied, - "Cannot be type of NodePort for the Tenant %s", tnt.GetName(), - ) + events.ReasonForbiddenNodePort, + events.ActionValidationDenied, + "Cannot be type of nodeport for the tenant", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) - return ad.Deny(caperrors.NewExternalNameDisabledError().Error()) + return ad.Deny(caperrors.NewNodePortDisabledError().Error()) } if svc.Spec.Type == corev1.ServiceTypeExternalName && tnt.Spec.ServiceOptions != nil && tnt.Spec.ServiceOptions.AllowedServices != nil && !*tnt.Spec.ServiceOptions.AllowedServices.ExternalName { - recorder.Eventf( + recorder.LabeledEvent( svc, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenExternalName, - evt.ActionValidationDenied, - "Cannot be type of ExternalName for the Tenant %s", tnt.GetName(), - ) + events.ReasonForbiddenExternalName, + events.ActionValidationDenied, + "cannot be type of externalname for the tenant", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewExternalNameDisabledError().Error()) } if svc.Spec.Type == corev1.ServiceTypeLoadBalancer && tnt.Spec.ServiceOptions != nil && tnt.Spec.ServiceOptions.AllowedServices != nil && !*tnt.Spec.ServiceOptions.AllowedServices.LoadBalancer { - recorder.Eventf( + recorder.LabeledEvent( svc, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenLoadBalancer, - evt.ActionValidationDenied, - "Cannot be type of LoadBalancer for the Tenant %s", tnt.GetName(), - ) + events.ReasonForbiddenLoadBalancer, + events.ActionValidationDenied, + "cannot be type of loadbalancer for the tenant", + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewLoadBalancerDisabled().Error()) } @@ -118,14 +128,17 @@ func (h *validating) handle( if err != nil { err = errors.Wrap(err, "annotations validation failed") - recorder.Eventf( + recorder.LabeledEvent( svc, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenAnnotation, - evt.ActionValidationDenied, + events.ReasonForbiddenAnnotation, + events.ActionValidationDenied, err.Error(), - ) + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(err.Error()) } @@ -134,14 +147,17 @@ func (h *validating) handle( if err != nil { err = errors.Wrap(err, "labels validation failed") - recorder.Eventf( + recorder.LabeledEvent( svc, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenLabel, - evt.ActionValidationDenied, + events.ReasonForbiddenLabel, + events.ActionValidationDenied, err.Error(), - ) + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(err.Error()) } @@ -171,14 +187,17 @@ func (h *validating) handle( ip := net.ParseIP(externalIP) if !ipInCIDR(ip) { - recorder.Eventf( + recorder.LabeledEvent( svc, - tnt, corev1.EventTypeWarning, - evt.ReasonForbiddenExternalServiceIP, - evt.ActionValidationDenied, - "External IP %s is forbidden for the Tenant %s", ip.String(), tnt.GetName(), - ) + events.ReasonForbiddenExternalServiceIP, + events.ActionValidationDenied, + fmt.Sprintf("external ip %s is forbidden for the tenant", ip.String()), + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(caperrors.NewExternalServiceIPForbidden(tnt.Spec.ServiceOptions.ExternalServiceIPs.Allowed).Error()) } diff --git a/internal/webhook/serviceaccounts/owner_promotion.go b/internal/webhook/serviceaccounts/owner_promotion.go index 318e78c2..b4389973 100644 --- a/internal/webhook/serviceaccounts/owner_promotion.go +++ b/internal/webhook/serviceaccounts/owner_promotion.go @@ -8,7 +8,6 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -16,7 +15,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/users" ) @@ -39,7 +38,7 @@ func (h *ownerPromotion) OnCreate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.handle(user, recorder, sa, tnt) + return h.handle(ctx, req, user, recorder, sa, tnt) } } @@ -54,7 +53,7 @@ func (h *ownerPromotion) OnUpdate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.handle(user, recorder, sa, tnt) + return h.handle(ctx, req, user, recorder, sa, tnt) } } @@ -73,6 +72,8 @@ func (h *ownerPromotion) OnDelete( } func (h *ownerPromotion) handle( + ctx context.Context, + req admission.Request, user users.AdmissionUser, recorder events.EventRecorder, sa *corev1.ServiceAccount, @@ -98,14 +99,17 @@ func (h *ownerPromotion) handle( msg := fmt.Sprintf("%s not allowed to promote serviceaccount to tenant owner", user.Username) - recorder.Eventf( + recorder.LabeledEvent( sa, - tnt, corev1.EventTypeWarning, - evt.ReasonPromotionDenied, - evt.ActionValidationDenied, + events.ReasonPromotionDenied, + events.ActionValidationDenied, msg, - ) + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(msg) } diff --git a/internal/webhook/serviceaccounts/promotion.go b/internal/webhook/serviceaccounts/promotion.go index 99cc2b79..cdcead60 100644 --- a/internal/webhook/serviceaccounts/promotion.go +++ b/internal/webhook/serviceaccounts/promotion.go @@ -8,7 +8,6 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -16,7 +15,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/users" ) @@ -39,7 +38,7 @@ func (h *promotion) OnCreate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.handle(user, recorder, sa, tnt) + return h.handle(ctx, req, user, recorder, sa, tnt) } } @@ -54,7 +53,7 @@ func (h *promotion) OnUpdate( tnt *capsulev1beta2.Tenant, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.handle(user, recorder, sa, tnt) + return h.handle(ctx, req, user, recorder, sa, tnt) } } @@ -73,6 +72,8 @@ func (h *promotion) OnDelete( } func (h *promotion) handle( + ctx context.Context, + req admission.Request, user users.AdmissionUser, recorder events.EventRecorder, sa *corev1.ServiceAccount, @@ -94,14 +95,17 @@ func (h *promotion) handle( msg := fmt.Sprintf("%s not allowed to promote serviceaccount to tenant owner", user.Username) - recorder.Eventf( + recorder.LabeledEvent( sa, - tnt, corev1.EventTypeWarning, - evt.ReasonPromotionDenied, - evt.ActionValidationDenied, + events.ReasonPromotionDenied, + events.ActionValidationDenied, msg, - ) + ). + WithRelated(tnt). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) return ad.Deny(msg) } diff --git a/internal/webhook/tenant/mutation/metadata.go b/internal/webhook/tenant/mutation/metadata.go index b4df39e0..e0aced2d 100644 --- a/internal/webhook/tenant/mutation/metadata.go +++ b/internal/webhook/tenant/mutation/metadata.go @@ -8,13 +8,13 @@ import ( "encoding/json" "net/http" - "k8s.io/client-go/tools/events" "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/pkg/api/meta" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/containerregistry_regex.go b/internal/webhook/tenant/validation/containerregistry_regex.go index 805fb6f7..514e061f 100644 --- a/internal/webhook/tenant/validation/containerregistry_regex.go +++ b/internal/webhook/tenant/validation/containerregistry_regex.go @@ -8,12 +8,12 @@ import ( "context" "regexp" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/forbidden_annotations_regex.go b/internal/webhook/tenant/validation/forbidden_annotations_regex.go index 8c39b80d..33a71a2f 100644 --- a/internal/webhook/tenant/validation/forbidden_annotations_regex.go +++ b/internal/webhook/tenant/validation/forbidden_annotations_regex.go @@ -7,12 +7,12 @@ import ( "context" "regexp" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/freezed_emitter.go b/internal/webhook/tenant/validation/freezed_emitter.go index 69f4dd6b..9c5ccfc6 100644 --- a/internal/webhook/tenant/validation/freezed_emitter.go +++ b/internal/webhook/tenant/validation/freezed_emitter.go @@ -7,12 +7,11 @@ import ( "context" corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - evt "github.com/projectcapsule/capsule/pkg/runtime/events" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -54,12 +53,30 @@ func (h *freezedEmitterHandler) OnUpdate( decoder admission.Decoder, recorder events.EventRecorder, ) handlers.Func { - return func(_ context.Context, req admission.Request) *admission.Response { + return func(ctx context.Context, req admission.Request) *admission.Response { switch { case !old.Spec.Cordoned && tnt.Spec.Cordoned: - recorder.Eventf(tnt, nil, corev1.EventTypeNormal, evt.ReasonCordoning, evt.ActionCordoned, "Tenant has been cordoned") + recorder.LabeledEvent( + tnt, + corev1.EventTypeNormal, + events.ReasonCordoning, + events.ActionCordoned, + "Tenant has been cordoned", + ). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) case old.Spec.Cordoned && !tnt.Spec.Cordoned: - recorder.Eventf(tnt, nil, corev1.EventTypeNormal, evt.ReasonCordoning, evt.ActionUncordoned, "Tenant has been uncordoned") + recorder.LabeledEvent( + tnt, + corev1.EventTypeNormal, + events.ReasonCordoning, + events.ActionUncordoned, + "Tenant has been uncordoned", + ). + WithTenantLabel(tnt). + WithRequestAnnotations(req). + Emit(ctx) } return nil diff --git a/internal/webhook/tenant/validation/handler.go b/internal/webhook/tenant/validation/handler.go index 76c4b14a..149181a4 100644 --- a/internal/webhook/tenant/validation/handler.go +++ b/internal/webhook/tenant/validation/handler.go @@ -6,13 +6,13 @@ package validation import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/hostname_regex.go b/internal/webhook/tenant/validation/hostname_regex.go index d4fbe8ed..700830b6 100644 --- a/internal/webhook/tenant/validation/hostname_regex.go +++ b/internal/webhook/tenant/validation/hostname_regex.go @@ -8,12 +8,12 @@ import ( "context" "regexp" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/ingressclass_regex.go b/internal/webhook/tenant/validation/ingressclass_regex.go index f4e45b39..a97a934f 100644 --- a/internal/webhook/tenant/validation/ingressclass_regex.go +++ b/internal/webhook/tenant/validation/ingressclass_regex.go @@ -8,12 +8,12 @@ import ( "context" "regexp" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/name.go b/internal/webhook/tenant/validation/name.go index 30c0ee37..7fb0fc73 100644 --- a/internal/webhook/tenant/validation/name.go +++ b/internal/webhook/tenant/validation/name.go @@ -7,12 +7,12 @@ import ( "context" "regexp" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/namespace_metadata.go b/internal/webhook/tenant/validation/namespace_metadata.go index 511d5dba..c47cae82 100644 --- a/internal/webhook/tenant/validation/namespace_metadata.go +++ b/internal/webhook/tenant/validation/namespace_metadata.go @@ -9,13 +9,13 @@ import ( "strings" "k8s.io/apimachinery/pkg/util/validation" - "k8s.io/client-go/tools/events" "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/pkg/api" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/template" ) diff --git a/internal/webhook/tenant/validation/owners.go b/internal/webhook/tenant/validation/owners.go index 4e722d8a..2f6969b8 100644 --- a/internal/webhook/tenant/validation/owners.go +++ b/internal/webhook/tenant/validation/owners.go @@ -6,12 +6,12 @@ package validation import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/tenant" ) diff --git a/internal/webhook/tenant/validation/protected.go b/internal/webhook/tenant/validation/protected.go index db2682f8..97eca571 100644 --- a/internal/webhook/tenant/validation/protected.go +++ b/internal/webhook/tenant/validation/protected.go @@ -6,12 +6,12 @@ package validation import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/required_metdata_regex.go b/internal/webhook/tenant/validation/required_metdata_regex.go index 84b6638b..8652da87 100644 --- a/internal/webhook/tenant/validation/required_metdata_regex.go +++ b/internal/webhook/tenant/validation/required_metdata_regex.go @@ -7,12 +7,12 @@ import ( "context" "regexp" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" "github.com/projectcapsule/capsule/pkg/utils" ) diff --git a/internal/webhook/tenant/validation/rolebindings_regex.go b/internal/webhook/tenant/validation/rolebindings_regex.go index 2d47aa9c..ce4aecfd 100644 --- a/internal/webhook/tenant/validation/rolebindings_regex.go +++ b/internal/webhook/tenant/validation/rolebindings_regex.go @@ -9,12 +9,12 @@ import ( rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/util/validation" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/rule_validator.go b/internal/webhook/tenant/validation/rule_validator.go index ac4c2e70..cfdb6d9d 100644 --- a/internal/webhook/tenant/validation/rule_validator.go +++ b/internal/webhook/tenant/validation/rule_validator.go @@ -6,15 +6,14 @@ package validation import ( "context" "regexp" - "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) @@ -99,18 +98,24 @@ func ValidateRule(tnt *capsulev1beta2.Tenant, req admission.Request) *admission. } for j, registry := range rule.Enforce.Workloads.Registries { - expr := registry.Expression() - - if strings.TrimSpace(expr.Expression) == "" { - return ad.Denyf("rules[%d].enforce.workloads.registries[%d].exp must not be empty", i, j) - } - - if _, err := regexp.Compile(expr.Expression); err != nil { + if _, err := regexp.Compile(registry.Expression); err != nil { return ad.Denyf( "rules[%d].enforce.workloads.registries[%d].exp %q is invalid: %v", i, j, - expr.Expression, + registry.Expression, + err, + ) + } + } + + for j, scheduler := range rule.Enforce.Workloads.Schedulers { + if _, err := regexp.Compile(scheduler.Expression); err != nil { + return ad.Denyf( + "rules[%d].enforce.workloads.schedulers[%d].exp %q is invalid: %v", + i, + j, + scheduler.Expression, err, ) } diff --git a/internal/webhook/tenant/validation/storageclass_regex.go b/internal/webhook/tenant/validation/storageclass_regex.go index 5af457ac..a394d6e7 100644 --- a/internal/webhook/tenant/validation/storageclass_regex.go +++ b/internal/webhook/tenant/validation/storageclass_regex.go @@ -8,12 +8,12 @@ import ( "context" "regexp" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" ad "github.com/projectcapsule/capsule/pkg/runtime/admission" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/internal/webhook/tenant/validation/warnings.go b/internal/webhook/tenant/validation/warnings.go index 7ac9da42..545f2b30 100644 --- a/internal/webhook/tenant/validation/warnings.go +++ b/internal/webhook/tenant/validation/warnings.go @@ -8,13 +8,13 @@ import ( "strings" admissionv1 "k8s.io/api/admission/v1" - "k8s.io/client-go/tools/events" "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/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/runtime/handlers" ) diff --git a/pkg/api/expression.go b/pkg/api/expression.go new file mode 100644 index 00000000..ff107d3a --- /dev/null +++ b/pkg/api/expression.go @@ -0,0 +1,113 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "fmt" + "regexp" + "slices" +) + +// At least one of Exact or Exp must be set. +// Both may be set together. +// +kubebuilder:object:generate=true +// +kubebuilder:validation:XValidation:rule="has(self.exact) || has(self.exp)",message="at least one of exact or exp must be set" +type ExpressionMatch struct { + ExpressionRegex `json:",inline"` + + // Exact matches one of the provided values exactly. + // + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:Items:MinLength=1 + // +optional + Exact []string `json:"exact,omitempty"` +} + +type ExpressionRegex struct { + // Exp matches regular expression. + // + // +kubebuilder:validation:MinLength=1 + // +optional + Expression string `json:"exp,omitempty"` + // Negate regular Expression + //+kubebuilder:default:=false + Negate bool `json:"negate,omitempty"` +} + +type ExpressionRegexMatcher interface { + MatchRegex(expression ExpressionRegex, value string) (bool, error) +} + +func (m ExpressionMatch) Matches(value string) (bool, error) { + matched, err := m.matches(value) + if err != nil { + return false, err + } + + return m.applyNegate(matched), nil +} + +func (m ExpressionMatch) MatchesWithExpressionMatcher( + matcher ExpressionRegexMatcher, + value string, +) (bool, error) { + if len(m.Exact) == 0 && m.Expression == "" { + return false, fmt.Errorf("expression match must define at least one of exact or exp") + } + + matched := containsExact(m.Exact, value) + if matched { + return m.applyNegate(true), nil + } + + if m.Expression == "" { + return m.applyNegate(false), nil + } + + if matcher == nil { + return m.Matches(value) + } + + matched, err := matcher.MatchRegex(m.ExpressionRegex, value) + if err != nil { + return false, err + } + + // Important: assume MatchRegex already applies ExpressionRegex.Negate. + // If your RegexCache.MatchRegex already handles Negate, return directly. + return matched, nil +} + +func (m ExpressionMatch) matches(value string) (bool, error) { + if len(m.Exact) == 0 && m.Expression == "" { + return false, fmt.Errorf("expression match must define at least one of exact or exp") + } + + if containsExact(m.Exact, value) { + return true, nil + } + + if m.Expression == "" { + return false, nil + } + + re, err := regexp.Compile(m.Expression) + if err != nil { + return false, fmt.Errorf("compile regexp %q: %w", m.Expression, err) + } + + return re.MatchString(value), nil +} + +func containsExact(values []string, value string) bool { + return slices.Contains(values, value) +} + +func (m ExpressionMatch) applyNegate(matched bool) bool { + if m.Negate { + return !matched + } + + return matched +} diff --git a/pkg/api/expression_test.go b/pkg/api/expression_test.go new file mode 100644 index 00000000..9f36c8ca --- /dev/null +++ b/pkg/api/expression_test.go @@ -0,0 +1,917 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "errors" + "fmt" + "regexp" + "testing" +) + +type fakeExpressionRegexMatcher struct { + t *testing.T + + calls int + + err error + + matches map[string]bool + seen []ExpressionRegex +} + +func (m *fakeExpressionRegexMatcher) MatchRegex(expr ExpressionRegex, value string) (bool, error) { + m.t.Helper() + + m.calls++ + m.seen = append(m.seen, expr) + + if m.err != nil { + return false, m.err + } + + key := fmt.Sprintf("%s|%t|%s", expr.Expression, expr.Negate, value) + if matched, ok := m.matches[key]; ok { + return matched, nil + } + + re, err := regexp.Compile(expr.Expression) + if err != nil { + return false, err + } + + matched := re.MatchString(value) + if expr.Negate { + return !matched, nil + } + + return matched, nil +} + +func TestExpressionMatch_Matches(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + match ExpressionMatch + value string + wantMatch bool + wantErr bool + }{ + { + name: "exact matches single value", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + }, + value: "default-scheduler", + wantMatch: true, + }, + { + name: "exact does not match different value", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + }, + value: "custom-scheduler", + wantMatch: false, + }, + { + name: "exact matches one of multiple values", + match: ExpressionMatch{ + Exact: []string{"default-scheduler", "custom-scheduler", "team-scheduler"}, + }, + value: "custom-scheduler", + wantMatch: true, + }, + { + name: "exact does not match any of multiple values", + match: ExpressionMatch{ + Exact: []string{"default-scheduler", "custom-scheduler"}, + }, + value: "other-scheduler", + wantMatch: false, + }, + { + name: "exact is case sensitive", + match: ExpressionMatch{ + Exact: []string{"Default-Scheduler"}, + }, + value: "default-scheduler", + wantMatch: false, + }, + { + name: "exact uses literal string not pattern", + match: ExpressionMatch{ + Exact: []string{"team-.*"}, + }, + value: "team-a", + wantMatch: false, + }, + { + name: "exact matches literal pattern string", + match: ExpressionMatch{ + Exact: []string{"team-.*"}, + }, + value: "team-.*", + wantMatch: true, + }, + { + name: "exact with empty value can match empty string when present", + match: ExpressionMatch{ + Exact: []string{""}, + }, + value: "", + wantMatch: true, + }, + { + name: "regex matches value", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^team-[a-z0-9-]+$", + }, + }, + value: "team-alpha-1", + wantMatch: true, + }, + { + name: "regex does not match value", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^team-[a-z0-9-]+$", + }, + }, + value: "kube-scheduler", + wantMatch: false, + }, + { + name: "regex is not implicitly anchored", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "team", + }, + }, + value: "my-team-scheduler", + wantMatch: true, + }, + { + name: "invalid regex returns error", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "[", + }, + }, + value: "team-alpha", + wantErr: true, + }, + { + name: "combined exact and regex matches by exact", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Expression: "^team-[a-z0-9-]+$", + }, + }, + value: "default-scheduler", + wantMatch: true, + }, + { + name: "combined exact and regex matches by regex", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Expression: "^team-[a-z0-9-]+$", + }, + }, + value: "team-alpha", + wantMatch: true, + }, + { + name: "combined exact and regex does not match either", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Expression: "^team-[a-z0-9-]+$", + }, + }, + value: "other-scheduler", + wantMatch: false, + }, + { + name: "combined exact match skips invalid regex", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Expression: "[", + }, + }, + value: "default-scheduler", + wantMatch: true, + }, + { + name: "combined exact miss evaluates invalid regex and returns error", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Expression: "[", + }, + }, + value: "team-alpha", + wantErr: true, + }, + { + name: "negated exact matching value returns false", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Negate: true, + }, + }, + value: "default-scheduler", + wantMatch: false, + }, + { + name: "negated exact non matching value returns true", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Negate: true, + }, + }, + value: "custom-scheduler", + wantMatch: true, + }, + { + name: "negated exact with multiple values matching one returns false", + match: ExpressionMatch{ + Exact: []string{"default-scheduler", "custom-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Negate: true, + }, + }, + value: "custom-scheduler", + wantMatch: false, + }, + { + name: "negated regex matching value returns false", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^trusted/.*", + Negate: true, + }, + }, + value: "trusted/platform/app:1", + wantMatch: false, + }, + { + name: "negated regex non matching value returns true", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^trusted/.*", + Negate: true, + }, + }, + value: "docker.io/library/nginx:latest", + wantMatch: true, + }, + { + name: "negated combined exact match returns false", + match: ExpressionMatch{ + Exact: []string{"trusted/platform/app:1"}, + ExpressionRegex: ExpressionRegex{ + Expression: "^trusted/.+", + Negate: true, + }, + }, + value: "trusted/platform/app:1", + wantMatch: false, + }, + { + name: "negated combined regex match returns false", + match: ExpressionMatch{ + Exact: []string{"trusted/platform/app:1"}, + ExpressionRegex: ExpressionRegex{ + Expression: "^trusted/.+", + Negate: true, + }, + }, + value: "trusted/other/app:1", + wantMatch: false, + }, + { + name: "negated combined no match returns true", + match: ExpressionMatch{ + Exact: []string{"trusted/platform/app:1"}, + ExpressionRegex: ExpressionRegex{ + Expression: "^trusted/.+", + Negate: true, + }, + }, + value: "harbor/platform/app:1", + wantMatch: true, + }, + { + name: "empty matcher returns error", + match: ExpressionMatch{}, + value: "anything", + wantErr: true, + wantMatch: false, + }, + { + name: "empty exact slice with empty regex returns error", + match: ExpressionMatch{ + Exact: []string{}, + }, + value: "anything", + wantErr: true, + }, + { + name: "nil exact with whitespace regex is treated as regex and does not match", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: " ", + }, + }, + value: "anything", + wantMatch: false, + }, + { + name: "duplicate exact values still match", + match: ExpressionMatch{ + Exact: []string{"a", "a", "b"}, + }, + value: "a", + wantMatch: true, + }, + { + name: "exact values are not trimmed", + match: ExpressionMatch{ + Exact: []string{" value "}, + }, + value: "value", + wantMatch: false, + }, + { + name: "exact values match with spaces when value has spaces", + match: ExpressionMatch{ + Exact: []string{" value "}, + }, + value: " value ", + wantMatch: true, + }, + { + name: "regex can match empty value", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^$", + }, + }, + value: "", + wantMatch: true, + }, + { + name: "negated regex can reject empty value", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^$", + Negate: true, + }, + }, + value: "", + wantMatch: false, + }, + { + name: "negated regex can match non empty value against empty regex", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^$", + Negate: true, + }, + }, + value: "non-empty", + wantMatch: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tt.match.Matches(tt.value) + + if tt.wantErr { + if err == nil { + t.Fatalf("Matches() expected error, got nil") + } + + return + } + + if err != nil { + t.Fatalf("Matches() unexpected error: %v", err) + } + + if got != tt.wantMatch { + t.Fatalf("Matches() = %t, want %t", got, tt.wantMatch) + } + }) + } +} + +func TestExpressionMatch_MatchesWithExpressionMatcher_NilMatcherFallback(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + match ExpressionMatch + value string + wantMatch bool + wantErr bool + }{ + { + name: "nil matcher exact match", + match: ExpressionMatch{ + Exact: []string{"default-scheduler"}, + }, + value: "default-scheduler", + wantMatch: true, + }, + { + name: "nil matcher regex match", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^team-.*", + }, + }, + value: "team-a", + wantMatch: true, + }, + { + name: "nil matcher negated regex non match returns true", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^trusted/.*", + Negate: true, + }, + }, + value: "docker.io/library/nginx:latest", + wantMatch: true, + }, + { + name: "nil matcher invalid regex returns error", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "[", + }, + }, + value: "team-a", + wantErr: true, + }, + { + name: "nil matcher empty expression match returns error", + match: ExpressionMatch{}, + value: "team-a", + wantErr: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := tt.match.MatchesWithExpressionMatcher(nil, tt.value) + + if tt.wantErr { + if err == nil { + t.Fatalf("MatchesWithExpressionMatcher(nil) expected error, got nil") + } + + return + } + + if err != nil { + t.Fatalf("MatchesWithExpressionMatcher(nil) unexpected error: %v", err) + } + + if got != tt.wantMatch { + t.Fatalf("MatchesWithExpressionMatcher(nil) = %t, want %t", got, tt.wantMatch) + } + }) + } +} + +func TestExpressionMatch_MatchesWithExpressionMatcher_UsesMatcherForRegex(t *testing.T) { + t.Parallel() + + matcher := &fakeExpressionRegexMatcher{ + t: t, + matches: map[string]bool{ + "^team-.*|false|team-a": true, + }, + } + + match := ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^team-.*", + }, + } + + got, err := match.MatchesWithExpressionMatcher(matcher, "team-a") + if err != nil { + t.Fatalf("MatchesWithExpressionMatcher() unexpected error: %v", err) + } + + if !got { + t.Fatalf("MatchesWithExpressionMatcher() = false, want true") + } + + if matcher.calls != 1 { + t.Fatalf("MatchRegex() calls = %d, want 1", matcher.calls) + } + + if len(matcher.seen) != 1 { + t.Fatalf("seen expressions = %d, want 1", len(matcher.seen)) + } + + if matcher.seen[0].Expression != "^team-.*" { + t.Fatalf("seen expression = %q, want %q", matcher.seen[0].Expression, "^team-.*") + } + + if matcher.seen[0].Negate { + t.Fatalf("seen negate = true, want false") + } +} + +func TestExpressionMatch_MatchesWithExpressionMatcher_PassesNegateToMatcher(t *testing.T) { + t.Parallel() + + matcher := &fakeExpressionRegexMatcher{ + t: t, + matches: map[string]bool{ + "^trusted/.*|true|docker.io/library/nginx:latest": true, + }, + } + + match := ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^trusted/.*", + Negate: true, + }, + } + + got, err := match.MatchesWithExpressionMatcher(matcher, "docker.io/library/nginx:latest") + if err != nil { + t.Fatalf("MatchesWithExpressionMatcher() unexpected error: %v", err) + } + + if !got { + t.Fatalf("MatchesWithExpressionMatcher() = false, want true") + } + + if matcher.calls != 1 { + t.Fatalf("MatchRegex() calls = %d, want 1", matcher.calls) + } + + if len(matcher.seen) != 1 { + t.Fatalf("seen expressions = %d, want 1", len(matcher.seen)) + } + + if !matcher.seen[0].Negate { + t.Fatalf("seen negate = false, want true") + } +} + +func TestExpressionMatch_MatchesWithExpressionMatcher_DoesNotUseMatcherWhenExactMatches(t *testing.T) { + t.Parallel() + + matcher := &fakeExpressionRegexMatcher{ + t: t, + err: errors.New("matcher should not be called"), + } + + match := ExpressionMatch{ + Exact: []string{"default-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Expression: "[", + }, + } + + got, err := match.MatchesWithExpressionMatcher(matcher, "default-scheduler") + if err != nil { + t.Fatalf("MatchesWithExpressionMatcher() unexpected error: %v", err) + } + + if !got { + t.Fatalf("MatchesWithExpressionMatcher() = false, want true") + } + + if matcher.calls != 0 { + t.Fatalf("MatchRegex() calls = %d, want 0", matcher.calls) + } +} + +func TestExpressionMatch_MatchesWithExpressionMatcher_UsesMatcherWhenExactDoesNotMatch(t *testing.T) { + t.Parallel() + + matcher := &fakeExpressionRegexMatcher{ + t: t, + matches: map[string]bool{ + "^team-.*|false|team-a": true, + }, + } + + match := ExpressionMatch{ + Exact: []string{"default-scheduler"}, + ExpressionRegex: ExpressionRegex{ + Expression: "^team-.*", + }, + } + + got, err := match.MatchesWithExpressionMatcher(matcher, "team-a") + if err != nil { + t.Fatalf("MatchesWithExpressionMatcher() unexpected error: %v", err) + } + + if !got { + t.Fatalf("MatchesWithExpressionMatcher() = false, want true") + } + + if matcher.calls != 1 { + t.Fatalf("MatchRegex() calls = %d, want 1", matcher.calls) + } +} + +func TestExpressionMatch_MatchesWithExpressionMatcher_ReturnsMatcherError(t *testing.T) { + t.Parallel() + + wantErr := errors.New("compile failed") + + matcher := &fakeExpressionRegexMatcher{ + t: t, + err: wantErr, + } + + match := ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^team-.*", + }, + } + + got, err := match.MatchesWithExpressionMatcher(matcher, "team-a") + if err == nil { + t.Fatalf("MatchesWithExpressionMatcher() expected error, got nil") + } + + if !errors.Is(err, wantErr) { + t.Fatalf("MatchesWithExpressionMatcher() error = %v, want %v", err, wantErr) + } + + if got { + t.Fatalf("MatchesWithExpressionMatcher() = true, want false on error") + } + + if matcher.calls != 1 { + t.Fatalf("MatchRegex() calls = %d, want 1", matcher.calls) + } +} + +func TestExpressionMatch_MatchesAndMatchesWithExpressionMatcher_AgreeForNilMatcher(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + match ExpressionMatch + value string + }{ + { + name: "exact only", + match: ExpressionMatch{ + Exact: []string{"a", "b"}, + }, + value: "a", + }, + { + name: "regex only", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^a+$", + }, + }, + value: "aaa", + }, + { + name: "combined exact and regex exact wins", + match: ExpressionMatch{ + Exact: []string{"a"}, + ExpressionRegex: ExpressionRegex{ + Expression: "^b+$", + }, + }, + value: "a", + }, + { + name: "combined exact and regex regex wins", + match: ExpressionMatch{ + Exact: []string{"a"}, + ExpressionRegex: ExpressionRegex{ + Expression: "^b+$", + }, + }, + value: "bbb", + }, + { + name: "negated exact", + match: ExpressionMatch{ + Exact: []string{"a"}, + ExpressionRegex: ExpressionRegex{ + Negate: true, + }, + }, + value: "b", + }, + { + name: "negated regex", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "^a+$", + Negate: true, + }, + }, + value: "bbb", + }, + { + name: "invalid regex", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Expression: "[", + }, + }, + value: "a", + }, + { + name: "empty matcher", + match: ExpressionMatch{}, + value: "a", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gotMatches, errMatches := tt.match.Matches(tt.value) + gotWithMatcher, errWithMatcher := tt.match.MatchesWithExpressionMatcher(nil, tt.value) + + if (errMatches != nil) != (errWithMatcher != nil) { + t.Fatalf( + "error mismatch: Matches() err=%v, MatchesWithExpressionMatcher(nil) err=%v", + errMatches, + errWithMatcher, + ) + } + + if gotMatches != gotWithMatcher { + t.Fatalf( + "result mismatch: Matches()=%t, MatchesWithExpressionMatcher(nil)=%t", + gotMatches, + gotWithMatcher, + ) + } + }) + } +} + +func TestContainsExact(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + values []string + value string + want bool + }{ + { + name: "nil values", + values: nil, + value: "a", + want: false, + }, + { + name: "empty values", + values: []string{}, + value: "a", + want: false, + }, + { + name: "contains value", + values: []string{"a", "b", "c"}, + value: "b", + want: true, + }, + { + name: "does not contain value", + values: []string{"a", "b", "c"}, + value: "d", + want: false, + }, + { + name: "case sensitive", + values: []string{"A"}, + value: "a", + want: false, + }, + { + name: "empty string", + values: []string{""}, + value: "", + want: true, + }, + { + name: "whitespace is significant", + values: []string{" a "}, + value: "a", + want: false, + }, + { + name: "whitespace matches exactly", + values: []string{" a "}, + value: " a ", + want: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := containsExact(tt.values, tt.value) + if got != tt.want { + t.Fatalf("containsExact(%v, %q) = %t, want %t", tt.values, tt.value, got, tt.want) + } + }) + } +} + +func TestExpressionMatch_applyNegate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + match ExpressionMatch + matched bool + want bool + }{ + { + name: "non negated true", + match: ExpressionMatch{}, + matched: true, + want: true, + }, + { + name: "non negated false", + match: ExpressionMatch{}, + matched: false, + want: false, + }, + { + name: "negated true", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Negate: true, + }, + }, + matched: true, + want: false, + }, + { + name: "negated false", + match: ExpressionMatch{ + ExpressionRegex: ExpressionRegex{ + Negate: true, + }, + }, + matched: false, + want: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.match.applyNegate(tt.matched) + if got != tt.want { + t.Fatalf("applyNegate(%t) = %t, want %t", tt.matched, got, tt.want) + } + }) + } +} diff --git a/pkg/api/meta/annotations.go b/pkg/api/meta/annotations.go index 9a93f60c..1c3aa245 100644 --- a/pkg/api/meta/annotations.go +++ b/pkg/api/meta/annotations.go @@ -37,6 +37,12 @@ const ( ResourceQuotaAnnotationPrefix = "quota.resources.capsule.clastix.io" ResourceUsedAnnotationPrefix = "used.resources.capsule.clastix.io" + + // Audit Annotations. + AuditRequestUID = "audit.projectcapsule.dev/request-uid" + AuditUsername = "audit.projectcapsule.dev/username" + AuditRuleSetName = "audit.projectcapsule.dev/rule-set" + AuditRuleSetAction = "audit.projectcapsule.dev/rule-action" ) func ReleaseAnnotationTriggers(obj client.Object) bool { diff --git a/pkg/api/regex.go b/pkg/api/regex.go deleted file mode 100644 index fa2f338d..00000000 --- a/pkg/api/regex.go +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package api - -// +kubebuilder:object:generate=true -type RegExpression struct { - // Expression used to evaluate regex - Expression string `json:"exp,omitempty"` - // Negate regular Expression - //+kubebuilder:default:=false - Negate bool `json:"negate,omitempty"` -} diff --git a/pkg/api/rules/enforce_workloads_registry_types.go b/pkg/api/rules/enforce_workloads_registry_types.go index 04daf0ca..a17cd6f6 100644 --- a/pkg/api/rules/enforce_workloads_registry_types.go +++ b/pkg/api/rules/enforce_workloads_registry_types.go @@ -18,14 +18,10 @@ func (i ImagePullPolicySpec) String() string { // +kubebuilder:object:generate=true type OCIRegistry struct { - api.RegExpression `json:",inline"` + api.ExpressionMatch `json:",inline"` // Allowed PullPolicy for the given registry. Supplying no value allows all policies. // +optional // +kubebuilder:validation:Items:Enum=Always;Never;IfNotPresent Policy []corev1.PullPolicy `json:"policy,omitempty"` } - -func (r OCIRegistry) Expression() api.RegExpression { - return r.RegExpression -} diff --git a/pkg/api/rules/enforce_workloads_types.go b/pkg/api/rules/enforce_workloads_types.go index c77df7d9..830abd72 100644 --- a/pkg/api/rules/enforce_workloads_types.go +++ b/pkg/api/rules/enforce_workloads_types.go @@ -3,7 +3,11 @@ package rules -import corev1 "k8s.io/api/core/v1" +import ( + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/pkg/api" +) // +kubebuilder:validation:Enum=pod/initcontainers;pod/ephemeralcontainers;pod/containers;pod/volumes type WorkloadValidationTarget string @@ -31,5 +35,14 @@ type NamespaceRuleEnforceWorkloadsBody struct { // Define registries which are allowed to be used within this tenant // The rules are aggregated, since you can use Regular Expressions the match registry endpoints + // +optional Registries []OCIRegistry `json:"registries,omitempty"` + + // Schedulers defines schedulerName matchers for Pod admission. + // + // The rule is evaluated against pod.spec.schedulerName. + // Empty schedulerName is ignored and is not normalized to default-scheduler. + // + // +optional + Schedulers []api.ExpressionMatch `json:"schedulers,omitempty"` } diff --git a/pkg/api/rules/zz_generated.deepcopy.go b/pkg/api/rules/zz_generated.deepcopy.go index 311f9ca8..0f1e1d97 100644 --- a/pkg/api/rules/zz_generated.deepcopy.go +++ b/pkg/api/rules/zz_generated.deepcopy.go @@ -8,6 +8,7 @@ package rules import ( + "github.com/projectcapsule/capsule/pkg/api" "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -94,6 +95,13 @@ func (in *NamespaceRuleEnforceWorkloadsBody) DeepCopyInto(out *NamespaceRuleEnfo (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Schedulers != nil { + in, out := &in.Schedulers, &out.Schedulers + *out = make([]api.ExpressionMatch, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceWorkloadsBody. @@ -160,7 +168,7 @@ func (in *NamespaceRulePromotionRule) DeepCopy() *NamespaceRulePromotionRule { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OCIRegistry) DeepCopyInto(out *OCIRegistry) { *out = *in - out.RegExpression = in.RegExpression + in.ExpressionMatch.DeepCopyInto(&out.ExpressionMatch) if in.Policy != nil { in, out := &in.Policy, &out.Policy *out = make([]v1.PullPolicy, len(*in)) diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index a1c04598..e43baf5d 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -142,6 +142,27 @@ func (in *DefaultAllowedListSpec) DeepCopy() *DefaultAllowedListSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ExpressionMatch) DeepCopyInto(out *ExpressionMatch) { + *out = *in + out.ExpressionRegex = in.ExpressionRegex + if in.Exact != nil { + in, out := &in.Exact, &out.Exact + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ExpressionMatch. +func (in *ExpressionMatch) DeepCopy() *ExpressionMatch { + if in == nil { + return nil + } + out := new(ExpressionMatch) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExternalServiceIPsSpec) DeepCopyInto(out *ExternalServiceIPsSpec) { *out = *in @@ -288,21 +309,6 @@ func (in *PoolExhaustionResource) DeepCopy() *PoolExhaustionResource { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *RegExpression) DeepCopyInto(out *RegExpression) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RegExpression. -func (in *RegExpression) DeepCopy() *RegExpression { - if in == nil { - return nil - } - out := new(RegExpression) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceQuotaSpec) DeepCopyInto(out *ResourceQuotaSpec) { *out = *in diff --git a/pkg/ruleengine/convert.go b/pkg/ruleengine/convert.go new file mode 100644 index 00000000..1c4a6a39 --- /dev/null +++ b/pkg/ruleengine/convert.go @@ -0,0 +1,26 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ruleengine + +import api "github.com/projectcapsule/capsule/pkg/api/rules" + +func EnforceBodiesFromNamespaceRules( + bodies []*api.NamespaceRuleBodyNamespace, +) []*api.NamespaceRuleEnforceBody { + if len(bodies) == 0 { + return nil + } + + out := make([]*api.NamespaceRuleEnforceBody, 0, len(bodies)) + + for _, body := range bodies { + if body == nil || body.Enforce == nil { + continue + } + + out = append(out, body.Enforce) + } + + return out +} diff --git a/pkg/ruleengine/enforce_evaluator.go b/pkg/ruleengine/enforce_evaluator.go new file mode 100644 index 00000000..8fcad7b0 --- /dev/null +++ b/pkg/ruleengine/enforce_evaluator.go @@ -0,0 +1,267 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ruleengine + +import ( + "fmt" + + api "github.com/projectcapsule/capsule/pkg/api/rules" +) + +type Value struct { + Value string + Path string +} + +type Match struct { + Matched bool + MatchedValue any +} + +type Decision struct { + SetName string + EventReason string + Action api.ActionType + Value Value + MatchedValue any + Message string +} + +type DecisionError struct { + Decision *Decision +} + +func (e *DecisionError) Error() string { + if e == nil || e.Decision == nil { + return "namespace rule decision denied request" + } + + return e.Decision.Message +} + +type Evaluation struct { + // Final is the last matching allow/deny decision. + Final *Decision + + // Blocking is set when the final result blocks admission. + Blocking *Decision + + // Audits contains all matching audit decisions. + Audits []*Decision +} + +func (e *Evaluation) BlockingError() error { + if e == nil || e.Blocking == nil { + return nil + } + + return &DecisionError{ + Decision: e.Blocking, + } +} + +func (e *Evaluation) Append(other *Evaluation) { + if e == nil || other == nil { + return + } + + e.Audits = append(e.Audits, other.Audits...) + + if other.Final != nil { + e.Final = other.Final + } + + if other.Blocking != nil { + e.Blocking = other.Blocking + } +} + +type Set[R any, T any] struct { + Name string + + EventReason string + + Values func(T) []Value + + Rules func(*api.NamespaceRuleEnforceBody) []R + + Matches func(R, Value) (Match, error) + + Message func(action api.ActionType, value Value, matchedValue any) string +} + +func EvaluateEnforce[R any, T any]( + obj T, + enforceBodies []*api.NamespaceRuleEnforceBody, + set Set[R, T], +) (*Evaluation, error) { + if set.Name == "" { + return nil, fmt.Errorf("rule set name is empty") + } + + if set.Values == nil { + return nil, fmt.Errorf("%s: values extractor is nil", set.Name) + } + + if set.Rules == nil { + return nil, fmt.Errorf("%s: rules extractor is nil", set.Name) + } + + if set.Matches == nil { + return nil, fmt.Errorf("%s: matcher is nil", set.Name) + } + + evaluation := &Evaluation{} + + values := set.Values(obj) + if len(values) == 0 { + return evaluation, nil + } + + for _, value := range values { + if value.Value == "" { + continue + } + + hasAllowRule := false + + var lastDecision *Decision + + for _, enforce := range enforceBodies { + if enforce == nil { + continue + } + + items := set.Rules(enforce) + if len(items) == 0 { + continue + } + + action := enforce.Action.OrDefault() + + switch action { + case api.ActionTypeAllow: + hasAllowRule = true + + case api.ActionTypeDeny, api.ActionTypeAudit: + // Supported actions. + + default: + return evaluation, fmt.Errorf( + "%s: unsupported rule action %q", + set.Name, + action, + ) + } + + for _, item := range items { + match, err := set.Matches(item, value) + if err != nil { + return evaluation, fmt.Errorf("%s: invalid rule: %w", set.Name, err) + } + + if !match.Matched { + continue + } + + decision := &Decision{ + SetName: set.Name, + EventReason: set.EventReason, + Action: action, + Value: value, + MatchedValue: match.MatchedValue, + Message: decisionMessage(set, action, value, match.MatchedValue), + } + + switch action { + case api.ActionTypeAudit: + // Audit is purely observational. It must not influence + // allow/deny evaluation. + evaluation.Audits = append(evaluation.Audits, decision) + + case api.ActionTypeAllow, api.ActionTypeDeny: + // Last matching allow/deny wins. + lastDecision = decision + } + } + } + + if lastDecision != nil { + evaluation.Final = lastDecision + + if lastDecision.Action == api.ActionTypeDeny { + evaluation.Blocking = lastDecision + + return evaluation, nil + } + + continue + } + + if hasAllowRule { + evaluation.Blocking = &Decision{ + SetName: set.Name, + EventReason: set.EventReason, + Action: api.ActionTypeDeny, + Value: value, + Message: fmt.Sprintf( + "%s %q at %s is not allowed by namespace rule", + set.Name, + value.Value, + value.Path, + ), + } + + return evaluation, nil + } + } + + return evaluation, nil +} + +func decisionMessage[R any, T any]( + set Set[R, T], + action api.ActionType, + value Value, + matchedValue any, +) string { + if set.Message != nil { + return set.Message(action, value, matchedValue) + } + + switch action { + case api.ActionTypeAudit: + return fmt.Sprintf( + "%s %q at %s matched audit namespace rule", + set.Name, + value.Value, + value.Path, + ) + + case api.ActionTypeDeny: + return fmt.Sprintf( + "%s %q at %s is denied by namespace rule", + set.Name, + value.Value, + value.Path, + ) + + case api.ActionTypeAllow: + return fmt.Sprintf( + "%s %q at %s is allowed by namespace rule", + set.Name, + value.Value, + value.Path, + ) + + default: + return fmt.Sprintf( + "%s %q at %s matched namespace rule action %q", + set.Name, + value.Value, + value.Path, + action, + ) + } +} diff --git a/pkg/ruleengine/enforce_evaluator_test.go b/pkg/ruleengine/enforce_evaluator_test.go new file mode 100644 index 00000000..ed7a1ba5 --- /dev/null +++ b/pkg/ruleengine/enforce_evaluator_test.go @@ -0,0 +1,1681 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package ruleengine + +import ( + "errors" + "strings" + "testing" + + api "github.com/projectcapsule/capsule/pkg/api/rules" +) + +type testObject struct { + Values []Value +} + +type testRule struct { + Name string + ShouldMatch bool + MatchValue any + Err error +} + +type enforceSpec struct { + action api.ActionType + items []testRule +} + +type testFixture struct { + items map[*api.NamespaceRuleEnforceBody][]testRule +} + +func TestEvaluateEnforce_ValidationErrors(t *testing.T) { + t.Parallel() + + validFixture := newTestFixture() + + validSet := validFixture.set("test", nil) + + tests := []struct { + name string + set Set[testRule, testObject] + wantErr string + }{ + { + name: "empty set name", + set: Set[testRule, testObject]{ + Values: validSet.Values, + Rules: validSet.Rules, + Matches: validSet.Matches, + }, + wantErr: "rule set name is empty", + }, + { + name: "nil values extractor", + set: Set[testRule, testObject]{ + Name: "test", + Rules: validSet.Rules, + Matches: validSet.Matches, + }, + wantErr: "test: values extractor is nil", + }, + { + name: "nil rules extractor", + set: Set[testRule, testObject]{ + Name: "test", + Values: validSet.Values, + Matches: validSet.Matches, + }, + wantErr: "test: rules extractor is nil", + }, + { + name: "nil matcher", + set: Set[testRule, testObject]{ + Name: "test", + Values: validSet.Values, + Rules: validSet.Rules, + }, + wantErr: "test: matcher is nil", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "a", Path: "spec.value"}}}, + []*api.NamespaceRuleEnforceBody{{Action: api.ActionTypeAllow}}, + tt.set, + ) + + if err == nil { + t.Fatalf("EvaluateEnforce() expected error, got nil") + } + + if evaluation != nil { + t.Fatalf("EvaluateEnforce() evaluation = %#v, want nil on validation error", evaluation) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("EvaluateEnforce() error = %q, want containing %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestEvaluateEnforce_EmptyInputs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + obj testObject + enforceSpecs []enforceSpec + includeNilBody bool + }{ + { + name: "no values", + obj: testObject{}, + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + }, + }, + { + name: "only empty value", + obj: testObject{ + Values: []Value{{Value: "", Path: "spec.value"}}, + }, + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + }, + }, + { + name: "nil enforce bodies", + obj: testObject{ + Values: []Value{{Value: "a", Path: "spec.value"}}, + }, + enforceSpecs: nil, + }, + { + name: "empty enforce bodies", + obj: testObject{ + Values: []Value{{Value: "a", Path: "spec.value"}}, + }, + enforceSpecs: []enforceSpec{}, + }, + { + name: "nil enforce body is ignored", + obj: testObject{ + Values: []Value{{Value: "a", Path: "spec.value"}}, + }, + includeNilBody: true, + }, + { + name: "enforce body without rule items is ignored", + obj: testObject{ + Values: []Value{{Value: "a", Path: "spec.value"}}, + }, + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + }, + }, + }, + { + name: "non matching rule item is ignored", + obj: testObject{ + Values: []Value{{Value: "a", Path: "spec.value"}}, + }, + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: false}}, + }, + }, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + enforceBodies := buildEnforceBodies(fixture, tt.enforceSpecs) + + if tt.includeNilBody { + enforceBodies = append(enforceBodies, nil) + } + + evaluation, err := EvaluateEnforce(tt.obj, enforceBodies, fixture.set("registry", nil)) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + assertNoBlocking(t, evaluation) + assertNoFinal(t, evaluation) + + if len(evaluation.Audits) != 0 { + t.Fatalf("audits = %d, want 0", len(evaluation.Audits)) + } + }) + } +} + +func TestEvaluateEnforce_LastMatchingAllowDenyWins(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + enforceSpecs []enforceSpec + wantBlocking bool + wantFinalAction api.ActionType + }{ + { + name: "single allow allows", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + }, + wantFinalAction: api.ActionTypeAllow, + }, + { + name: "single deny denies", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + }, + wantBlocking: true, + wantFinalAction: api.ActionTypeDeny, + }, + { + name: "deny then allow allows", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + }, + wantFinalAction: api.ActionTypeAllow, + }, + { + name: "allow then deny denies", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + }, + wantBlocking: true, + wantFinalAction: api.ActionTypeDeny, + }, + { + name: "unmatched later deny does not override previous allow", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: false}}, + }, + }, + wantFinalAction: api.ActionTypeAllow, + }, + { + name: "unmatched later allow does not override previous deny", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: false}}, + }, + }, + wantBlocking: true, + wantFinalAction: api.ActionTypeDeny, + }, + { + name: "multiple matching decisive rules last allow wins", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow-1", ShouldMatch: true}}, + }, + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow-2", ShouldMatch: true}}, + }, + }, + wantFinalAction: api.ActionTypeAllow, + }, + { + name: "multiple matching decisive rules last deny wins", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny-1", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny-2", ShouldMatch: true}}, + }, + }, + wantBlocking: true, + wantFinalAction: api.ActionTypeDeny, + }, + { + name: "only unmatched allow implicitly denies", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: false}}, + }, + }, + wantBlocking: true, + }, + { + name: "only unmatched deny allows", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: false}}, + }, + }, + }, + { + name: "unrelated allow before unrelated deny implicitly denies", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: false}}, + }, + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: false}}, + }, + }, + wantBlocking: true, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + buildEnforceBodies(fixture, tt.enforceSpecs), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if tt.wantBlocking { + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want decision") + } + + if evaluation.Blocking.Action != api.ActionTypeDeny { + t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) + } + + if err := evaluation.BlockingError(); err == nil { + t.Fatalf("BlockingError() = nil, want error") + } + } else { + assertNoBlocking(t, evaluation) + } + + if tt.wantFinalAction == "" { + assertNoFinal(t, evaluation) + + return + } + + if evaluation.Final == nil { + t.Fatalf("Final = nil, want action %q", tt.wantFinalAction) + } + + if evaluation.Final.Action != tt.wantFinalAction { + t.Fatalf("Final.Action = %q, want %q", evaluation.Final.Action, tt.wantFinalAction) + } + + if tt.wantBlocking && evaluation.Blocking != evaluation.Final { + t.Fatalf("Blocking and Final should point to same deny decision") + } + }) + } +} + +func TestEvaluateEnforce_AuditSemantics(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + enforceSpecs []enforceSpec + wantAudits int + wantBlocking bool + wantFinal api.ActionType + }{ + { + name: "single audit allows and records audit", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: true}}, + }, + }, + wantAudits: 1, + }, + { + name: "multiple audits are all recorded", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit-1", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit-2", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit-3", ShouldMatch: true}}, + }, + }, + wantAudits: 3, + }, + { + name: "unmatched audit is ignored", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: false}}, + }, + }, + wantAudits: 0, + }, + { + name: "audit plus deny records audit and denies", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: true}}, + }, + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + }, + wantAudits: 1, + wantBlocking: true, + wantFinal: api.ActionTypeDeny, + }, + { + name: "deny plus audit records audit but final deny remains", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: true}}, + }, + }, + wantAudits: 1, + wantBlocking: true, + wantFinal: api.ActionTypeDeny, + }, + { + name: "audit plus allow records audit and allows", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + }, + wantAudits: 1, + wantFinal: api.ActionTypeAllow, + }, + { + name: "allow plus audit records audit and final allow remains", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: true}}, + }, + }, + wantAudits: 1, + wantFinal: api.ActionTypeAllow, + }, + { + name: "audit records audit but does not prevent implicit allow-list deny", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: false}}, + }, + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: true}}, + }, + }, + wantAudits: 1, + wantBlocking: true, + }, + { + name: "audit plus allow plus later deny denies and records audit", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + }, + wantAudits: 1, + wantBlocking: true, + wantFinal: api.ActionTypeDeny, + }, + { + name: "audit plus deny plus later allow allows and records audit", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: true}}, + }, + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + }, + wantAudits: 1, + wantFinal: api.ActionTypeAllow, + }, + { + name: "unmatched audit after matching allow does not change final allow", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeAllow, + items: []testRule{{Name: "allow", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: false}}, + }, + }, + wantAudits: 0, + wantFinal: api.ActionTypeAllow, + }, + { + name: "unmatched audit after matching deny does not change final deny", + enforceSpecs: []enforceSpec{ + { + action: api.ActionTypeDeny, + items: []testRule{{Name: "deny", ShouldMatch: true}}, + }, + { + action: api.ActionTypeAudit, + items: []testRule{{Name: "audit", ShouldMatch: false}}, + }, + }, + wantAudits: 0, + wantBlocking: true, + wantFinal: api.ActionTypeDeny, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + buildEnforceBodies(fixture, tt.enforceSpecs), + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if len(evaluation.Audits) != tt.wantAudits { + t.Fatalf("audits = %d, want %d", len(evaluation.Audits), tt.wantAudits) + } + + for _, audit := range evaluation.Audits { + if audit.Action != api.ActionTypeAudit { + t.Fatalf("audit action = %q, want %q", audit.Action, api.ActionTypeAudit) + } + + if audit.Message == "" { + t.Fatalf("audit message is empty") + } + } + + if tt.wantBlocking { + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want decision") + } + + if evaluation.Blocking.Action != api.ActionTypeDeny { + t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) + } + + if err := evaluation.BlockingError(); err == nil { + t.Fatalf("BlockingError() = nil, want error") + } + } else { + assertNoBlocking(t, evaluation) + } + + if tt.wantFinal == "" { + assertNoFinal(t, evaluation) + + return + } + + if evaluation.Final == nil { + t.Fatalf("Final = nil, want %q", tt.wantFinal) + } + + if evaluation.Final.Action != tt.wantFinal { + t.Fatalf("Final.Action = %q, want %q", evaluation.Final.Action, tt.wantFinal) + } + }) + } +} + +func TestEvaluateEnforce_ListValues(t *testing.T) { + t.Parallel() + + t.Run("blocks on first value not matched by allow-list", func(t *testing.T) { + t.Parallel() + + set := Set[string, testObject]{ + Name: "registry", + EventReason: "ForbiddenRegistry", + Values: func(obj testObject) []Value { + return obj.Values + }, + Rules: func(_ *api.NamespaceRuleEnforceBody) []string { + return []string{"allowed"} + }, + Matches: func(rule string, value Value) (Match, error) { + return Match{Matched: value.Value == rule}, nil + }, + } + + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "blocked", Path: "containers[0]"}, + {Value: "allowed", Path: "containers[1]"}, + }, + }, + []*api.NamespaceRuleEnforceBody{ + {Action: api.ActionTypeAllow}, + }, + set, + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want implicit allow-list deny") + } + + if evaluation.Blocking.Value.Value != "blocked" { + t.Fatalf("Blocking.Value.Value = %q, want blocked", evaluation.Blocking.Value.Value) + } + + if evaluation.Blocking.Value.Path != "containers[0]" { + t.Fatalf("Blocking.Value.Path = %q, want containers[0]", evaluation.Blocking.Value.Path) + } + + if evaluation.Final != nil { + t.Fatalf("Final = %#v, want nil because first value was implicitly denied", evaluation.Final) + } + }) + +} + +func TestEvaluateEnforce_AllowListImplicitDeny(t *testing.T) { + t.Parallel() + + t.Run("unmatched allow creates blocking decision without final decision", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "harbor/app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeAllow, testRule{ + Name: "allow", + ShouldMatch: false, + }), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want implicit allow-list deny") + } + + if evaluation.Blocking.Action != api.ActionTypeDeny { + t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) + } + + if evaluation.Blocking.Value.Value != "harbor/app:1" { + t.Fatalf("Blocking.Value.Value = %q, want harbor/app:1", evaluation.Blocking.Value.Value) + } + + if evaluation.Blocking.Value.Path != "containers[0]" { + t.Fatalf("Blocking.Value.Path = %q, want containers[0]", evaluation.Blocking.Value.Path) + } + + if evaluation.Final != nil { + t.Fatalf("Final = %#v, want nil for implicit allow-list deny", evaluation.Final) + } + + if !strings.Contains(evaluation.Blocking.Message, "is not allowed by namespace rule") { + t.Fatalf("Blocking.Message = %q, want implicit allow-list deny message", evaluation.Blocking.Message) + } + }) + + t.Run("audit-only rules remain observational and do not deny", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "harbor/app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeAudit, testRule{ + Name: "audit", + ShouldMatch: true, + }), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if len(evaluation.Audits) != 1 { + t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) + } + + assertNoBlocking(t, evaluation) + assertNoFinal(t, evaluation) + }) + + t.Run("matched audit does not prevent implicit allow-list deny", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "harbor/app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeAudit, testRule{ + Name: "audit", + ShouldMatch: true, + }), + fixture.enforce(api.ActionTypeAllow, testRule{ + Name: "allow", + ShouldMatch: false, + }), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if len(evaluation.Audits) != 1 { + t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) + } + + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want implicit allow-list deny") + } + + if evaluation.Blocking.Action != api.ActionTypeDeny { + t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) + } + + if evaluation.Final != nil { + t.Fatalf("Final = %#v, want nil for implicit allow-list deny", evaluation.Final) + } + }) +} + +func TestEvaluateEnforce_MultipleValues(t *testing.T) { + t.Parallel() + + t.Run("continues after allowed first value and blocks on second value", func(t *testing.T) { + t.Parallel() + + set := Set[string, testObject]{ + Name: "registry", + EventReason: "ForbiddenRegistry", + Values: func(obj testObject) []Value { + return obj.Values + }, + Rules: func(_ *api.NamespaceRuleEnforceBody) []string { + return []string{"bad"} + }, + Matches: func(rule string, value Value) (Match, error) { + return Match{Matched: value.Value == rule}, nil + }, + } + + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "good", Path: "containers[0]"}, + {Value: "bad", Path: "containers[1]"}, + }, + }, + []*api.NamespaceRuleEnforceBody{ + {Action: api.ActionTypeDeny}, + }, + set, + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want decision") + } + + if evaluation.Blocking.Value.Value != "bad" { + t.Fatalf("Blocking.Value.Value = %q, want %q", evaluation.Blocking.Value.Value, "bad") + } + + if evaluation.Blocking.Value.Path != "containers[1]" { + t.Fatalf("Blocking.Value.Path = %q, want %q", evaluation.Blocking.Value.Path, "containers[1]") + } + }) + + t.Run("skips empty values and evaluates non-empty values", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "", Path: "containers[0]"}, + {Value: "bad", Path: "containers[1]"}, + }, + }, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeDeny, testRule{Name: "deny", ShouldMatch: true}), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want decision") + } + + if evaluation.Blocking.Value.Path != "containers[1]" { + t.Fatalf("Blocking.Value.Path = %q, want containers[1]", evaluation.Blocking.Value.Path) + } + }) + + t.Run("audits all matching values", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "a", Path: "containers[0]"}, + {Value: "b", Path: "containers[1]"}, + }, + }, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeAudit, testRule{Name: "audit", ShouldMatch: true}), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if len(evaluation.Audits) != 2 { + t.Fatalf("audits = %d, want 2", len(evaluation.Audits)) + } + + if evaluation.Audits[0].Value.Path != "containers[0]" { + t.Fatalf("first audit path = %q, want containers[0]", evaluation.Audits[0].Value.Path) + } + + if evaluation.Audits[1].Value.Path != "containers[1]" { + t.Fatalf("second audit path = %q, want containers[1]", evaluation.Audits[1].Value.Path) + } + }) + + t.Run("stops after first blocking value", func(t *testing.T) { + t.Parallel() + + set := Set[string, testObject]{ + Name: "registry", + Values: func(obj testObject) []Value { + return obj.Values + }, + Rules: func(_ *api.NamespaceRuleEnforceBody) []string { + return []string{"bad", "worse"} + }, + Matches: func(rule string, value Value) (Match, error) { + return Match{Matched: value.Value == rule}, nil + }, + } + + evaluation, err := EvaluateEnforce( + testObject{ + Values: []Value{ + {Value: "bad", Path: "containers[0]"}, + {Value: "worse", Path: "containers[1]"}, + }, + }, + []*api.NamespaceRuleEnforceBody{ + {Action: api.ActionTypeDeny}, + }, + set, + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want decision") + } + + if evaluation.Blocking.Value.Value != "bad" { + t.Fatalf("Blocking.Value.Value = %q, want bad", evaluation.Blocking.Value.Value) + } + }) +} + +func TestEvaluateEnforce_MatchedValueAndMessages(t *testing.T) { + t.Parallel() + + t.Run("matched value is propagated to final decision", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + matchedValue := map[string]string{"rule": "compiled-registry-rule"} + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeAllow, testRule{ + Name: "allow", + ShouldMatch: true, + MatchValue: matchedValue, + }), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if evaluation.Final == nil { + t.Fatalf("Final = nil, want decision") + } + + got, ok := evaluation.Final.MatchedValue.(map[string]string) + if !ok { + t.Fatalf("Final.MatchedValue type = %T, want map[string]string", evaluation.Final.MatchedValue) + } + + if got["rule"] != "compiled-registry-rule" { + t.Fatalf("Final.MatchedValue[rule] = %q", got["rule"]) + } + }) + + t.Run("matched value is propagated to blocking decision", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + matchedValue := "compiled-deny-rule" + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeDeny, testRule{ + Name: "deny", + ShouldMatch: true, + MatchValue: matchedValue, + }), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want decision") + } + + if evaluation.Blocking.MatchedValue != matchedValue { + t.Fatalf("Blocking.MatchedValue = %#v, want %#v", evaluation.Blocking.MatchedValue, matchedValue) + } + }) + + t.Run("matched value is propagated to audit decision", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + matchedValue := "compiled-audit-rule" + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeAudit, testRule{ + Name: "audit", + ShouldMatch: true, + MatchValue: matchedValue, + }), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if len(evaluation.Audits) != 1 { + t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) + } + + if evaluation.Audits[0].MatchedValue != matchedValue { + t.Fatalf("audit MatchedValue = %#v, want %#v", evaluation.Audits[0].MatchedValue, matchedValue) + } + }) + + t.Run("custom message is used", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeDeny, testRule{Name: "deny", ShouldMatch: true}), + }, + fixture.set("registry", func(action api.ActionType, value Value, matchedValue any) string { + return "custom: " + string(action) + " " + value.Value + }), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want decision") + } + + if evaluation.Blocking.Message != "custom: deny app:1" { + t.Fatalf("Blocking.Message = %q, want custom message", evaluation.Blocking.Message) + } + }) + + t.Run("default messages are populated", func(t *testing.T) { + t.Parallel() + + for _, action := range []api.ActionType{ + api.ActionTypeAllow, + api.ActionTypeDeny, + api.ActionTypeAudit, + } { + action := action + + t.Run(string(action), func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(action, testRule{Name: string(action), ShouldMatch: true}), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + switch action { + case api.ActionTypeAllow: + if evaluation.Final == nil || evaluation.Final.Message == "" { + t.Fatalf("allow final message is empty") + } + case api.ActionTypeDeny: + if evaluation.Blocking == nil || evaluation.Blocking.Message == "" { + t.Fatalf("deny blocking message is empty") + } + case api.ActionTypeAudit: + if len(evaluation.Audits) != 1 || evaluation.Audits[0].Message == "" { + t.Fatalf("audit message is empty") + } + } + }) + } + }) +} + +func TestEvaluateEnforce_DefaultActionAndUnsupportedAction(t *testing.T) { + t.Parallel() + + t.Run("empty action defaults to deny", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce("", testRule{Name: "default-deny", ShouldMatch: true}), + }, + fixture.set("registry", nil), + ) + if err != nil { + t.Fatalf("EvaluateEnforce() unexpected error: %v", err) + } + + if evaluation.Blocking == nil { + t.Fatalf("Blocking = nil, want default deny decision") + } + + if evaluation.Blocking.Action != api.ActionTypeDeny { + t.Fatalf("Blocking.Action = %q, want %q", evaluation.Blocking.Action, api.ActionTypeDeny) + } + }) + + t.Run("unsupported action returns error with partial evaluation", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeAudit, testRule{Name: "audit", ShouldMatch: true}), + fixture.enforce(api.ActionType("unsupported"), testRule{Name: "bad", ShouldMatch: true}), + }, + fixture.set("registry", nil), + ) + if err == nil { + t.Fatalf("EvaluateEnforce() expected error, got nil") + } + + if !strings.Contains(err.Error(), `registry: unsupported rule action "unsupported"`) { + t.Fatalf("error = %q, want unsupported action message", err.Error()) + } + + if evaluation == nil { + t.Fatalf("evaluation = nil, want partial evaluation") + } + + if len(evaluation.Audits) != 1 { + t.Fatalf("audits = %d, want 1 from partial evaluation", len(evaluation.Audits)) + } + }) +} + +func TestEvaluateEnforce_MatcherErrors(t *testing.T) { + t.Parallel() + + t.Run("matcher error is wrapped and returns partial evaluation", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + wantErr := errors.New("matcher failed") + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeAllow, testRule{Name: "allow", ShouldMatch: true, Err: wantErr}), + }, + fixture.set("registry", nil), + ) + + if err == nil { + t.Fatalf("EvaluateEnforce() expected error, got nil") + } + + if !errors.Is(err, wantErr) { + t.Fatalf("EvaluateEnforce() error = %v, want wrapping %v", err, wantErr) + } + + if !strings.Contains(err.Error(), "registry: invalid rule") { + t.Fatalf("EvaluateEnforce() error = %q, want invalid rule context", err.Error()) + } + + if evaluation == nil { + t.Fatalf("evaluation = nil, want partial evaluation") + } + }) + + t.Run("matcher error after audit keeps audit in partial evaluation", func(t *testing.T) { + t.Parallel() + + fixture := newTestFixture() + + wantErr := errors.New("matcher failed") + + evaluation, err := EvaluateEnforce( + testObject{Values: []Value{{Value: "app:1", Path: "containers[0]"}}}, + []*api.NamespaceRuleEnforceBody{ + fixture.enforce(api.ActionTypeAudit, testRule{Name: "audit", ShouldMatch: true}), + fixture.enforce(api.ActionTypeAllow, testRule{Name: "allow", Err: wantErr}), + }, + fixture.set("registry", nil), + ) + + if err == nil { + t.Fatalf("EvaluateEnforce() expected error, got nil") + } + + if !errors.Is(err, wantErr) { + t.Fatalf("EvaluateEnforce() error = %v, want wrapping %v", err, wantErr) + } + + if evaluation == nil { + t.Fatalf("evaluation = nil, want partial evaluation") + } + + if len(evaluation.Audits) != 1 { + t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) + } + }) +} + +func TestEvaluation_BlockingError(t *testing.T) { + t.Parallel() + + t.Run("nil evaluation has no blocking error", func(t *testing.T) { + t.Parallel() + + var evaluation *Evaluation + + if err := evaluation.BlockingError(); err != nil { + t.Fatalf("BlockingError() = %v, want nil", err) + } + }) + + t.Run("evaluation without blocking has no blocking error", func(t *testing.T) { + t.Parallel() + + evaluation := &Evaluation{} + + if err := evaluation.BlockingError(); err != nil { + t.Fatalf("BlockingError() = %v, want nil", err) + } + }) + + t.Run("evaluation with blocking returns decision error", func(t *testing.T) { + t.Parallel() + + decision := &Decision{ + SetName: "registry", + Action: api.ActionTypeDeny, + Message: "denied", + } + + evaluation := &Evaluation{ + Blocking: decision, + } + + err := evaluation.BlockingError() + if err == nil { + t.Fatalf("BlockingError() = nil, want error") + } + + var decisionErr *DecisionError + if !errors.As(err, &decisionErr) { + t.Fatalf("BlockingError() type = %T, want *DecisionError", err) + } + + if decisionErr.Decision != decision { + t.Fatalf("DecisionError.Decision = %#v, want original decision", decisionErr.Decision) + } + + if err.Error() != "denied" { + t.Fatalf("BlockingError().Error() = %q, want denied", err.Error()) + } + }) +} + +func TestDecisionError_Error(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err *DecisionError + want string + }{ + { + name: "nil error receiver", + err: nil, + want: "namespace rule decision denied request", + }, + { + name: "nil decision", + err: &DecisionError{}, + want: "namespace rule decision denied request", + }, + { + name: "decision message", + err: &DecisionError{ + Decision: &Decision{ + Message: "custom denied message", + }, + }, + want: "custom denied message", + }, + { + name: "empty decision message", + err: &DecisionError{ + Decision: &Decision{}, + }, + want: "", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := tt.err.Error() + if got != tt.want { + t.Fatalf("Error() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestEvaluation_Append(t *testing.T) { + t.Parallel() + + t.Run("nil receiver is no-op", func(t *testing.T) { + t.Parallel() + + var evaluation *Evaluation + + evaluation.Append(&Evaluation{ + Audits: []*Decision{{SetName: "audit"}}, + Final: &Decision{Action: api.ActionTypeAllow}, + }) + }) + + t.Run("nil other is no-op", func(t *testing.T) { + t.Parallel() + + evaluation := &Evaluation{ + Audits: []*Decision{{SetName: "audit-1"}}, + Final: &Decision{Action: api.ActionTypeAllow}, + } + + evaluation.Append(nil) + + if len(evaluation.Audits) != 1 { + t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) + } + + if evaluation.Final == nil || evaluation.Final.Action != api.ActionTypeAllow { + t.Fatalf("Final = %#v, want allow", evaluation.Final) + } + }) + + t.Run("appends audits and replaces final and blocking", func(t *testing.T) { + t.Parallel() + + initialFinal := &Decision{SetName: "initial", Action: api.ActionTypeAllow} + newFinal := &Decision{SetName: "new", Action: api.ActionTypeDeny} + newBlocking := &Decision{SetName: "new", Action: api.ActionTypeDeny} + + evaluation := &Evaluation{ + Audits: []*Decision{{SetName: "audit-1"}}, + Final: initialFinal, + } + + evaluation.Append(&Evaluation{ + Audits: []*Decision{{SetName: "audit-2"}, {SetName: "audit-3"}}, + Final: newFinal, + Blocking: newBlocking, + }) + + if len(evaluation.Audits) != 3 { + t.Fatalf("audits = %d, want 3", len(evaluation.Audits)) + } + + if evaluation.Final != newFinal { + t.Fatalf("Final = %#v, want new final", evaluation.Final) + } + + if evaluation.Blocking != newBlocking { + t.Fatalf("Blocking = %#v, want new blocking", evaluation.Blocking) + } + }) + + t.Run("does not replace final or blocking with nil", func(t *testing.T) { + t.Parallel() + + final := &Decision{SetName: "final", Action: api.ActionTypeAllow} + blocking := &Decision{SetName: "blocking", Action: api.ActionTypeDeny} + + evaluation := &Evaluation{ + Final: final, + Blocking: blocking, + } + + evaluation.Append(&Evaluation{ + Audits: []*Decision{{SetName: "audit"}}, + }) + + if evaluation.Final != final { + t.Fatalf("Final was replaced unexpectedly") + } + + if evaluation.Blocking != blocking { + t.Fatalf("Blocking was replaced unexpectedly") + } + + if len(evaluation.Audits) != 1 { + t.Fatalf("audits = %d, want 1", len(evaluation.Audits)) + } + }) +} + +func TestDecisionMessage(t *testing.T) { + t.Parallel() + + set := Set[testRule, testObject]{ + Name: "registry", + } + + value := Value{ + Value: "harbor/app:1", + Path: "containers[0]", + } + + tests := []struct { + name string + action api.ActionType + want string + }{ + { + name: "audit", + action: api.ActionTypeAudit, + want: `registry "harbor/app:1" at containers[0] matched audit namespace rule`, + }, + { + name: "deny", + action: api.ActionTypeDeny, + want: `registry "harbor/app:1" at containers[0] is denied by namespace rule`, + }, + { + name: "allow", + action: api.ActionTypeAllow, + want: `registry "harbor/app:1" at containers[0] is allowed by namespace rule`, + }, + { + name: "unknown", + action: api.ActionType("custom"), + want: `registry "harbor/app:1" at containers[0] matched namespace rule action "custom"`, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := decisionMessage(set, tt.action, value, nil) + if got != tt.want { + t.Fatalf("decisionMessage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDecisionMessage_CustomMessageReceivesMatchedValue(t *testing.T) { + t.Parallel() + + set := Set[testRule, testObject]{ + Name: "registry", + Message: func(action api.ActionType, value Value, matchedValue any) string { + return string(action) + ":" + value.Value + ":" + matchedValue.(string) + }, + } + + value := Value{ + Value: "harbor/app:1", + Path: "containers[0]", + } + + got := decisionMessage(set, api.ActionTypeAudit, value, "compiled-rule") + want := "audit:harbor/app:1:compiled-rule" + + if got != want { + t.Fatalf("decisionMessage() = %q, want %q", got, want) + } +} + +func newTestFixture() *testFixture { + return &testFixture{ + items: make(map[*api.NamespaceRuleEnforceBody][]testRule), + } +} + +func (f *testFixture) set( + name string, + message func(action api.ActionType, value Value, matchedValue any) string, +) Set[testRule, testObject] { + return Set[testRule, testObject]{ + Name: name, + EventReason: "TestReason", + Values: func(obj testObject) []Value { + return obj.Values + }, + Rules: func(enforce *api.NamespaceRuleEnforceBody) []testRule { + if enforce == nil { + return nil + } + + return f.items[enforce] + }, + Matches: func(rule testRule, value Value) (Match, error) { + if rule.Err != nil { + return Match{}, rule.Err + } + + return Match{ + Matched: rule.ShouldMatch, + MatchedValue: rule.MatchValue, + }, nil + }, + Message: message, + } +} + +func (f *testFixture) enforce( + action api.ActionType, + rules ...testRule, +) *api.NamespaceRuleEnforceBody { + body := &api.NamespaceRuleEnforceBody{ + Action: action, + } + + f.items[body] = rules + + return body +} + +func buildEnforceBodies( + fixture *testFixture, + specs []enforceSpec, +) []*api.NamespaceRuleEnforceBody { + out := make([]*api.NamespaceRuleEnforceBody, 0, len(specs)) + + for _, spec := range specs { + out = append(out, fixture.enforce(spec.action, spec.items...)) + } + + return out +} + +func assertNoBlocking(t *testing.T, evaluation *Evaluation) { + t.Helper() + + if evaluation == nil { + t.Fatalf("evaluation = nil, want non-nil") + } + + if evaluation.Blocking != nil { + t.Fatalf("Blocking = %#v, want nil", evaluation.Blocking) + } + + if err := evaluation.BlockingError(); err != nil { + t.Fatalf("BlockingError() = %v, want nil", err) + } +} + +func assertNoFinal(t *testing.T, evaluation *Evaluation) { + t.Helper() + + if evaluation == nil { + t.Fatalf("evaluation = nil, want non-nil") + } + + if evaluation.Final != nil { + t.Fatalf("Final = %#v, want nil", evaluation.Final) + } +} diff --git a/pkg/runtime/configuration/client.go b/pkg/runtime/configuration/client.go index 3aa82812..e040fc8c 100644 --- a/pkg/runtime/configuration/client.go +++ b/pkg/runtime/configuration/client.go @@ -54,7 +54,7 @@ func DefaultCapsuleConfiguration() capsulev1beta2.CapsuleConfigurationSpec { } } -func NewCapsuleConfiguration(ctx context.Context, c client.Client, rest *rest.Config, name string) Configuration { +func NewCapsuleConfiguration(ctx context.Context, c client.Client, reader client.Reader, rest *rest.Config, name string) Configuration { return &capsuleConfiguration{ client: c, rest: rest, @@ -62,7 +62,7 @@ func NewCapsuleConfiguration(ctx context.Context, c client.Client, rest *rest.Co cfg := &capsulev1beta2.CapsuleConfiguration{} key := types.NamespacedName{Name: name} - if err := c.Get(ctx, key, cfg); err == nil { + if err := reader.Get(ctx, key, cfg); err == nil { return cfg } else if !apierrors.IsNotFound(err) { panic(errors.Wrap(err, "cannot retrieve Capsule configuration with name "+name)) @@ -86,7 +86,7 @@ func NewCapsuleConfiguration(ctx context.Context, c client.Client, rest *rest.Co if err := c.Create(ctx, cfg); err != nil { if apierrors.IsAlreadyExists(err) { - if err := c.Get(ctx, key, cfg); err != nil { + if err := reader.Get(ctx, key, cfg); err != nil { panic(errors.Wrap(err, "configuration created concurrently but cannot be retrieved")) } @@ -232,6 +232,10 @@ func (c *capsuleConfiguration) Admission() capsulev1beta2.DynamicAdmission { return c.retrievalFn().Spec.Admission } +func (c *capsuleConfiguration) Events() capsulev1beta2.EventsConfiguration { + return c.retrievalFn().Spec.Events +} + func (c *capsuleConfiguration) RBAC() *capsulev1beta2.RBACConfiguration { return c.retrievalFn().Spec.RBAC } diff --git a/pkg/runtime/configuration/configuration.go b/pkg/runtime/configuration/configuration.go index ff1e2ea4..18b6d48e 100644 --- a/pkg/runtime/configuration/configuration.go +++ b/pkg/runtime/configuration/configuration.go @@ -43,6 +43,7 @@ type Configuration interface { ServiceAccountClientProperties() capsulev1beta2.ServiceAccountClient ServiceAccountClient(context.Context) (*rest.Config, error) Admission() capsulev1beta2.DynamicAdmission + Events() capsulev1beta2.EventsConfiguration RBAC() *capsulev1beta2.RBACConfiguration CacheInvalidation() metav1.Duration } diff --git a/pkg/runtime/events/actions.go b/pkg/runtime/events/actions.go index e9c4e33d..5cb99fec 100644 --- a/pkg/runtime/events/actions.go +++ b/pkg/runtime/events/actions.go @@ -11,4 +11,5 @@ const ( ActionMutated string = "Mutated" ActionValidationDenied string = "ValidationDenied" + ActionRuleAudit string = "RuleAudit" ) diff --git a/pkg/runtime/events/event_types.go b/pkg/runtime/events/event_types.go new file mode 100644 index 00000000..368d34b1 --- /dev/null +++ b/pkg/runtime/events/event_types.go @@ -0,0 +1,97 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package events + +import ( + "context" + "maps" + + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/pkg/api/meta" +) + +type LabeledEvent struct { + recorder *EventRecorder + + regarding runtime.Object + related runtime.Object + + eventType string + reason string + action string + note string + + labels map[string]string + annotations map[string]string +} + +func (r *EventRecorder) LabeledEvent( + regarding runtime.Object, + eventType string, + reason string, + action string, + note string, +) *LabeledEvent { + return &LabeledEvent{ + recorder: r, + regarding: regarding, + eventType: eventType, + reason: reason, + action: action, + note: note, + labels: map[string]string{}, + annotations: map[string]string{}, + } +} + +func (e *LabeledEvent) Emit(ctx context.Context) { + if e == nil || e.recorder == nil { + return + } + + e.recorder.emitLabeledEvent(ctx, e) +} + +func (e *LabeledEvent) WithRelated(obj runtime.Object) *LabeledEvent { + e.related = obj + + return e +} + +func (e *LabeledEvent) WithLabels(labels map[string]string) *LabeledEvent { + maps.Copy(e.labels, labels) + + return e +} + +func (e *LabeledEvent) WithAnnotations(annotations map[string]string) *LabeledEvent { + maps.Copy(e.annotations, annotations) + + return e +} + +func (e *LabeledEvent) WithTenantLabel(tnt *capsulev1beta2.Tenant) *LabeledEvent { + if tnt == nil { + return e + } + + e.labels[meta.NewTenantLabel] = tnt.Name + + return e +} + +func (e *LabeledEvent) WithRequestAnnotations(req admission.Request) *LabeledEvent { + if req.UID != "" { + e.annotations[meta.AuditRequestUID] = string(req.UID) + } + + if req.UserInfo.Username != "" { + e.annotations[meta.AuditUsername] = req.UserInfo.Username + } + + return e +} diff --git a/pkg/runtime/events/reasons.go b/pkg/runtime/events/reasons.go index 500de7c6..79fe344e 100644 --- a/pkg/runtime/events/reasons.go +++ b/pkg/runtime/events/reasons.go @@ -12,6 +12,10 @@ const ( ReasonForbiddenLabel string = "ForbiddenLabel" // ForbiddenAnnotationReason used as reason string to deny forbidden annotations. ReasonForbiddenAnnotation string = "ForbiddenAnnotation" + ReasonAdmissionFailure string = "AdmissionFailed" + + // RuleStatus. + ReasonNamespaceRuleAudit string = "NamespaceRuleAudit" // Namespace. ReasonNamespaceHijack string = "ReasonNamespacePatch" @@ -34,11 +38,15 @@ const ( ReasonMissingDeviceClass string = "MissingDeviceClass" ReasonForbiddenDeviceClass string = "ForbiddenDeviceClass" + // Nodes. + ReasonForbiddenNodeSelectorUpdate string = "ForbiddenNodeSelectorUpdate" + // Pods. ReasonMissingFQCI string = "MissingFQCI" ReasonForbiddenContainerRegistry string = "ForbiddenContainerRegistry" ReasonForbiddenPullPolicy string = "ForbiddenPullPolicy" - ReasonForbiddenPodQoSClass string = "ForbiddenPodQoSClass" + ReasonForbiddenPodQoSClass string = "ForbiddenQoSClass" + ReasonForbiddenPodScheduler string = "ForbiddenScheduler" // Ingress. ReasonWildcardDenied string = "WildcardDenied" diff --git a/pkg/runtime/events/recorder.go b/pkg/runtime/events/recorder.go new file mode 100644 index 00000000..5b985242 --- /dev/null +++ b/pkg/runtime/events/recorder.go @@ -0,0 +1,161 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package events + +import ( + "context" + "fmt" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + eventsv1 "k8s.io/api/events/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + k8sevents "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/projectcapsule/capsule/pkg/runtime/configuration" +) + +const ( + ReportingController = "controller.projectcapsule.dev" + ReportingInstance = "capsule-admission" +) + +type EventRecorder struct { + k8sevents.EventRecorder + + client client.Client + configuration configuration.Configuration + log logr.Logger +} + +func NewEventRecorder( + c client.Client, + log logr.Logger, + recorder k8sevents.EventRecorder, + configuration configuration.Configuration, +) *EventRecorder { + return &EventRecorder{ + EventRecorder: recorder, + client: c, + log: log.WithName("event-recorder"), + configuration: configuration, + } +} + +func (r *EventRecorder) emitLabeledEvent( + ctx context.Context, + e *LabeledEvent, +) { + if r == nil { + return + } + + if r.client == nil { + r.log.Error(nil, "cannot emit labeled event: client is nil") + + return + } + + if e == nil { + r.log.Error(nil, "cannot emit labeled event: event is nil") + + return + } + + if e.reason == "" { + r.log.Error(nil, "cannot emit labeled event: reason is empty") + + return + } + + if e.action == "" { + r.log.Error(nil, "cannot emit labeled event: action is empty") + + return + } + + regardingRef, metaObj, err := objectReference(e.regarding) + if err != nil { + r.log.Error(err, "cannot emit labeled event: build regarding reference") + + return + } + + namespace := metaObj.GetNamespace() + if namespace == "" { + namespace = r.configuration.Events().ClusterEventNamespace + } + + if namespace == "" { + r.log.Error(nil, "cannot emit labeled event: namespace is empty") + + return + } + + event := &eventsv1.Event{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: metaObj.GetName(), + Namespace: namespace, + Labels: e.labels, + Annotations: e.annotations, + }, + EventTime: metav1.MicroTime{Time: time.Now()}, + ReportingController: ReportingController, + ReportingInstance: ReportingInstance, + Action: e.action, + Reason: e.reason, + Regarding: regardingRef, + Note: e.note, + Type: e.eventType, + } + + if e.related != nil { + relatedRef, _, err := objectReference(e.related) + if err != nil { + r.log.Error(err, "cannot emit labeled event: build related reference") + + return + } + + event.Related = &relatedRef + } + + if err := r.client.Create(ctx, event); err != nil { + r.log.Error( + err, + "cannot emit labeled event", + "reason", e.reason, + "action", e.action, + "type", e.eventType, + "regarding", regardingRef.Name, + "namespace", namespace, + ) + + return + } +} + +func objectReference(obj runtime.Object) (corev1.ObjectReference, metav1.Object, error) { + if obj == nil { + return corev1.ObjectReference{}, nil, fmt.Errorf("object is nil") + } + + metaObj, ok := obj.(metav1.Object) + if !ok { + return corev1.ObjectReference{}, nil, fmt.Errorf("%T does not implement metav1.Object", obj) + } + + gvk := obj.GetObjectKind().GroupVersionKind() + + return corev1.ObjectReference{ + APIVersion: gvk.GroupVersion().String(), + Kind: gvk.Kind, + Namespace: metaObj.GetNamespace(), + Name: metaObj.GetName(), + UID: metaObj.GetUID(), + }, metaObj, nil +} diff --git a/pkg/runtime/handlers/handlers.go b/pkg/runtime/handlers/handlers.go index 33acc0c5..cf52b905 100644 --- a/pkg/runtime/handlers/handlers.go +++ b/pkg/runtime/handlers/handlers.go @@ -6,11 +6,11 @@ package handlers import ( "context" - "k8s.io/client-go/tools/events" "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/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/users" ) diff --git a/pkg/runtime/handlers/in_capsule_groups.go b/pkg/runtime/handlers/in_capsule_groups.go index 334f9387..8fdef5f4 100644 --- a/pkg/runtime/handlers/in_capsule_groups.go +++ b/pkg/runtime/handlers/in_capsule_groups.go @@ -7,11 +7,11 @@ package handlers import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/users" ) diff --git a/pkg/runtime/handlers/is_not_privileged.go b/pkg/runtime/handlers/is_not_privileged.go index 047cb151..50228ad2 100644 --- a/pkg/runtime/handlers/is_not_privileged.go +++ b/pkg/runtime/handlers/is_not_privileged.go @@ -6,11 +6,11 @@ package handlers import ( "context" - "k8s.io/client-go/tools/events" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" "github.com/projectcapsule/capsule/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/users" ) diff --git a/pkg/runtime/handlers/typed_tenant_object.go b/pkg/runtime/handlers/typed_tenant_object.go index 453a4675..055ba4e2 100644 --- a/pkg/runtime/handlers/typed_tenant_object.go +++ b/pkg/runtime/handlers/typed_tenant_object.go @@ -7,11 +7,11 @@ package handlers import ( "context" - "k8s.io/client-go/tools/events" "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/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/tenant" ) diff --git a/pkg/runtime/handlers/typed_tenant_ruleset.go b/pkg/runtime/handlers/typed_tenant_ruleset.go index ec571410..46b232b1 100644 --- a/pkg/runtime/handlers/typed_tenant_ruleset.go +++ b/pkg/runtime/handlers/typed_tenant_ruleset.go @@ -10,13 +10,13 @@ import ( corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/tools/events" "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/pkg/api/meta" "github.com/projectcapsule/capsule/pkg/api/rules" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/tenant" ) @@ -187,7 +187,7 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveTenant( // If not yet present, try to calculate it. func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset( ctx context.Context, - c client.Reader, + c client.Client, req admission.Request, namespace string, tnt *capsulev1beta2.Tenant, @@ -209,5 +209,5 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset( return nil, err } - return tenant.BuildNamespaceRuleBodyStatus(ctx, c, ns, tnt) + return tenant.BuildNamespaceRuleBodyStatus(c.Scheme(), ns, tnt) } diff --git a/pkg/runtime/handlers/typed_tenant_user_object.go b/pkg/runtime/handlers/typed_tenant_user_object.go index d9495eea..cc3f12e4 100644 --- a/pkg/runtime/handlers/typed_tenant_user_object.go +++ b/pkg/runtime/handlers/typed_tenant_user_object.go @@ -7,12 +7,12 @@ package handlers import ( "context" - "k8s.io/client-go/tools/events" "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/pkg/runtime/configuration" + "github.com/projectcapsule/capsule/pkg/runtime/events" "github.com/projectcapsule/capsule/pkg/tenant" "github.com/projectcapsule/capsule/pkg/users" ) diff --git a/pkg/template/render.go b/pkg/template/render.go new file mode 100644 index 00000000..1387dab2 --- /dev/null +++ b/pkg/template/render.go @@ -0,0 +1,48 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package template + +import ( + "bytes" + "fmt" + "strings" + "text/template" + + "github.com/projectcapsule/capsule/pkg/template/functions" +) + +func RenderTemplateBytes( + context map[string]any, + key MissingKeyOption, + tplBytes []byte, +) ([]byte, error) { + tmpl, err := template.New("tpl"). + Option("missingkey=" + key.String()). + Funcs(functions.ExtraFuncMap()). + Parse(string(tplBytes)) + if err != nil { + return nil, fmt.Errorf("parse template: %w", err) + } + + var rendered bytes.Buffer + if err := tmpl.Execute(&rendered, context); err != nil { + return nil, fmt.Errorf("execute template: %w", err) + } + + return rendered.Bytes(), nil +} + +func withLineNumbers(s string) string { + lines := strings.Split(s, "\n") + + width := len(fmt.Sprintf("%d", len(lines))) + + var b strings.Builder + + for i, line := range lines { + fmt.Fprintf(&b, "%*d | %s\n", width, i+1, line) + } + + return b.String() +} diff --git a/pkg/template/render_test.go b/pkg/template/render_test.go new file mode 100644 index 00000000..426423fd --- /dev/null +++ b/pkg/template/render_test.go @@ -0,0 +1,210 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package template + +import ( + "bytes" + "strings" + "testing" +) + +func TestRenderTemplateBytes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + context map[string]any + key MissingKeyOption + tpl string + want string + wantErr string + wantErrNot string + }{ + { + name: "renders values from nested context", + context: map[string]any{ + "tenant": map[string]any{ + "metadata": map[string]any{ + "name": "solar", + }, + }, + "namespace": map[string]any{ + "metadata": map[string]any{ + "name": "solar-prod", + }, + }, + }, + key: MissingKeyOption("error"), + tpl: `{{ .tenant.metadata.name }}/{{ .namespace.metadata.name }}/app:1`, + want: "solar/solar-prod/app:1", + }, + { + name: "renders map keys using index", + context: map[string]any{ + "namespace": map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{ + "registry-prefix": "harbor/team-a", + }, + }, + }, + }, + key: MissingKeyOption("error"), + tpl: `{{ index .namespace.metadata.labels "registry-prefix" }}/app:1`, + want: "harbor/team-a/app:1", + }, + { + name: "renders sprig functions", + context: map[string]any{ + "registry": "harbor", + }, + key: MissingKeyOption("error"), + tpl: `{{ .registry | upper }}/app:1`, + want: "HARBOR/app:1", + }, + { + name: "missing key returns execute error when missingkey error is enabled", + context: map[string]any{ + "namespace": map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{}, + }, + }, + }, + key: MissingKeyOption("error"), + tpl: `{{ .namespace.metadata.labels.registry }}/app:1`, + wantErr: "execute template", + }, + { + name: "missing key invalid renders placeholder-like output", + context: map[string]any{ + "namespace": map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{}, + }, + }, + }, + key: MissingKeyOption("invalid"), + tpl: `{{ .namespace.metadata.labels.registry }}/app:1`, + wantErrNot: "execute template", + want: "/app:1", + }, + { + name: "parse error is wrapped", + context: map[string]any{ + "tenant": map[string]any{ + "metadata": map[string]any{ + "name": "solar", + }, + }, + }, + key: MissingKeyOption("error"), + tpl: `{{ .tenant.metadata.name `, + wantErr: "parse template", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := RenderTemplateBytes(tt.context, tt.key, []byte(tt.tpl)) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("RenderTemplateBytes() error = nil, want error containing %q", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("RenderTemplateBytes() error = %q, want containing %q", err.Error(), tt.wantErr) + } + + return + } + + if tt.wantErrNot != "" && err != nil && strings.Contains(err.Error(), tt.wantErrNot) { + t.Fatalf("RenderTemplateBytes() error = %q, want not containing %q", err.Error(), tt.wantErrNot) + } + + if err != nil { + t.Fatalf("RenderTemplateBytes() unexpected error: %v", err) + } + + if string(got) != tt.want { + t.Fatalf("RenderTemplateBytes() = %q, want %q", string(got), tt.want) + } + }) + } +} + +func TestRenderTemplateBytes_DoesNotMutateInput(t *testing.T) { + t.Parallel() + + input := []byte(`{{ .tenant.metadata.name }}/app:1`) + original := append([]byte(nil), input...) + + _, err := RenderTemplateBytes( + map[string]any{ + "tenant": map[string]any{ + "metadata": map[string]any{ + "name": "solar", + }, + }, + }, + MissingKeyOption("error"), + input, + ) + if err != nil { + t.Fatalf("RenderTemplateBytes() unexpected error: %v", err) + } + + if !bytes.Equal(input, original) { + t.Fatalf("RenderTemplateBytes() mutated input: got %q, want %q", input, original) + } +} + +func TestWithLineNumbers(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + }{ + { + name: "single line", + in: "hello", + want: "1 | hello\n", + }, + { + name: "multiple lines", + in: "alpha\nbeta\ngamma", + want: "1 | alpha\n2 | beta\n3 | gamma\n", + }, + { + name: "trailing newline includes empty final line", + in: "alpha\nbeta\n", + want: "1 | alpha\n2 | beta\n3 | \n", + }, + { + name: "pads line numbers for double digits", + in: strings.Repeat("x\n", 10) + "x", + want: " 1 | x\n 2 | x\n 3 | x\n 4 | x\n 5 | x\n 6 | x\n 7 | x\n 8 | x\n 9 | x\n10 | x\n11 | x\n", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := withLineNumbers(tt.in) + if got != tt.want { + t.Fatalf("withLineNumbers() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/template/rules.go b/pkg/template/rules.go new file mode 100644 index 00000000..6f7c548c --- /dev/null +++ b/pkg/template/rules.go @@ -0,0 +1,43 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package template + +import ( + "fmt" + + "go.yaml.in/yaml/v2" + + "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func RenderNamespaceRuleBodies( + context map[string]any, + key MissingKeyOption, + bodies []*rules.NamespaceRuleBodyNamespace, +) ([]*rules.NamespaceRuleBodyNamespace, error) { + if len(bodies) == 0 { + return nil, nil + } + + raw, err := yaml.Marshal(bodies) + if err != nil { + return nil, fmt.Errorf("marshal namespace rule bodies: %w", err) + } + + rendered, err := RenderTemplateBytes(context, key, raw) + if err != nil { + return nil, fmt.Errorf("render namespace rule bodies template: %w", err) + } + + var out []*rules.NamespaceRuleBodyNamespace + if err := yaml.Unmarshal(rendered, &out); err != nil { + return nil, fmt.Errorf( + "unmarshal rendered namespace rule bodies: %w\nrendered template:\n%s", + err, + withLineNumbers(string(rendered)), + ) + } + + return out, nil +} diff --git a/pkg/template/rules_test.go b/pkg/template/rules_test.go new file mode 100644 index 00000000..daf1fc26 --- /dev/null +++ b/pkg/template/rules_test.go @@ -0,0 +1,327 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package template + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func TestRenderNamespaceRuleBodies(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + context map[string]any + key MissingKeyOption + bodies []*rules.NamespaceRuleBodyNamespace + assertFn func(t *testing.T, got []*rules.NamespaceRuleBodyNamespace) + wantErr string + }{ + { + name: "empty input returns nil", + key: MissingKeyOption("error"), + assertFn: func(t *testing.T, got []*rules.NamespaceRuleBodyNamespace) { + t.Helper() + + if got != nil { + t.Fatalf("RenderNamespaceRuleBodies() = %#v, want nil", got) + } + }, + }, + { + name: "renders tenant and namespace values in registry exact match", + context: map[string]any{ + "tenant": map[string]any{ + "metadata": map[string]any{ + "name": "solar", + }, + }, + "namespace": map[string]any{ + "metadata": map[string]any{ + "name": "solar-prod", + }, + }, + }, + key: MissingKeyOption("error"), + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + "{{ .tenant.metadata.name }}/{{ .namespace.metadata.name }}/app:1", + }, + }, + }, + }, + }, + }, + }, + }, + assertFn: func(t *testing.T, got []*rules.NamespaceRuleBodyNamespace) { + t.Helper() + + if len(got) != 1 { + t.Fatalf("len(got) = %d, want 1", len(got)) + } + + body := got[0] + if body == nil || body.Enforce == nil { + t.Fatalf("got[0].Enforce = nil, want rendered enforce body") + } + + if body.Enforce.Action != rules.ActionTypeAllow { + t.Fatalf("Action = %q, want %q", body.Enforce.Action, rules.ActionTypeAllow) + } + + registries := body.Enforce.Workloads.Registries + if len(registries) != 1 { + t.Fatalf("len(Registries) = %d, want 1", len(registries)) + } + + exact := registries[0].Exact + if len(exact) != 1 || exact[0] != "solar/solar-prod/app:1" { + t.Fatalf("Exact = %#v, want [solar/solar-prod/app:1]", exact) + } + }, + }, + { + name: "renders namespace labels using index", + context: map[string]any{ + "namespace": map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{ + "registry-prefix": "harbor/team-a", + }, + }, + }, + }, + key: MissingKeyOption("error"), + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + `{{ index .namespace.metadata.labels "registry-prefix" }}/app:1`, + }, + }, + }, + }, + }, + }, + }, + }, + assertFn: func(t *testing.T, got []*rules.NamespaceRuleBodyNamespace) { + t.Helper() + + exact := got[0].Enforce.Workloads.Registries[0].Exact + if len(exact) != 1 || exact[0] != "harbor/team-a/app:1" { + t.Fatalf("Exact = %#v, want [harbor/team-a/app:1]", exact) + } + }, + }, + { + name: "renders multiple bodies while preserving order", + context: map[string]any{ + "tenant": map[string]any{ + "metadata": map[string]any{ + "name": "solar", + }, + }, + }, + key: MissingKeyOption("error"), + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "{{ .tenant.metadata.name }}/allow/.*", + }, + }, + }, + }, + }, + }, + }, + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + QoSClasses: []corev1.PodQOSClass{ + corev1.PodQOSBestEffort, + }, + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + ExpressionRegex: api.ExpressionRegex{ + Expression: "{{ .tenant.metadata.name }}/deny/.*", + }, + }, + }, + }, + }, + }, + }, + }, + assertFn: func(t *testing.T, got []*rules.NamespaceRuleBodyNamespace) { + t.Helper() + + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + + if got[0].Enforce.Action != rules.ActionTypeAllow { + t.Fatalf("got[0].Action = %q, want allow", got[0].Enforce.Action) + } + + if got[1].Enforce.Action != rules.ActionTypeDeny { + t.Fatalf("got[1].Action = %q, want deny", got[1].Enforce.Action) + } + + first := got[0].Enforce.Workloads.Registries[0].Expression + if first != "solar/allow/.*" { + t.Fatalf("first expression = %q, want solar/allow/.*", first) + } + + second := got[1].Enforce.Workloads.Registries[0].Expression + if second != "solar/deny/.*" { + t.Fatalf("second expression = %q, want solar/deny/.*", second) + } + + qos := got[1].Enforce.Workloads.QoSClasses + if len(qos) != 1 || qos[0] != corev1.PodQOSBestEffort { + t.Fatalf("QoSClasses = %#v, want [%q]", qos, corev1.PodQOSBestEffort) + } + }, + }, + { + name: "missing key is wrapped with render namespace rule bodies template", + context: map[string]any{ + "namespace": map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{}, + }, + }, + }, + key: MissingKeyOption("error"), + bodies: []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + "{{ .namespace.metadata.labels.registry }}/app:1", + }, + }, + }, + }, + }, + }, + }, + }, + wantErr: "render namespace rule bodies template", + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := RenderNamespaceRuleBodies(tt.context, tt.key, tt.bodies) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("RenderNamespaceRuleBodies() error = nil, want containing %q", tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("RenderNamespaceRuleBodies() error = %q, want containing %q", err.Error(), tt.wantErr) + } + + return + } + + if err != nil { + t.Fatalf("RenderNamespaceRuleBodies() unexpected error: %v", err) + } + + if tt.assertFn != nil { + tt.assertFn(t, got) + } + }) + } +} + +func TestRenderNamespaceRuleBodies_DoesNotMutateInput(t *testing.T) { + t.Parallel() + + bodies := []*rules.NamespaceRuleBodyNamespace{ + { + Enforce: &rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Workloads: rules.NamespaceRuleEnforceWorkloadsBody{ + Registries: []rules.OCIRegistry{ + { + ExpressionMatch: api.ExpressionMatch{ + Exact: []string{ + "{{ .tenant.metadata.name }}/app:1", + }, + }, + }, + }, + }, + }, + }, + } + + got, err := RenderNamespaceRuleBodies( + map[string]any{ + "tenant": map[string]any{ + "metadata": map[string]any{ + "name": "solar", + }, + }, + }, + MissingKeyOption("error"), + bodies, + ) + if err != nil { + t.Fatalf("RenderNamespaceRuleBodies() unexpected error: %v", err) + } + + if len(got) != 1 { + t.Fatalf("len(got) = %d, want 1", len(got)) + } + + originalExact := bodies[0].Enforce.Workloads.Registries[0].Exact[0] + if originalExact != "{{ .tenant.metadata.name }}/app:1" { + t.Fatalf("input body was mutated: exact = %q", originalExact) + } + + renderedExact := got[0].Enforce.Workloads.Registries[0].Exact[0] + if renderedExact != "solar/app:1" { + t.Fatalf("rendered exact = %q, want solar/app:1", renderedExact) + } +} diff --git a/pkg/template/unstructured.go b/pkg/template/unstructured.go index 2b50c53b..39764332 100644 --- a/pkg/template/unstructured.go +++ b/pkg/template/unstructured.go @@ -8,7 +8,6 @@ import ( "errors" "fmt" "io" - "strings" "text/template" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -62,17 +61,3 @@ func RenderUnstructuredItems( return out, nil } - -func withLineNumbers(s string) string { - lines := strings.Split(s, "\n") - - width := len(fmt.Sprintf("%d", len(lines))) - - var b strings.Builder - - for i, line := range lines { - fmt.Fprintf(&b, "%*d | %s\n", width, i+1, line) - } - - return b.String() -} diff --git a/pkg/tenant/rules.go b/pkg/tenant/rules.go index dfe4a0c7..92f050c1 100644 --- a/pkg/tenant/rules.go +++ b/pkg/tenant/rules.go @@ -9,13 +9,16 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" 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/runtime/sanitize" "github.com/projectcapsule/capsule/pkg/runtime/selectors" + "github.com/projectcapsule/capsule/pkg/template" ) func GetManagedRuleStatus( @@ -38,8 +41,7 @@ func GetManagedRuleStatus( // - Matching rules are returned in the order they appear in tnt.Spec.Rules. // - Order is important because registry/QoS evaluation uses "later allow/deny wins" semantics. func BuildNamespaceRuleBodyStatus( - ctx context.Context, - c client.Reader, + scheme *runtime.Scheme, ns *corev1.Namespace, tnt *capsulev1beta2.Tenant, ) ([]*rules.NamespaceRuleBodyNamespace, error) { @@ -52,7 +54,12 @@ func BuildNamespaceRuleBodyStatus( nsLabels = labels.Set(ns.Labels) } - out := make([]*rules.NamespaceRuleBodyNamespace, 0, len(tnt.Spec.Rules)) + templateContext, err := NewTenantNamespaceContext(tnt, ns, scheme, sanitize.DefaultSanitizeOptions()) + if err != nil { + return nil, fmt.Errorf("build namespace rule template context: %w", err) + } + + selected := make([]*rules.NamespaceRuleBodyNamespace, 0, len(tnt.Spec.Rules)) for i, rule := range tnt.Spec.Rules { if rule == nil { @@ -75,7 +82,26 @@ func BuildNamespaceRuleBodyStatus( continue } - out = append(out, body.DeepCopy()) + selected = append(selected, body.DeepCopy()) + } + + rendered, err := template.RenderNamespaceRuleBodies( + templateContext, + template.MissingKeyError, + selected, + ) + if err != nil { + return nil, fmt.Errorf("render namespace rule bodies: %w", err) + } + + out := make([]*rules.NamespaceRuleBodyNamespace, 0, len(rendered)) + + for _, body := range rendered { + if body == nil || body.Enforce == nil { + continue + } + + out = append(out, body) } return out, nil diff --git a/pkg/tenant/template.go b/pkg/tenant/template.go index 96684539..728a1b95 100644 --- a/pkg/tenant/template.go +++ b/pkg/tenant/template.go @@ -28,6 +28,44 @@ func NewTenantContext(tnt *capsulev1beta2.Tenant, scheme *runtime.Scheme, opts s return context, nil } +// NewTenantNamespaceContext returns the context for the tenant and a given namespace. +func NewTenantNamespaceContext( + tnt *capsulev1beta2.Tenant, + ns *corev1.Namespace, + scheme *runtime.Scheme, + opts sanitize.SanitizeOptions, +) (map[string]any, error) { + context := make(map[string]any) + + if tnt != nil { + tntCopy := tnt.DeepCopy() + + tCtx, err := NewTenantContext(tntCopy, scheme, opts) + if err != nil { + return context, err + } + + context["tenant"] = tCtx + } + + if ns != nil { + nsCopy := ns.DeepCopy() + + if err := sanitize.SanitizeObject(nsCopy, scheme, opts); err != nil { + return context, err + } + + nsMap, err := utils.ToUnstructuredMap(nsCopy) + if err != nil { + return context, err + } + + context["namespace"] = nsMap + } + + return context, nil +} + // TemplateForTenantAndNamespace applies templatingto the provided string. func FastContextForTenantAndNamespace(tnt *capsulev1beta2.Tenant, ns *corev1.Namespace) map[string]string { values := map[string]string{}