diff --git a/api/v1beta1/tenant_types.go b/api/v1beta1/tenant_types.go index a605a5977..4fea49b7e 100644 --- a/api/v1beta1/tenant_types.go +++ b/api/v1beta1/tenant_types.go @@ -8,6 +8,7 @@ import ( "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" ) // TenantSpec defines the desired state of Tenant. @@ -39,7 +40,7 @@ type TenantSpec struct { // Specifies additional RoleBindings assigned to the Tenant. Capsule will ensure that all namespaces in the Tenant always contain the RoleBinding for the given ClusterRole. Optional. AdditionalRoleBindings []rbac.AdditionalRoleBindingsSpec `json:"additionalRoleBindings,omitempty"` // Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional. - ImagePullPolicies []api.ImagePullPolicySpec `json:"imagePullPolicies,omitempty"` + ImagePullPolicies []rules.ImagePullPolicySpec `json:"imagePullPolicies,omitempty"` // Specifies the allowed priorityClasses assigned to the Tenant. Capsule assures that all Pods resources created in the Tenant can use only one of the allowed PriorityClasses. Optional. PriorityClasses *api.AllowedListSpec `json:"priorityClasses,omitempty"` } diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 5baf39fc9..d6043ebe5 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -10,6 +10,7 @@ package v1beta1 import ( "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/rbac" + "github.com/projectcapsule/capsule/pkg/api/rules" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -331,7 +332,7 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) { } if in.ImagePullPolicies != nil { in, out := &in.ImagePullPolicies, &out.ImagePullPolicies - *out = make([]api.ImagePullPolicySpec, len(*in)) + *out = make([]rules.ImagePullPolicySpec, len(*in)) copy(*out, *in) } if in.PriorityClasses != nil { diff --git a/api/v1beta2/rule_status_type.go b/api/v1beta2/rule_status_type.go index 6802f8c57..0a48540fe 100644 --- a/api/v1beta2/rule_status_type.go +++ b/api/v1beta2/rule_status_type.go @@ -6,8 +6,8 @@ package v1beta2 import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "github.com/projectcapsule/capsule/pkg/api" "github.com/projectcapsule/capsule/pkg/api/meta" + "github.com/projectcapsule/capsule/pkg/api/rules" ) // RuleStatus contains the accumulated rules applying to namespace it's deployed in. @@ -16,9 +16,14 @@ type RuleStatusStatus struct { // ObservedGeneration is the most recent generation the controller has observed. // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` - // Managed Enforcement properties per Namespace (aggregated from rules) - //+optional - Rule api.NamespaceRuleBodyNamespace `json:"rule,omitzero"` + // Deprecated: use Rules. + // Rule contains a legacy flattened view and cannot fully represent action-aware rules. + // +optional + Rule rules.NamespaceRuleBodyNamespace `json:"rule,omitzero"` + // Rules contains the effective namespace rules after tenant rule selection. + // Order is preserved from the originating Tenant rules. + // +optional + Rules []*rules.NamespaceRuleBodyNamespace `json:"rules,omitempty"` // Conditions Conditions meta.ConditionList `json:"conditions"` } @@ -26,15 +31,14 @@ type RuleStatusStatus struct { // +kubebuilder:object:root=true // +kubebuilder:storageversion // +kubebuilder:subresource:status -// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age" +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].status",description="Ready Status" +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[?(@.type==\"Ready\")].message",description="Ready Message" type RuleStatus struct { - metav1.TypeMeta `json:",inline"` - - // +optional + metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitzero"` // +optional - Spec []*api.NamespaceRuleBodyNamespace `json:"spec,omitzero"` + Spec []*rules.NamespaceRuleBodyNamespace `json:"spec,omitzero"` // +optional Status RuleStatusStatus `json:"status,omitzero"` diff --git a/api/v1beta2/tenant_status.go b/api/v1beta2/tenant_status.go index 2a229c835..e2eecc15c 100644 --- a/api/v1beta2/tenant_status.go +++ b/api/v1beta2/tenant_status.go @@ -6,9 +6,9 @@ package v1beta2 import ( k8stypes "k8s.io/apimachinery/pkg/types" - "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" ) // +kubebuilder:validation:Enum=Cordoned;Active;Terminating @@ -72,7 +72,7 @@ type TenantStatusRuleStatusItem struct { type TenantStatusNamespaceEnforcement struct { // Registries which are allowed within this namespace - Registries []api.OCIRegistry `json:"registry,omitempty"` + Registries []rules.OCIRegistry `json:"registry,omitempty"` } type TenantStatusNamespaceMetadata struct { diff --git a/api/v1beta2/tenant_types.go b/api/v1beta2/tenant_types.go index c80b61347..35da62e0c 100644 --- a/api/v1beta2/tenant_types.go +++ b/api/v1beta2/tenant_types.go @@ -13,6 +13,7 @@ import ( "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" "github.com/projectcapsule/capsule/pkg/runtime/selectors" ) @@ -32,7 +33,7 @@ type TenantSpec struct { // // Read More: https://projectcapsule.dev/docs/tenants/rules/ //+optional - Rules []*api.NamespaceRuleBodyTenant `json:"rules,omitzero"` + Rules []*rules.NamespaceRuleBodyTenant `json:"rules,omitzero"` // Specifies the owners of the Tenant. // Optional @@ -96,7 +97,7 @@ type TenantSpec struct { // Deprecated: Use Enforcement.Registries instead // // Specify the allowed values for the imagePullPolicies option in Pod resources. Capsule assures that all Pod resources created in the Tenant can use only one of the allowed policy. Optional. - ImagePullPolicies []api.ImagePullPolicySpec `json:"imagePullPolicies,omitempty"` + ImagePullPolicies []rules.ImagePullPolicySpec `json:"imagePullPolicies,omitempty"` // Deprecated: Use Tenant Replications instead (https://projectcapsule.dev/docs/replications/) // diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index b68d5fe0e..ce1c495cd 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -11,6 +11,7 @@ import ( "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" "github.com/projectcapsule/capsule/pkg/runtime/admission" "github.com/projectcapsule/capsule/pkg/runtime/selectors" "github.com/projectcapsule/capsule/pkg/template" @@ -1575,11 +1576,11 @@ func (in *RuleStatus) DeepCopyInto(out *RuleStatus) { in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) if in.Spec != nil { in, out := &in.Spec, &out.Spec - *out = make([]*api.NamespaceRuleBodyNamespace, len(*in)) + *out = make([]*rules.NamespaceRuleBodyNamespace, len(*in)) for i := range *in { if (*in)[i] != nil { in, out := &(*in)[i], &(*out)[i] - *out = new(api.NamespaceRuleBodyNamespace) + *out = new(rules.NamespaceRuleBodyNamespace) (*in).DeepCopyInto(*out) } } @@ -1641,6 +1642,17 @@ func (in *RuleStatusList) DeepCopyObject() runtime.Object { func (in *RuleStatusStatus) DeepCopyInto(out *RuleStatusStatus) { *out = *in in.Rule.DeepCopyInto(&out.Rule) + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]*rules.NamespaceRuleBodyNamespace, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(rules.NamespaceRuleBodyNamespace) + (*in).DeepCopyInto(*out) + } + } + } if in.Conditions != nil { in, out := &in.Conditions, &out.Conditions *out = make(meta.ConditionList, len(*in)) @@ -2108,11 +2120,11 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) { in.Permissions.DeepCopyInto(&out.Permissions) if in.Rules != nil { in, out := &in.Rules, &out.Rules - *out = make([]*api.NamespaceRuleBodyTenant, len(*in)) + *out = make([]*rules.NamespaceRuleBodyTenant, len(*in)) for i := range *in { if (*in)[i] != nil { in, out := &(*in)[i], &(*out)[i] - *out = new(api.NamespaceRuleBodyTenant) + *out = new(rules.NamespaceRuleBodyTenant) (*in).DeepCopyInto(*out) } } @@ -2188,7 +2200,7 @@ func (in *TenantSpec) DeepCopyInto(out *TenantSpec) { } if in.ImagePullPolicies != nil { in, out := &in.ImagePullPolicies, &out.ImagePullPolicies - *out = make([]api.ImagePullPolicySpec, len(*in)) + *out = make([]rules.ImagePullPolicySpec, len(*in)) copy(*out, *in) } in.NetworkPolicies.DeepCopyInto(&out.NetworkPolicies) @@ -2263,7 +2275,7 @@ func (in *TenantStatusNamespaceEnforcement) DeepCopyInto(out *TenantStatusNamesp *out = *in if in.Registries != nil { in, out := &in.Registries, &out.Registries - *out = make([]api.OCIRegistry, len(*in)) + *out = make([]rules.OCIRegistry, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } diff --git a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml index d4c00e8ea..d86e11b1e 100644 --- a/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml +++ b/charts/capsule/crds/capsule.clastix.io_rulestatuses.yaml @@ -15,10 +15,14 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: - - description: Age - jsonPath: .metadata.creationTimestamp - name: Age - type: date + - description: Ready Status + jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - description: Ready Message + jsonPath: .status.conditions[?(@.type=="Ready")].message + name: Message + type: string name: v1beta2 schema: openAPIV3Schema: @@ -48,12 +52,31 @@ spec: enforce: description: Enforcement for given rule properties: + action: + default: deny + description: |- + Declare the action being performed on the enforcement rule: + deny: On match, deny admission request + allow: On match, allowed admission request + audit: On match, audit (post event) of admission request + enum: + - allow + - deny + - audit + type: string registries: description: |- 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 items: properties: + exp: + description: Expression used to evaluate regex + type: string + negate: + default: false + description: Negate regular Expression + type: boolean policy: description: Allowed PullPolicy for the given registry. Supplying no value allows all policies. @@ -63,8 +86,10 @@ spec: type: string type: array url: - description: OCI Registry endpoint, is treated as regular - expression. + description: |- + Deprecated: Use exp field + + OCI Registry endpoint, is treated as regular expression. type: string validation: default: @@ -77,8 +102,6 @@ spec: - pod/volumes type: string type: array - required: - - url type: object type: array type: object @@ -151,18 +174,38 @@ spec: format: int64 type: integer rule: - description: Managed Enforcement properties per Namespace (aggregated - from rules) + description: |- + Deprecated: use Rules. + Rule contains a legacy flattened view and cannot fully represent action-aware rules. properties: enforce: description: Enforcement for given rule properties: + action: + default: deny + description: |- + Declare the action being performed on the enforcement rule: + deny: On match, deny admission request + allow: On match, allowed admission request + audit: On match, audit (post event) of admission request + enum: + - allow + - deny + - audit + type: string registries: description: |- 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 items: properties: + exp: + description: Expression used to evaluate regex + type: string + negate: + default: false + description: Negate regular Expression + type: boolean policy: description: Allowed PullPolicy for the given registry. Supplying no value allows all policies. @@ -172,8 +215,10 @@ spec: type: string type: array url: - description: OCI Registry endpoint, is treated as regular - expression. + description: |- + Deprecated: Use exp field + + OCI Registry endpoint, is treated as regular expression. type: string validation: default: @@ -186,15 +231,81 @@ spec: - pod/volumes type: string type: array - required: - - url type: object type: array type: object type: object + rules: + description: |- + Rules contains the effective namespace rules after tenant rule selection. + Order is preserved from the originating Tenant rules. + items: + description: For future implementation where users might manage + RuleStatus CRs themselves + properties: + enforce: + description: Enforcement for given rule + properties: + action: + default: deny + description: |- + Declare the action being performed on the enforcement rule: + deny: On match, deny admission request + allow: On match, allowed admission request + audit: On match, audit (post event) of admission request + enum: + - allow + - deny + - audit + type: string + registries: + description: |- + 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 + items: + properties: + exp: + description: Expression used to evaluate regex + type: string + negate: + default: false + description: Negate regular Expression + type: boolean + policy: + description: Allowed PullPolicy for the given registry. + Supplying no value allows all policies. + items: + description: PullPolicy describes a policy for if/when + to pull a container image + type: string + type: array + url: + description: |- + Deprecated: Use exp field + + OCI Registry endpoint, is treated as regular expression. + type: string + validation: + default: + - pod/images + - pod/volumes + description: Requesting Resources + items: + enum: + - pod/images + - pod/volumes + type: string + type: array + type: object + type: array + type: object + type: object + type: array required: - conditions type: object + required: + - metadata type: object served: true storage: true diff --git a/charts/capsule/crds/capsule.clastix.io_tenants.yaml b/charts/capsule/crds/capsule.clastix.io_tenants.yaml index a78cb0d41..828df24f4 100644 --- a/charts/capsule/crds/capsule.clastix.io_tenants.yaml +++ b/charts/capsule/crds/capsule.clastix.io_tenants.yaml @@ -2503,12 +2503,31 @@ spec: enforce: description: Enforcement for given rule properties: + action: + default: deny + description: |- + Declare the action being performed on the enforcement rule: + deny: On match, deny admission request + allow: On match, allowed admission request + audit: On match, audit (post event) of admission request + enum: + - allow + - deny + - audit + type: string registries: description: |- 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 items: properties: + exp: + description: Expression used to evaluate regex + type: string + negate: + default: false + description: Negate regular Expression + type: boolean policy: description: Allowed PullPolicy for the given registry. Supplying no value allows all policies. @@ -2518,8 +2537,10 @@ spec: type: string type: array url: - description: OCI Registry endpoint, is treated as - regular expression. + description: |- + Deprecated: Use exp field + + OCI Registry endpoint, is treated as regular expression. type: string validation: default: @@ -2532,8 +2553,6 @@ spec: - pod/volumes type: string type: array - required: - - url type: object type: array type: object @@ -3091,6 +3110,13 @@ spec: description: Registries which are allowed within this namespace items: properties: + exp: + description: Expression used to evaluate regex + type: string + negate: + default: false + description: Negate regular Expression + type: boolean policy: description: Allowed PullPolicy for the given registry. Supplying no value allows all policies. @@ -3100,8 +3126,10 @@ spec: type: string type: array url: - description: OCI Registry endpoint, is treated as - regular expression. + description: |- + Deprecated: Use exp field + + OCI Registry endpoint, is treated as regular expression. type: string validation: default: @@ -3114,8 +3142,6 @@ spec: - pod/volumes type: string type: array - required: - - url type: object type: array type: object diff --git a/cmd/main.go b/cmd/main.go index 6f3a420a2..b8264e979 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -503,7 +503,8 @@ func main() { // Initialize Caches impersonationCache := cache.NewImpersonationCache() - registryCache := cache.NewRegistryRuleSetCache() + regexCache := cache.NewRegexCache() + registryCache := cache.NewRegistryRuleSetCache(regexCache) customQuotaQuantityCache := cache.NewQuantityCache[string]() jsonPathCache := cache.NewJSONPathCache() targetsCache := cache.NewCompiledTargetsCache[string]() @@ -749,6 +750,7 @@ func main() { RegistryCache: registryCache, JSONPathCache: jsonPathCache, TargetsCache: targetsCache, + RegexCache: regexCache, } if err := localInvalidator.SetupWithManager(manager, controllerConfig); err != nil { diff --git a/e2e/rules_registry_test.go b/e2e/rules_registry_test.go index 7253a97a0..752706869 100644 --- a/e2e/rules_registry_test.go +++ b/e2e/rules_registry_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2023 Project Capsule Authors. +// Copyright 2020-2026 Project Capsule Authors. // SPDX-License-Identifier: Apache-2.0 package e2e @@ -21,82 +21,141 @@ import ( "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 a Container Registry", Ordered, Label("tenant", "rules", "images", "registry"), func() { - tnt := &capsulev1beta2.Tenant{ - ObjectMeta: metav1.ObjectMeta{ - Name: "e2e-rule-registry", - Labels: map[string]string{ - "env": "e2e", +var _ = Describe("enforcing container registry namespace rules", Ordered, Label("tenant", "rules", "images", "registry"), func() { + const ownerName = "e2e-rules-registry" + + var tnt *capsulev1beta2.Tenant + + newTenant := func() *capsulev1beta2.Tenant { + return &capsulev1beta2.Tenant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "e2e-rule-registry", + Labels: map[string]string{ + "env": "e2e", + }, }, - }, - Spec: capsulev1beta2.TenantSpec{ - Owners: rbac.OwnerListSpec{ - { - CoreOwnerSpec: rbac.CoreOwnerSpec{ - UserSpec: rbac.UserSpec{ - Name: "e2e-rules-registry", - Kind: "User", + Spec: capsulev1beta2.TenantSpec{ + Owners: rbac.OwnerListSpec{ + { + CoreOwnerSpec: rbac.CoreOwnerSpec{ + UserSpec: rbac.UserSpec{ + Name: ownerName, + Kind: "User", + }, }, }, }, - }, - Rules: []*api.NamespaceRuleBodyTenant{ - { - NamespaceRuleBodyNamespace: api.NamespaceRuleBodyNamespace{ - Enforce: api.NamespaceRuleEnforceBody{ - Registries: []api.OCIRegistry{ - // Global: allow any registry, but require PullPolicy Always (images+volumes) - { - Registry: ".*", - Validation: []api.RegistryValidationTarget{ - api.ValidateImages, - api.ValidateVolumes, + Rules: []*rules.NamespaceRuleBodyTenant{ + { + NamespaceRuleBodyNamespace: rules.NamespaceRuleBodyNamespace{ + Enforce: rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Registries: []rules.OCIRegistry{ + { + Registry: "harbor/.*", + Validation: []rules.RegistryValidationTarget{ + rules.ValidateImages, + rules.ValidateVolumes, + }, }, - Policy: []corev1.PullPolicy{corev1.PullAlways}, }, - // More specific harbor rule (no policy override => should NOT remove Always restriction) - { - Registry: "harbor/.*", - Validation: []api.RegistryValidationTarget{ - api.ValidateImages, - api.ValidateVolumes, + }, + }, + }, + { + NamespaceRuleBodyNamespace: rules.NamespaceRuleBodyNamespace{ + Enforce: rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Registries: []rules.OCIRegistry{ + { + Registry: "harbor/customer/.*", + Policy: []corev1.PullPolicy{ + corev1.PullNever, + }, + Validation: []rules.RegistryValidationTarget{ + rules.ValidateImages, + rules.ValidateVolumes, + }, }, }, }, }, }, - }, - { - NamespaceSelector: &metav1.LabelSelector{ - MatchLabels: map[string]string{ - "environment": "prod", + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "environment": "prod", + }, + }, + NamespaceRuleBodyNamespace: rules.NamespaceRuleBodyNamespace{ + Enforce: rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAllow, + Registries: []rules.OCIRegistry{ + { + Registry: "harbor/customer/prod-image/.*", + Validation: []rules.RegistryValidationTarget{ + rules.ValidateImages, + rules.ValidateVolumes, + }, + }, + }, + }, }, }, - NamespaceRuleBodyNamespace: api.NamespaceRuleBodyNamespace{ - Enforce: api.NamespaceRuleEnforceBody{ - Registries: []api.OCIRegistry{ - // Prod-only special-case - { - Registry: "harbor/production-image/.*", - Validation: []api.RegistryValidationTarget{ - api.ValidateImages, - api.ValidateVolumes, + { + NamespaceRuleBodyNamespace: rules.NamespaceRuleBodyNamespace{ + Enforce: rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeAudit, + Registries: []rules.OCIRegistry{ + { + Registry: "audit/.*", + Validation: []rules.RegistryValidationTarget{ + rules.ValidateImages, + rules.ValidateVolumes, + }, + }, + }, + }, + }, + }, + { + NamespaceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "negate": "true", + }, + }, + NamespaceRuleBodyNamespace: rules.NamespaceRuleBodyNamespace{ + Enforce: rules.NamespaceRuleEnforceBody{ + Action: rules.ActionTypeDeny, + Registries: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: "trusted/.*", + Negate: true, + }, + Validation: []rules.RegistryValidationTarget{ + rules.ValidateImages, + }, }, - Policy: []corev1.PullPolicy{corev1.PullAlways}, }, }, }, }, }, }, - }, + } } - // ---- Small local helpers (keep e2e readable) ---- + type expectedStatusRule struct { + action rules.ActionType + expressions []string + negated []bool + } - expectNamespaceStatusRegistries := func(nsName string, want []string) { + expectNamespaceStatusRules := func(nsName string, want []expectedStatusRule) { Eventually(func(g Gomega) { nsStatus := &capsulev1beta2.RuleStatus{} g.Expect(k8sClient.Get( @@ -105,12 +164,23 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul nsStatus, )).To(Succeed()) - got := make([]string, 0, len(nsStatus.Status.Rule.Enforce.Registries)) - for _, r := range nsStatus.Status.Rule.Enforce.Registries { - got = append(got, r.Registry) - } + g.Expect(nsStatus.Status.Rules).To(HaveLen(len(want))) - g.Expect(got).To(Equal(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.Registries).To(HaveLen(len(expected.expressions))) + + for j, expectedExpression := range expected.expressions { + expr := gotRule.Enforce.Registries[j].Expression() + g.Expect(expr.Expression).To(Equal(expectedExpression)) + + if len(expected.negated) > j { + g.Expect(expr.Negate).To(Equal(expected.negated[j])) + } + } + } }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) } @@ -122,13 +192,13 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul } Eventually(func() error { - // unique name per attempt to avoid AlreadyExists p := base.DeepCopy() - p.Name = fmt.Sprintf("%s-%d", baseName, int(time.Now().UnixNano()%1e6)) + 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") } @@ -137,11 +207,12 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul } msg := err.Error() - for _, s := range substrings { - if !strings.Contains(msg, s) { - return fmt.Errorf("expected error to contain %q, got: %s", s, msg) + 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()) } @@ -149,13 +220,93 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul 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()) } + updatePodAndExpectDenied := func(cs kubernetes.Interface, nsName string, podName string, mutate func(*corev1.Pod), substrings ...string) { + Eventually(func() error { + pod, err := cs.CoreV1().Pods(nsName).Get(context.Background(), podName, metav1.GetOptions{}) + if err != nil { + return err + } + + mutate(pod) + + _, err = cs.CoreV1().Pods(nsName).Update(context.Background(), pod, metav1.UpdateOptions{}) + if err == nil { + return fmt.Errorf("expected update to be denied, but it succeeded") + } + + msg := err.Error() + for _, substring := range substrings { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } + } + + return nil + }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) + } + + restrictedPod := func(name string, image string, pullPolicy corev1.PullPolicy) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: corev1.PodSpec{ + SecurityContext: nobodyPodSecurityContext(), + Containers: []corev1.Container{ + { + Name: "c", + Image: image, + ImagePullPolicy: pullPolicy, + SecurityContext: restrictedContainerSecurityContext(), + }, + }, + }, + } + } + + 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{}) + if err != nil { + return err + } + + for _, event := range events.Items { + if event.InvolvedObject.Name != podName { + continue + } + + msg := event.Message + 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()) + } + JustBeforeEach(func() { + tnt = newTenant() + EventuallyCreation(func() error { tnt.ResourceVersion = "" + return k8sClient.Create(context.TODO(), tnt) }).Should(Succeed()) @@ -166,36 +317,91 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul EventuallyDeletion(tnt) }) - It("aggregates enforcement rules into NamespaceStatus for a non-prod namespace", func() { + It("stores matching tenant rules as independent status rule blocks", 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()) - // Non-prod: should include only the global rule body (two registries in order) - expectNamespaceStatusRegistries(ns.GetName(), []string{ - ".*", - "harbor/.*", + expectNamespaceStatusRules(ns.GetName(), []expectedStatusRule{ + { + action: rules.ActionTypeAllow, + expressions: []string{"harbor/.*"}, + }, + { + action: rules.ActionTypeDeny, + expressions: []string{"harbor/customer/.*"}, + }, + { + action: rules.ActionTypeAudit, + expressions: []string{"audit/.*"}, + }, + }) + }) + + It("stores namespace-selector matched rules as additional independent status rule blocks", func() { + ns := NewNamespace("", map[string]string{ + "environment": "prod", + meta.TenantLabel: tnt.GetName(), }) - // Sanity: we can still create a trivial pod with explicit Always (since global allows all registries) - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "sanity"}, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "c", Image: "gcr.io/google_containers/pause-amd64:3.0", ImagePullPolicy: corev1.PullAlways}, - }, + 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/.*"}, }, - } - createPodAndExpectAllowed(cs, ns.Name, pod) + { + action: rules.ActionTypeDeny, + expressions: []string{"harbor/customer/.*"}, + }, + { + action: rules.ActionTypeAllow, + expressions: []string{"harbor/customer/prod-image/.*"}, + }, + { + action: rules.ActionTypeAudit, + expressions: []string{"audit/.*"}, + }, + }) }) - It("aggregates enforcement rules into NamespaceStatus for a prod namespace", func() { + It("stores namespace-selector matched negated regex rules as independent status rule blocks", func() { + ns := NewNamespace("", map[string]string{ + "negate": "true", + meta.TenantLabel: tnt.GetName(), + }) + + 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, + expressions: []string{"harbor/customer/.*"}, + }, + { + action: rules.ActionTypeAudit, + expressions: []string{"audit/.*"}, + }, + { + action: rules.ActionTypeDeny, + expressions: []string{"trusted/.*"}, + negated: []bool{true}, + }, + }) + }) + + It("allows a broad matching allow rule", func() { ns := NewNamespace("", map[string]string{ - "environment": "prod", meta.TenantLabel: tnt.GetName(), }) @@ -204,138 +410,127 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) - // Prod: should include global + prod rule (3 registries in order) - expectNamespaceStatusRegistries(ns.GetName(), []string{ - ".*", - "harbor/.*", - "harbor/production-image/.*", - }) + pod := restrictedPod("harbor-allowed", "harbor/platform/app:1", corev1.PullIfNotPresent) - // Sanity allow with Always - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-sanity"}, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "c", Image: "harbor/production-image/app:1", ImagePullPolicy: corev1.PullAlways}, - }, - }, - } createPodAndExpectAllowed(cs, ns.Name, pod) }) - It("denies a container image when pullPolicy is not explicitly set under restriction (dev)", func() { - ns := NewNamespace("", - map[string]string{ - meta.TenantLabel: tnt.GetName(), - }, - ) + 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(), + }) + cs := ownerClient(tnt.Spec.Owners[0].UserSpec) NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) - // No ImagePullPolicy set => "" => should be denied because global rule restricts policy to Always - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "no-pullpolicy"}, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "c", Image: "gcr.io/google_containers/pause-amd64:3.0"}, - }, - }, - } + pod := restrictedPod("customer-denied", "harbor/customer/app:1", corev1.PullIfNotPresent) createPodAndExpectDenied(cs, ns.Name, pod, - "uses pullPolicy=IfNotPresent", - "not allowed", - "allowed: Always", + "containers[0]", + "harbor/customer/app:1", + "denied", + "harbor/customer/.*", ) }) - It("denies a harbor image with pullPolicy IfNotPresent because global Always must still apply (dev)", func() { + It("denies an update when the new image matches a later specific 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 := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "harbor-wrong-policy"}, - Spec: corev1.PodSpec{ - SecurityContext: nobodyPodSecurityContext(), - Containers: []corev1.Container{ - { - Name: "c", - Image: "harbor/some-team/app:1", - ImagePullPolicy: corev1.PullIfNotPresent, - SecurityContext: restrictedContainerSecurityContext(), - }, - }, - }, - } + pod := restrictedPod("update-to-denied", "harbor/platform/app:1", corev1.PullIfNotPresent) - createPodAndExpectDenied(cs, ns.Name, pod, - "pullPolicy=IfNotPresent", - "not allowed", - "allowed:", + createPodAndExpectAllowed(cs, ns.Name, pod) + + updatePodAndExpectDenied(cs, ns.Name, pod.Name, func(pod *corev1.Pod) { + pod.Spec.Containers[0].Image = "harbor/customer/adad:1" + }, + "containers[0]", + "harbor/customer/adad:1", + "denied", + "harbor/customer/.*", ) }) - It("allows a harbor image with pullPolicy Always (dev)", func() { + It("allows a later more specific allow rule to override an earlier deny rule in a selected namespace", func() { ns := NewNamespace("", map[string]string{ + "environment": "prod", 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 := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "harbor-always"}, - Spec: corev1.PodSpec{ - SecurityContext: nobodyPodSecurityContext(), - Containers: []corev1.Container{ - { - Name: "c", - Image: "harbor/some-team/app:1", - ImagePullPolicy: corev1.PullAlways, - SecurityContext: restrictedContainerSecurityContext(), - }, - }, - }, - } + denied := restrictedPod("prod-customer-denied", "harbor/customer/other-image/app:1", corev1.PullIfNotPresent) + createPodAndExpectDenied(cs, ns.Name, denied, + "containers[0]", + "harbor/customer/other-image/app:1", + "denied", + "harbor/customer/.*", + ) + + allowed := restrictedPod("prod-customer-allowed", "harbor/customer/prod-image/app:1", corev1.PullIfNotPresent) + 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/team/app:1", corev1.PullIfNotPresent) createPodAndExpectAllowed(cs, ns.Name, pod) + + expectAuditEvent(cs, ns.Name, pod.Name, + "matched audit registry rule", + "audit/.*", + ) }) - It("denies initContainers when they violate policy (dev) and includes the correct location in the message", func() { + It("evaluates init containers with the same multi-rule action semantics", 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 := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "init-deny"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "init-denied", + }, Spec: corev1.PodSpec{ SecurityContext: nobodyPodSecurityContext(), InitContainers: []corev1.Container{ { Name: "init", - Image: "harbor/some-team/init:1", - ImagePullPolicy: corev1.PullIfNotPresent, // should be denied + Image: "harbor/customer/init:1", + ImagePullPolicy: corev1.PullIfNotPresent, SecurityContext: restrictedContainerSecurityContext(), }, }, - Containers: []corev1.Container{ { Name: "c", - Image: "harbor/some-team/app:1", - ImagePullPolicy: corev1.PullAlways, + Image: "harbor/platform/app:1", + ImagePullPolicy: corev1.PullIfNotPresent, SecurityContext: restrictedContainerSecurityContext(), }, }, @@ -344,29 +539,33 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul createPodAndExpectDenied(cs, ns.Name, pod, "initContainers[0]", - "pullPolicy=IfNotPresent", - "allowed:", + "harbor/customer/init:1", + "denied", + "harbor/customer/.*", ) }) - It("denies volume image pullPolicy if not allowed (dev)", Label("skip-on-openshift"), func() { + It("evaluates image volumes with the same multi-rule action semantics", Label("skip-on-openshift"), 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 := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "volume-deny"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "volume-denied", + }, Spec: corev1.PodSpec{ SecurityContext: nobodyPodSecurityContext(), Containers: []corev1.Container{ { Name: "c", - Image: "harbor/some-team/app:1", - ImagePullPolicy: corev1.PullAlways, + Image: "harbor/platform/app:1", + ImagePullPolicy: corev1.PullIfNotPresent, SecurityContext: restrictedContainerSecurityContext(), }, }, @@ -375,8 +574,8 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul Name: "imgvol", VolumeSource: corev1.VolumeSource{ Image: &corev1.ImageVolumeSource{ - Reference: "harbor/some-team/volimg:1", - PullPolicy: corev1.PullIfNotPresent, // should be denied + Reference: "harbor/customer/volume:1", + PullPolicy: corev1.PullIfNotPresent, }, }, }, @@ -386,14 +585,14 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul createPodAndExpectDenied(cs, ns.Name, pod, "volumes[0](imgvol)", - "pullPolicy=IfNotPresent", - "allowed:", + "harbor/customer/volume:1", + "denied", + "harbor/customer/.*", ) }) - It("allows prod-specific image only with Always, still enforcing global policy", func() { + It("denies adding an ephemeral container when it matches the later specific deny rule", func() { ns := NewNamespace("", map[string]string{ - "environment": "prod", meta.TenantLabel: tnt.GetName(), }) @@ -402,88 +601,33 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul NamespaceCreation(ns, tnt.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed()) NamespaceIsPartOfTenant(tnt, ns).Should(Succeed()) - // Wrong policy => denied - bad := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-bad"}, - Spec: corev1.PodSpec{ - SecurityContext: nobodyPodSecurityContext(), - Containers: []corev1.Container{ - {Name: "c", Image: "harbor/production-image/app:1", ImagePullPolicy: corev1.PullNever, SecurityContext: restrictedContainerSecurityContext()}, - }, - }, - } - createPodAndExpectDenied(cs, ns.Name, bad, - "pullPolicy=Never", - "allowed:", - ) - - // Correct policy => allowed - good := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "prod-good"}, - Spec: corev1.PodSpec{ - SecurityContext: nobodyPodSecurityContext(), - Containers: []corev1.Container{ - {Name: "c", Image: "harbor/production-image/app:1", ImagePullPolicy: corev1.PullAlways, SecurityContext: restrictedContainerSecurityContext()}, - }, - }, - } - createPodAndExpectAllowed(cs, ns.Name, good) - }) - - It("denies adding an ephemeral container with wrong pullPolicy on UPDATE", 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()) - - expectNamespaceStatusRegistries(ns.GetName(), []string{".*", "harbor/.*"}) - cleanupRBAC := GrantEphemeralContainersUpdate(ns.Name, tnt.Spec.Owners[0].UserSpec.Name) defer cleanupRBAC() - // Create an allowed pod - pod := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "base"}, - Spec: corev1.PodSpec{ - SecurityContext: nobodyPodSecurityContext(), - Containers: []corev1.Container{ - { - Name: "c", - Image: "harbor/some-team/app:1", - ImagePullPolicy: corev1.PullAlways, - SecurityContext: restrictedContainerSecurityContext(), - }, - }, - }, - } + pod := restrictedPod("base", "harbor/platform/app:1", corev1.PullIfNotPresent) createPodAndExpectAllowed(cs, ns.Name, pod) - // Now attempt to add an ephemeral container with IfNotPresent (should be denied) - ephem := corev1.EphemeralContainer{ + ephemeral := corev1.EphemeralContainer{ EphemeralContainerCommon: corev1.EphemeralContainerCommon{ Name: "debug", - Image: "harbor/some-team/debug:1", + Image: "harbor/customer/debug:1", ImagePullPolicy: corev1.PullIfNotPresent, SecurityContext: restrictedContainerSecurityContext(), }, } Eventually(func() error { - // Must use the ephemeralcontainers subresource - cur, err := cs.CoreV1().Pods(ns.Name).Get(context.Background(), pod.Name, metav1.GetOptions{}) + current, err := cs.CoreV1().Pods(ns.Name).Get(context.Background(), pod.Name, metav1.GetOptions{}) if err != nil { return err } - cur.Spec.EphemeralContainers = append(cur.Spec.EphemeralContainers, ephem) + current.Spec.EphemeralContainers = append(current.Spec.EphemeralContainers, ephemeral) _, err = cs.CoreV1().Pods(ns.Name).UpdateEphemeralContainers( context.Background(), - cur.Name, - cur, + current.Name, + current, metav1.UpdateOptions{}, ) if err == nil { @@ -491,73 +635,18 @@ var _ = Describe("enforcing a Container Registry", Ordered, Label("tenant", "rul } msg := err.Error() - // Your webhook reports "ephemeralContainers[0]" location - if !strings.Contains(msg, "ephemeralContainers") || !strings.Contains(msg, "pullPolicy=IfNotPresent") { - return fmt.Errorf("unexpected error: %v", err) + for _, substring := range []string{ + "ephemeralContainers[0]", + "harbor/customer/debug:1", + "denied", + "harbor/customer/.*", + } { + if !strings.Contains(msg, substring) { + return fmt.Errorf("expected error to contain %q, got: %s", substring, msg) + } } + return nil }, defaultTimeoutInterval, defaultPollInterval).Should(Succeed()) }) - - It("denies a pod when volume image reference changes to a disallowed pullPolicy (recreate)", Label("skip-on-openshift"), 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()) - - expectNamespaceStatusRegistries(ns.GetName(), []string{".*", "harbor/.*"}) - - pod1 := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "vol-ok"}, - Spec: corev1.PodSpec{ - SecurityContext: nobodyPodSecurityContext(), - Containers: []corev1.Container{ - {Name: "c", Image: "harbor/some-team/app:1", ImagePullPolicy: corev1.PullAlways, SecurityContext: restrictedContainerSecurityContext()}, - }, - Volumes: []corev1.Volume{ - { - Name: "imgvol", - VolumeSource: corev1.VolumeSource{ - Image: &corev1.ImageVolumeSource{ - Reference: "harbor/some-team/volimg:1", - PullPolicy: corev1.PullAlways, - }, - }, - }, - }, - }, - } - createPodAndExpectAllowed(cs, ns.Name, pod1) - - pod2 := &corev1.Pod{ - ObjectMeta: metav1.ObjectMeta{Name: "vol-bad"}, - Spec: corev1.PodSpec{ - SecurityContext: nobodyPodSecurityContext(), - Containers: []corev1.Container{ - {Name: "c", Image: "harbor/some-team/app:1", ImagePullPolicy: corev1.PullAlways, SecurityContext: restrictedContainerSecurityContext()}, - }, - Volumes: []corev1.Volume{ - { - Name: "imgvol", - VolumeSource: corev1.VolumeSource{ - Image: &corev1.ImageVolumeSource{ - Reference: "harbor/some-team/volimg:2", - PullPolicy: corev1.PullIfNotPresent, - }, - }, - }, - }, - }, - } - - createPodAndExpectDenied(cs, ns.Name, pod2, - "volumes[0](imgvol)", - "pullPolicy=IfNotPresent", - "allowed:", - ) - }) - }) diff --git a/e2e/sa_promotion_test.go b/e2e/sa_promotion_test.go index fa2bd9fa1..3b1127c2d 100644 --- a/e2e/sa_promotion_test.go +++ b/e2e/sa_promotion_test.go @@ -20,9 +20,9 @@ import ( "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 serviceAccountPromotionClusterRoles = []string{ @@ -266,10 +266,10 @@ var _ = Describe("Promoting ServiceAccounts", Ordered, Label("config", "permissi }, }, Spec: capsulev1beta2.TenantSpec{ - Rules: []*api.NamespaceRuleBodyTenant{ + Rules: []*rules.NamespaceRuleBodyTenant{ { - Permissions: api.NamespaceRulePermissionBody{ - Promotions: []*api.NamespaceRulePromotionRule{ + Permissions: rules.NamespaceRulePermissionBody{ + Promotions: []*rules.NamespaceRulePromotionRule{ { ClusterRoles: []string{"view"}, }, @@ -290,8 +290,8 @@ var _ = Describe("Promoting ServiceAccounts", Ordered, Label("config", "permissi "environment": "prod", }, }, - Permissions: api.NamespaceRulePermissionBody{ - Promotions: []*api.NamespaceRulePromotionRule{ + Permissions: rules.NamespaceRulePermissionBody{ + Promotions: []*rules.NamespaceRulePromotionRule{ { ClusterRoles: []string{"prod-view"}, }, @@ -312,8 +312,8 @@ var _ = Describe("Promoting ServiceAccounts", Ordered, Label("config", "permissi "environment": "dev", }, }, - Permissions: api.NamespaceRulePermissionBody{ - Promotions: []*api.NamespaceRulePromotionRule{ + Permissions: rules.NamespaceRulePermissionBody{ + Promotions: []*rules.NamespaceRulePromotionRule{ { ClusterRoles: []string{"dev-view"}, }, diff --git a/e2e/tenant_imagepullpolicy_multiple_test.go b/e2e/tenant_imagepullpolicy_multiple_test.go index 78251d69d..c34891dba 100644 --- a/e2e/tenant_imagepullpolicy_multiple_test.go +++ b/e2e/tenant_imagepullpolicy_multiple_test.go @@ -13,9 +13,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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 some defined ImagePullPolicy", Ordered, Label("tenant", "pods", "images", "policy"), func() { @@ -37,7 +37,7 @@ var _ = Describe("enforcing some defined ImagePullPolicy", Ordered, Label("tenan }, }, }, - ImagePullPolicies: []api.ImagePullPolicySpec{"Always", "IfNotPresent"}, + ImagePullPolicies: []rules.ImagePullPolicySpec{"Always", "IfNotPresent"}, }, } diff --git a/e2e/tenant_imagepullpolicy_single_test.go b/e2e/tenant_imagepullpolicy_single_test.go index 8bc99ef03..67c49c0c5 100644 --- a/e2e/tenant_imagepullpolicy_single_test.go +++ b/e2e/tenant_imagepullpolicy_single_test.go @@ -13,9 +13,9 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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 a defined ImagePullPolicy", Ordered, Label("tenant", "pods", "images", "policy"), func() { @@ -37,7 +37,7 @@ var _ = Describe("enforcing a defined ImagePullPolicy", Ordered, Label("tenant", }, }, }, - ImagePullPolicies: []api.ImagePullPolicySpec{"Always"}, + ImagePullPolicies: []rules.ImagePullPolicySpec{"Always"}, }, } diff --git a/hack/distro/capsule/example-setup/tenants.yaml b/hack/distro/capsule/example-setup/tenants.yaml index 2ccc29d16..3328b0adf 100644 --- a/hack/distro/capsule/example-setup/tenants.yaml +++ b/hack/distro/capsule/example-setup/tenants.yaml @@ -90,16 +90,15 @@ spec: name: alice rules: - enforce: + action: "deny" registries: - url: "harbor/.*" - policy: - - "Never" - enforce: + action: "allow" registries: - - url: "custom/.*" + - url: "harbor/customer/.*" policy: - "Never" - - namespaceSelector: matchExpressions: - key: env @@ -107,6 +106,7 @@ spec: values: - "prod" enforce: + action: "allow" registries: - url: "harbor/v2/customer-registry/prod-image/.*" policy: diff --git a/internal/cache/regex.go b/internal/cache/regex.go new file mode 100644 index 000000000..c58561f11 --- /dev/null +++ b/internal/cache/regex.go @@ -0,0 +1,135 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "regexp" + "strings" + "sync" + + "github.com/projectcapsule/capsule/pkg/api" +) + +type CompiledRegex struct { + ID string + Expression string + Negate bool + RE *regexp.Regexp +} + +func (r *CompiledRegex) MatchString(value string) bool { + if r == nil || r.RE == nil { + return false + } + + matched := r.RE.MatchString(value) + + if r.Negate { + return !matched + } + + return matched +} + +type RegexCache struct { + mu sync.RWMutex + re map[string]*CompiledRegex +} + +func NewRegexCache() *RegexCache { + return &RegexCache{ + re: make(map[string]*CompiledRegex), + } +} + +func (c *RegexCache) GetOrCompile(expr api.RegExpression) (*CompiledRegex, bool, error) { + if c == nil { + return nil, false, fmt.Errorf("regex cache is nil") + } + + expression := strings.TrimSpace(expr.Expression) + if expression == "" { + return nil, false, fmt.Errorf("regex expression must not be empty") + } + + id := HashRegex(expr) + + c.mu.RLock() + compiled := c.re[id] + c.mu.RUnlock() + + if compiled != nil { + return compiled, true, nil + } + + re, err := regexp.Compile(expression) + if err != nil { + return nil, false, fmt.Errorf("invalid regex expression %q: %w", expression, err) + } + + built := &CompiledRegex{ + ID: id, + Expression: expression, + Negate: expr.Negate, + RE: re, + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.re == nil { + c.re = make(map[string]*CompiledRegex) + } + + if compiled = c.re[id]; compiled != nil { + return compiled, true, nil + } + + c.re[id] = built + + return built, false, nil +} + +func (c *RegexCache) Has(id string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + + _, ok := c.re[id] + + return ok +} + +func (c *RegexCache) Stats() int { + c.mu.RLock() + defer c.mu.RUnlock() + + return len(c.re) +} + +func (c *RegexCache) Reset() { + c.mu.Lock() + defer c.mu.Unlock() + + c.re = make(map[string]*CompiledRegex) +} + +func HashRegex(expr api.RegExpression) string { + var b strings.Builder + + b.WriteString(strings.TrimSpace(expr.Expression)) + b.WriteString("\x1f") + + if expr.Negate { + b.WriteString("1") + } else { + b.WriteString("0") + } + + sum := sha256.Sum256([]byte(b.String())) + + return hex.EncodeToString(sum[:]) +} diff --git a/internal/cache/regex_test.go b/internal/cache/regex_test.go new file mode 100644 index 000000000..b942aa1f0 --- /dev/null +++ b/internal/cache/regex_test.go @@ -0,0 +1,234 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package cache + +import ( + "testing" + + "github.com/projectcapsule/capsule/pkg/api" +) + +func TestRegexCache_GetOrCompile(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + expression api.RegExpression + value string + wantMatch bool + wantErr bool + wantCached bool + wantEntries int + }{ + { + name: "compile matching regex", + expression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + value: "ghcr.io/projectcapsule/capsule:latest", + wantMatch: true, + wantErr: false, + wantCached: false, + wantEntries: 1, + }, + { + name: "compile non matching regex", + expression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + value: "docker.io/library/nginx:latest", + wantMatch: false, + wantErr: false, + wantCached: false, + wantEntries: 1, + }, + { + name: "compile negated matching regex", + expression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + Negate: true, + }, + value: "ghcr.io/projectcapsule/capsule:latest", + wantMatch: false, + wantErr: false, + wantCached: false, + wantEntries: 1, + }, + { + name: "compile negated non matching regex", + expression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + Negate: true, + }, + value: "docker.io/library/nginx:latest", + wantMatch: true, + wantErr: false, + wantCached: false, + wantEntries: 1, + }, + { + name: "reject empty expression", + expression: api.RegExpression{ + Expression: "", + }, + value: "ghcr.io/projectcapsule/capsule:latest", + wantErr: true, + wantEntries: 0, + }, + { + name: "reject invalid regex", + expression: api.RegExpression{ + Expression: `[`, + }, + value: "ghcr.io/projectcapsule/capsule:latest", + wantErr: true, + wantEntries: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + c := NewRegexCache() + + compiled, fromCache, err := c.GetOrCompile(tt.expression) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + + if compiled != nil { + t.Fatalf("expected nil compiled regex on error, got %#v", compiled) + } + + if got := c.Stats(); got != tt.wantEntries { + t.Fatalf("expected %d cache entries, got %d", tt.wantEntries, got) + } + + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if compiled == nil { + t.Fatal("expected compiled regex, got nil") + } + + if fromCache != tt.wantCached { + t.Fatalf("expected fromCache=%t, got %t", tt.wantCached, fromCache) + } + + if got := compiled.MatchString(tt.value); got != tt.wantMatch { + t.Fatalf("expected match=%t, got %t", tt.wantMatch, got) + } + + if got := c.Stats(); got != tt.wantEntries { + t.Fatalf("expected %d cache entries, got %d", tt.wantEntries, got) + } + + if !c.Has(compiled.ID) { + t.Fatalf("expected cache to contain regex id %q", compiled.ID) + } + }) + } +} + +func TestRegexCache_GetOrCompile_ReusesCachedRegex(t *testing.T) { + t.Parallel() + + c := NewRegexCache() + + expr := api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + } + + first, fromCache, err := c.GetOrCompile(expr) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if fromCache { + t.Fatal("expected first lookup to build regex, got cache hit") + } + + second, fromCache, err := c.GetOrCompile(expr) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !fromCache { + t.Fatal("expected second lookup to hit cache") + } + + if first != second { + t.Fatal("expected cached regex pointer to be reused") + } + + if got := c.Stats(); got != 1 { + t.Fatalf("expected 1 cache entry, got %d", got) + } +} + +func TestRegexCache_HashRegex_UsesNegate(t *testing.T) { + t.Parallel() + + positive := HashRegex(api.RegExpression{ + Expression: `^ghcr\.io/.*`, + }) + + negative := HashRegex(api.RegExpression{ + Expression: `^ghcr\.io/.*`, + Negate: true, + }) + + if positive == negative { + t.Fatal("expected different hashes for negated and non-negated expressions") + } +} + +func TestRegexCache_Reset(t *testing.T) { + t.Parallel() + + c := NewRegexCache() + + compiled, _, err := c.GetOrCompile(api.RegExpression{ + Expression: `^ghcr\.io/.*`, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if got := c.Stats(); got != 1 { + t.Fatalf("expected 1 cache entry, got %d", got) + } + + c.Reset() + + if got := c.Stats(); got != 0 { + t.Fatalf("expected 0 cache entries after reset, got %d", got) + } + + if c.Has(compiled.ID) { + t.Fatalf("expected regex id %q to be removed after reset", compiled.ID) + } +} + +func TestCompiledRegex_MatchString_NilSafe(t *testing.T) { + t.Parallel() + + var compiled *CompiledRegex + + if compiled.MatchString("ghcr.io/projectcapsule/capsule:latest") { + t.Fatal("expected nil compiled regex to return false") + } + + compiled = &CompiledRegex{} + + if compiled.MatchString("ghcr.io/projectcapsule/capsule:latest") { + t.Fatal("expected compiled regex with nil RE to return false") + } +} diff --git a/internal/cache/registries.go b/internal/cache/registries.go index 035b872a8..b4d25ad65 100644 --- a/internal/cache/registries.go +++ b/internal/cache/registries.go @@ -7,7 +7,6 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "regexp" "sort" "strings" "sync" @@ -15,6 +14,7 @@ import ( corev1 "k8s.io/api/core/v1" "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/rules" ) type RuleSet struct { @@ -25,29 +25,62 @@ type RuleSet struct { } type CompiledRule struct { - Registry string - RE *regexp.Regexp + Expression api.RegExpression + RegexID string + AllowedPolicy map[corev1.PullPolicy]struct{} // nil/empty => allow any ValidateImages bool ValidateVolumes bool } +func (r *CompiledRule) AllowsPullPolicy(pullPolicy corev1.PullPolicy) bool { + if len(r.AllowedPolicy) == 0 { + return true + } + + _, ok := r.AllowedPolicy[pullPolicy] + + return ok +} + +func (r *CompiledRule) MatchesTarget(target rules.RegistryValidationTarget) bool { + switch target { + case rules.ValidateImages: + return r.ValidateImages + case rules.ValidateVolumes: + return r.ValidateVolumes + default: + return false + } +} + type RegistryRuleSetCache struct { + regexCache *RegexCache + mu sync.RWMutex rs map[string]*RuleSet } -func NewRegistryRuleSetCache() *RegistryRuleSetCache { +func NewRegistryRuleSetCache(regexCache *RegexCache) *RegistryRuleSetCache { + if regexCache == nil { + regexCache = NewRegexCache() + } + return &RegistryRuleSetCache{ - rs: make(map[string]*RuleSet), + regexCache: regexCache, + rs: make(map[string]*RuleSet), } } -func (c *RegistryRuleSetCache) GetOrBuild(specRules []api.OCIRegistry) (rs *RuleSet, fromCache bool, err error) { +func (c *RegistryRuleSetCache) GetOrBuild(specRules []rules.OCIRegistry) (rs *RuleSet, fromCache bool, err error) { if len(specRules) == 0 { return nil, false, nil } + if c == nil { + return nil, false, fmt.Errorf("registry rule set cache is nil") + } + id := c.HashRules(specRules) c.mu.RLock() @@ -58,13 +91,12 @@ func (c *RegistryRuleSetCache) GetOrBuild(specRules []api.OCIRegistry) (rs *Rule return rs, true, nil } - // Build outside locks (regex compile etc.) - built, err := buildRuleSet(id, specRules) + // Build outside locks. Regex compilation is delegated to RegexCache. + built, err := c.buildRuleSet(id, specRules) if err != nil { return nil, false, err } - // Insert with double-check c.mu.Lock() defer c.mu.Unlock() @@ -72,7 +104,6 @@ func (c *RegistryRuleSetCache) GetOrBuild(specRules []api.OCIRegistry) (rs *Rule c.rs = make(map[string]*RuleSet) } - // Another goroutine may have inserted meanwhile if rs = c.rs[id]; rs != nil { return rs, true, nil } @@ -82,7 +113,115 @@ func (c *RegistryRuleSetCache) GetOrBuild(specRules []api.OCIRegistry) (rs *Rule return built, false, nil } +// Match matches a reference against target, regex and pullPolicy. +// Admission deny/allow/audit evaluation should usually use MatchReference instead, +// because it needs to distinguish "regex matched but pullPolicy is forbidden" from +// "regex did not match". +func (c *RegistryRuleSetCache) Match( + specRules []rules.OCIRegistry, + reference string, + pullPolicy corev1.PullPolicy, + target rules.RegistryValidationTarget, +) (*CompiledRule, error) { + rs, _, err := c.GetOrBuild(specRules) + if err != nil { + return nil, err + } + + if rs == nil { + return nil, nil + } + + return c.MatchRuleSet(rs, reference, pullPolicy, target) +} + +// MatchRuleSet matches a reference against target, regex and pullPolicy. +func (c *RegistryRuleSetCache) MatchRuleSet( + rs *RuleSet, + reference string, + pullPolicy corev1.PullPolicy, + target rules.RegistryValidationTarget, +) (*CompiledRule, error) { + if c == nil { + return nil, fmt.Errorf("registry rule set cache is nil") + } + + if c.regexCache == nil { + return nil, fmt.Errorf("regex cache is nil") + } + + if rs == nil { + return nil, nil + } + + for i := range rs.Compiled { + rule := &rs.Compiled[i] + + if !rule.MatchesTarget(target) { + continue + } + + if !rule.AllowsPullPolicy(pullPolicy) { + continue + } + + compiled, _, err := c.regexCache.GetOrCompile(rule.Expression) + if err != nil { + return nil, err + } + + if compiled.MatchString(reference) { + return rule, nil + } + } + + return nil, nil +} + +// MatchReference matches a reference against target and regex only. +// It intentionally does not check pullPolicy. +func (c *RegistryRuleSetCache) MatchReference( + rs *RuleSet, + reference string, + target rules.RegistryValidationTarget, +) (*CompiledRule, error) { + if c == nil { + return nil, fmt.Errorf("registry rule set cache is nil") + } + + if c.regexCache == nil { + return nil, fmt.Errorf("regex cache is nil") + } + + if rs == nil { + return nil, nil + } + + for i := range rs.Compiled { + rule := &rs.Compiled[i] + + if !rule.MatchesTarget(target) { + continue + } + + compiled, _, err := c.regexCache.GetOrCompile(rule.Expression) + if err != nil { + return nil, err + } + + if compiled.MatchString(reference) { + return rule, nil + } + } + + return nil, nil +} + func (c *RegistryRuleSetCache) Stats() int { + if c == nil { + return 0 + } + c.mu.RLock() defer c.mu.RUnlock() @@ -91,6 +230,10 @@ func (c *RegistryRuleSetCache) Stats() int { // activeIDs: set of ids currently referenced by RuleStatus in cluster. func (c *RegistryRuleSetCache) PruneActive(activeIDs map[string]struct{}) int { + if c == nil { + return 0 + } + c.mu.Lock() defer c.mu.Unlock() @@ -109,10 +252,10 @@ func (c *RegistryRuleSetCache) PruneActive(activeIDs map[string]struct{}) int { return removed } -func (c *RegistryRuleSetCache) HashRules(specRules []api.OCIRegistry) string { +func (c *RegistryRuleSetCache) HashRules(specRules []rules.OCIRegistry) string { var b strings.Builder - b.Grow(len(specRules) * 64) + b.Grow(len(specRules) * 96) const ( sepRule = "\n" @@ -121,7 +264,7 @@ func (c *RegistryRuleSetCache) HashRules(specRules []api.OCIRegistry) string { ) for _, r := range specRules { - url := strings.TrimSpace(r.Registry) + expr := r.Expression() policies := make([]string, 0, len(r.Policy)) for _, p := range r.Policy { @@ -137,7 +280,15 @@ func (c *RegistryRuleSetCache) HashRules(specRules []api.OCIRegistry) string { sort.Strings(validations) - b.WriteString(url) + b.WriteString(strings.TrimSpace(expr.Expression)) + b.WriteString(sepField) + + if expr.Negate { + b.WriteString("1") + } else { + b.WriteString("0") + } + b.WriteString(sepField) for i, p := range policies { @@ -168,6 +319,10 @@ func (c *RegistryRuleSetCache) HashRules(specRules []api.OCIRegistry) string { // Has is useful in tests and debugging. func (c *RegistryRuleSetCache) Has(id string) bool { + if c == nil { + return false + } + c.mu.RLock() defer c.mu.RUnlock() @@ -177,13 +332,17 @@ func (c *RegistryRuleSetCache) Has(id string) bool { } func (c *RegistryRuleSetCache) Reset() { + if c == nil { + return + } + c.mu.Lock() defer c.mu.Unlock() c.rs = make(map[string]*RuleSet) } -// InsertForTest can be behind a build tag if you prefer, but it's fine to keep simple. +// InsertForTest can be behind a build tag if you prefer, but it is fine to keep simple. // //nolint:unused func (c *RegistryRuleSetCache) insertForTest(id string) { @@ -197,38 +356,52 @@ func (c *RegistryRuleSetCache) insertForTest(id string) { c.rs[id] = &RuleSet{ID: id} } -func buildRuleSet(id string, specRules []api.OCIRegistry) (*RuleSet, error) { +func (c *RegistryRuleSetCache) buildRuleSet(id string, specRules []rules.OCIRegistry) (*RuleSet, error) { + if c.regexCache == nil { + return nil, fmt.Errorf("regex cache is nil") + } + rs := &RuleSet{ ID: id, Compiled: make([]CompiledRule, 0, len(specRules)), } for _, r := range specRules { - re, err := regexp.Compile(r.Registry) + expression := r.Expression() + + compiled, _, err := c.regexCache.GetOrCompile(expression) if err != nil { - return nil, fmt.Errorf("invalid registry regex %q: %w", r.Registry, err) + return nil, err } cr := CompiledRule{ - Registry: r.Registry, - RE: re, + Expression: expression, + RegexID: compiled.ID, } if len(r.Policy) > 0 { cr.AllowedPolicy = make(map[corev1.PullPolicy]struct{}, len(r.Policy)) + for _, p := range r.Policy { cr.AllowedPolicy[p] = struct{}{} } } - for _, v := range r.Validation { - switch v { - case api.ValidateImages: - cr.ValidateImages = true - rs.HasImages = true - case api.ValidateVolumes: - cr.ValidateVolumes = true - rs.HasVolumes = true + if len(r.Validation) == 0 { + cr.ValidateImages = true + cr.ValidateVolumes = true + rs.HasImages = true + rs.HasVolumes = true + } else { + for _, v := range r.Validation { + switch v { + case rules.ValidateImages: + cr.ValidateImages = true + rs.HasImages = true + case rules.ValidateVolumes: + cr.ValidateVolumes = true + rs.HasVolumes = true + } } } diff --git a/internal/cache/registries_test.go b/internal/cache/registries_test.go index ab33e98d7..5c012cf7d 100644 --- a/internal/cache/registries_test.go +++ b/internal/cache/registries_test.go @@ -1,524 +1,656 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + package cache import ( - "sync" "testing" corev1 "k8s.io/api/core/v1" "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/rules" ) -func set(ids ...string) map[string]struct{} { - m := make(map[string]struct{}, len(ids)) - for _, id := range ids { - m[id] = struct{}{} - } - return m -} - -func TestRegistryRuleSetCache_GetOrBuild_ReturnsFromCacheFlag(t *testing.T) { - c := NewRegistryRuleSetCache() - - rules := []api.OCIRegistry{ +func TestRegistryRuleSetCache_GetOrBuild(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules []rules.OCIRegistry + wantNil bool + wantErr bool + wantFromCache bool + wantRuleCount int + wantHasImages bool + wantHasVolumes bool + }{ + { + name: "empty rules return nil", + rules: nil, + wantNil: true, + }, + { + name: "build single rule", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + Policy: []corev1.PullPolicy{ + corev1.PullIfNotPresent, + }, + Validation: []rules.RegistryValidationTarget{ + rules.ValidateImages, + }, + }, + }, + wantRuleCount: 1, + wantHasImages: true, + wantHasVolumes: false, + }, + { + name: "empty validation defaults to images and volumes", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + }, + }, + wantRuleCount: 1, + wantHasImages: true, + wantHasVolumes: true, + }, { - Registry: "harbor/.*", - Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes}, - Policy: []corev1.PullPolicy{corev1.PullNever}, + name: "invalid regex returns error", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `[`, + }, + }, + }, + wantErr: true, }, } - rs1, fromCache1, err := c.GetOrBuild(rules) - if err != nil { - t.Fatalf("unexpected err: %v", err) - } - if rs1 == nil { - t.Fatalf("expected ruleset, got nil") - } - if fromCache1 { - t.Fatalf("expected fromCache=false on first build, got true") - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - rs2, fromCache2, err := c.GetOrBuild(rules) - if err != nil { - t.Fatalf("unexpected err: %v", err) - } - if rs2 == nil { - t.Fatalf("expected ruleset, got nil") - } - if !fromCache2 { - t.Fatalf("expected fromCache=true on second call, got false") - } + regexCache := NewRegexCache() + registryCache := NewRegistryRuleSetCache(regexCache) - if rs1 != rs2 { - t.Fatalf("expected same cached pointer, got rs1=%p rs2=%p", rs1, rs2) - } -} + rs, fromCache, err := registryCache.GetOrBuild(tt.rules) -func TestRuleSetCache_GetOrBuild_EmptyReturnsNil(t *testing.T) { - c := NewRegistryRuleSetCache() + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } - rs, _, err := c.GetOrBuild(nil) - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } - if rs != nil { - t.Fatalf("expected nil ruleset, got %#v", rs) - } + if rs != nil { + t.Fatalf("expected nil ruleset on error, got %#v", rs) + } - rs, _, err = c.GetOrBuild([]api.OCIRegistry{}) - if err != nil { - t.Fatalf("expected nil error, got %v", err) - } - if rs != nil { - t.Fatalf("expected nil ruleset, got %#v", rs) - } + return + } - if got := c.Stats(); got != 0 { - t.Fatalf("expected Stats()=0, got %d", got) - } -} + if err != nil { + t.Fatalf("expected no error, got %v", err) + } -func TestRuleSetCache_GetOrBuild_InvalidRegexReturnsError(t *testing.T) { - c := NewRegistryRuleSetCache() + if tt.wantNil { + if rs != nil { + t.Fatalf("expected nil ruleset, got %#v", rs) + } - // invalid regex - rules := []api.OCIRegistry{ - { - Registry: "([", - Validation: []api.RegistryValidationTarget{api.ValidateImages}, - Policy: []corev1.PullPolicy{corev1.PullAlways}, - }, - } + return + } - rs, _, err := c.GetOrBuild(rules) - if err == nil { - t.Fatalf("expected error, got nil") - } - if rs != nil { - t.Fatalf("expected nil ruleset on error, got %#v", rs) - } + if rs == nil { + t.Fatal("expected ruleset, got nil") + } + + if fromCache != tt.wantFromCache { + t.Fatalf("expected fromCache=%t, got %t", tt.wantFromCache, fromCache) + } + + if len(rs.Compiled) != tt.wantRuleCount { + t.Fatalf("expected %d compiled rules, got %d", tt.wantRuleCount, len(rs.Compiled)) + } - if got := c.Stats(); got != 0 { - t.Fatalf("expected Stats()=0 after failing build, got %d", got) + if rs.HasImages != tt.wantHasImages { + t.Fatalf("expected HasImages=%t, got %t", tt.wantHasImages, rs.HasImages) + } + + if rs.HasVolumes != tt.wantHasVolumes { + t.Fatalf("expected HasVolumes=%t, got %t", tt.wantHasVolumes, rs.HasVolumes) + } + + if got := registryCache.Stats(); got != 1 { + t.Fatalf("expected 1 registry ruleset cache entry, got %d", got) + } + + if got := regexCache.Stats(); got != tt.wantRuleCount { + t.Fatalf("expected %d regex cache entries, got %d", tt.wantRuleCount, got) + } + }) } } -func TestRuleSetCache_GetOrBuild_DeduplicatesByContent(t *testing.T) { - c := NewRegistryRuleSetCache() +func TestRegistryRuleSetCache_GetOrBuild_ReusesCachedRuleSet(t *testing.T) { + t.Parallel() - rulesA := []api.OCIRegistry{ - { - Registry: "harbor/.*", - Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes}, - Policy: []corev1.PullPolicy{corev1.PullNever}, - }, - } + regexCache := NewRegexCache() + registryCache := NewRegistryRuleSetCache(regexCache) - // same content but different backing slice - rulesB := []api.OCIRegistry{ + rules := []rules.OCIRegistry{ { - Registry: "harbor/.*", - Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes}, - Policy: []corev1.PullPolicy{corev1.PullNever}, + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, }, } - rs1, _, err := c.GetOrBuild(rulesA) - if err != nil { - t.Fatalf("unexpected err: %v", err) - } - rs2, _, err := c.GetOrBuild(rulesB) + first, fromCache, err := registryCache.GetOrBuild(rules) if err != nil { - t.Fatalf("unexpected err: %v", err) + t.Fatalf("expected no error, got %v", err) } - // the whole point: should be the exact same pointer - if rs1 != rs2 { - t.Fatalf("expected same cached pointer, got rs1=%p rs2=%p", rs1, rs2) + if fromCache { + t.Fatal("expected first lookup to build ruleset, got cache hit") } - if got := c.Stats(); got != 1 { - t.Fatalf("expected Stats()=1, got %d", got) - } - - // sanity: compiled fields are correct (no DeepEqual; check specific invariants) - if rs1.ID == "" { - t.Fatalf("expected non-empty ruleset ID") - } - if len(rs1.Compiled) != 1 { - t.Fatalf("expected 1 compiled rule, got %d", len(rs1.Compiled)) - } - cr := rs1.Compiled[0] - if cr.RE == nil { - t.Fatalf("expected compiled regexp, got nil") - } - if cr.Registry != "harbor/.*" { - t.Fatalf("expected Registry to match input, got %q", cr.Registry) - } - if !cr.ValidateImages || !cr.ValidateVolumes { - t.Fatalf("expected ValidateImages and ValidateVolumes true, got images=%v volumes=%v", cr.ValidateImages, cr.ValidateVolumes) - } - if rs1.HasImages != true || rs1.HasVolumes != true { - t.Fatalf("expected ruleset flags HasImages/HasVolumes true, got images=%v volumes=%v", rs1.HasImages, rs1.HasVolumes) - } - if cr.AllowedPolicy == nil { - t.Fatalf("expected AllowedPolicy map non-nil") - } - if _, ok := cr.AllowedPolicy[corev1.PullNever]; !ok { - t.Fatalf("expected AllowedPolicy to contain PullNever") + second, fromCache, err := registryCache.GetOrBuild(rules) + if err != nil { + t.Fatalf("expected no error, got %v", err) } -} -func TestRuleSetCache_GetOrBuild_OrderMatters_LaterWins(t *testing.T) { - c := NewRegistryRuleSetCache() - - // Two rules with same items but swapped order - // hashRules preserves rule order, so the IDs must differ. - rules1 := []api.OCIRegistry{ - {Registry: ".*", Validation: []api.RegistryValidationTarget{api.ValidateImages}, Policy: []corev1.PullPolicy{corev1.PullAlways}}, - {Registry: "harbor/.*", Validation: []api.RegistryValidationTarget{api.ValidateImages}}, - } - rules2 := []api.OCIRegistry{ - {Registry: "harbor/.*", Validation: []api.RegistryValidationTarget{api.ValidateImages}}, - {Registry: ".*", Validation: []api.RegistryValidationTarget{api.ValidateImages}, Policy: []corev1.PullPolicy{corev1.PullAlways}}, + if !fromCache { + t.Fatal("expected second lookup to hit cache") } - rs1, _, err := c.GetOrBuild(rules1) - if err != nil { - t.Fatalf("unexpected err: %v", err) - } - rs2, _, err := c.GetOrBuild(rules2) - if err != nil { - t.Fatalf("unexpected err: %v", err) + if first != second { + t.Fatal("expected cached ruleset pointer to be reused") } - if rs1 == rs2 { - t.Fatalf("expected different cached entries due to different rule order, got same pointer %p", rs1) - } - if rs1.ID == rs2.ID { - t.Fatalf("expected different IDs for different order, got same %q", rs1.ID) - } - if got := c.Stats(); got != 2 { - t.Fatalf("expected Stats()=2, got %d", got) + if got := registryCache.Stats(); got != 1 { + t.Fatalf("expected 1 registry ruleset cache entry, got %d", got) } - // Verify compiled slice preserves the rule order we provided - if len(rs1.Compiled) != 2 { - t.Fatalf("expected 2 compiled rules, got %d", len(rs1.Compiled)) - } - if rs1.Compiled[0].Registry != ".*" || rs1.Compiled[1].Registry != "harbor/.*" { - t.Fatalf("expected compiled order to match input for rules1, got %q then %q", - rs1.Compiled[0].Registry, rs1.Compiled[1].Registry) + if got := regexCache.Stats(); got != 1 { + t.Fatalf("expected 1 regex cache entry, got %d", got) } } -func TestRuleSetCache_GetOrBuild_ConcurrentReturnsSamePointer(t *testing.T) { - c := NewRegistryRuleSetCache() - - rules := []api.OCIRegistry{ +func TestRegistryRuleSetCache_Match(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rules []rules.OCIRegistry + image string + pullPolicy corev1.PullPolicy + target rules.RegistryValidationTarget + wantMatch bool + wantErr bool + }{ + { + name: "match image with default validation and any pull policy", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + }, + }, + image: "ghcr.io/projectcapsule/capsule:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantMatch: true, + }, + { + name: "match volume with default validation", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + }, + }, + image: "ghcr.io/projectcapsule/capsule:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateVolumes, + wantMatch: true, + }, + { + name: "does not match wrong image", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + }, + }, + image: "docker.io/library/nginx:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantMatch: false, + }, + { + name: "does not match wrong pull policy", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + Policy: []corev1.PullPolicy{ + corev1.PullAlways, + }, + }, + }, + image: "ghcr.io/projectcapsule/capsule:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantMatch: false, + }, + { + name: "matches allowed pull policy", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + Policy: []corev1.PullPolicy{ + corev1.PullIfNotPresent, + }, + }, + }, + image: "ghcr.io/projectcapsule/capsule:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantMatch: true, + }, + { + name: "does not match wrong validation target", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + Validation: []rules.RegistryValidationTarget{ + rules.ValidateVolumes, + }, + }, + }, + image: "ghcr.io/projectcapsule/capsule:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantMatch: false, + }, + { + name: "matches configured validation target", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + Validation: []rules.RegistryValidationTarget{ + rules.ValidateImages, + }, + }, + }, + image: "ghcr.io/projectcapsule/capsule:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantMatch: true, + }, + { + name: "negated regex matches non matching image", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + Negate: true, + }, + }, + }, + image: "docker.io/library/nginx:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantMatch: true, + }, + { + name: "negated regex does not match matching image", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + Negate: true, + }, + }, + }, + image: "ghcr.io/projectcapsule/capsule:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantMatch: false, + }, { - Registry: "harbor/.*", - Validation: []api.RegistryValidationTarget{api.ValidateImages, api.ValidateVolumes}, - Policy: []corev1.PullPolicy{corev1.PullAlways, corev1.PullIfNotPresent}, + name: "invalid regex returns error", + rules: []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `[`, + }, + }, + }, + image: "ghcr.io/projectcapsule/capsule:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantErr: true, + }, + { + name: "empty rules do not match", + rules: nil, + image: "ghcr.io/projectcapsule/capsule:latest", + pullPolicy: corev1.PullIfNotPresent, + target: rules.ValidateImages, + wantMatch: false, }, } - const workers = 32 - var wg sync.WaitGroup - wg.Add(workers) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - results := make([]*RuleSet, workers) - errs := make([]error, workers) + registryCache := NewRegistryRuleSetCache(NewRegexCache()) - for i := 0; i < workers; i++ { - go func(i int) { - defer wg.Done() - rs, _, err := c.GetOrBuild(rules) - results[i] = rs - errs[i] = err - }(i) - } + matched, err := registryCache.Match( + tt.rules, + tt.image, + tt.pullPolicy, + tt.target, + ) - wg.Wait() + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } - for i := 0; i < workers; i++ { - if errs[i] != nil { - t.Fatalf("worker %d got err: %v", i, errs[i]) - } - if results[i] == nil { - t.Fatalf("worker %d got nil ruleset", i) - } - } + return + } - // all pointers must match the first - first := results[0] - for i := 1; i < workers; i++ { - if results[i] != first { - t.Fatalf("expected same cached pointer across goroutines; got %p vs %p", first, results[i]) - } - } -} + if err != nil { + t.Fatalf("expected no error, got %v", err) + } -func TestRegistryRuleSetCache_GetOrBuild_ConcurrentPointersAndFlags(t *testing.T) { - c := NewRegistryRuleSetCache() + if tt.wantMatch && matched == nil { + t.Fatal("expected match, got nil") + } - rules := []api.OCIRegistry{ - {Registry: "harbor/.*", Validation: []api.RegistryValidationTarget{api.ValidateImages}}, + if !tt.wantMatch && matched != nil { + t.Fatalf("expected no match, got %#v", matched) + } + }) } +} - const workers = 32 - var wg sync.WaitGroup - wg.Add(workers) +func TestRegistryRuleSetCache_HashRules_NormalizesPolicyAndValidationOrder(t *testing.T) { + t.Parallel() - results := make([]*RuleSet, workers) - flags := make([]bool, workers) - errs := make([]error, workers) + c := NewRegistryRuleSetCache(NewRegexCache()) - for i := 0; i < workers; i++ { - go func(i int) { - defer wg.Done() - rs, fromCache, err := c.GetOrBuild(rules) - results[i] = rs - flags[i] = fromCache - errs[i] = err - }(i) + a := []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + Policy: []corev1.PullPolicy{ + corev1.PullAlways, + corev1.PullIfNotPresent, + }, + Validation: []rules.RegistryValidationTarget{ + rules.ValidateImages, + rules.ValidateVolumes, + }, + }, } - wg.Wait() - for i := 0; i < workers; i++ { - if errs[i] != nil { - t.Fatalf("worker %d err: %v", i, errs[i]) - } - if results[i] == nil { - t.Fatalf("worker %d got nil ruleset", i) - } + b := []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + Policy: []corev1.PullPolicy{ + corev1.PullIfNotPresent, + corev1.PullAlways, + }, + Validation: []rules.RegistryValidationTarget{ + rules.ValidateVolumes, + rules.ValidateImages, + }, + }, } - first := results[0] - for i := 1; i < workers; i++ { - if results[i] != first { - t.Fatalf("expected same cached pointer across goroutines; got %p vs %p", first, results[i]) - } + if c.HashRules(a) != c.HashRules(b) { + t.Fatal("expected equal hashes when policy and validation values only differ by order") } +} - seenFalse := false - seenTrue := false - for i := 0; i < workers; i++ { - if flags[i] { - seenTrue = true - } else { - seenFalse = true - } - } +func TestRegistryRuleSetCache_HashRules_UsesNegate(t *testing.T) { + t.Parallel() - if !seenFalse { - t.Fatalf("expected at least one fromCache=false (builder), got none") - } + c := NewRegistryRuleSetCache(NewRegexCache()) - if !seenTrue { - t.Fatalf("expected at least one fromCache=true (builder), got none") + positive := []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/.*`, + }, + }, } -} - -func TestRegistryRuleSetCache_InsertForTest_ThenHasAndLen(t *testing.T) { - c := NewRegistryRuleSetCache() - if got := c.Stats(); got != 0 { - t.Fatalf("expected Len()=0, got %d", got) - } - if c.Has("x") { - t.Fatalf("expected Has(x)=false on empty cache") + negative := []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/.*`, + Negate: true, + }, + }, } - c.insertForTest("x") - - if !c.Has("x") { - t.Fatalf("expected Has(x)=true after insert") - } - if got := c.Stats(); got != 1 { - t.Fatalf("expected Len()=1 after insert, got %d", got) + if c.HashRules(positive) == c.HashRules(negative) { + t.Fatal("expected different hashes for negated and non-negated registry expressions") } } -func TestRegistryRuleSetCache_InsertForTest_DuplicateDoesNotIncreaseLen(t *testing.T) { - c := NewRegistryRuleSetCache() +func TestRegistryRuleSetCache_PruneActive(t *testing.T) { + t.Parallel() - c.insertForTest("x") - if got := c.Stats(); got != 1 { - t.Fatalf("expected Len()=1 after first insert, got %d", got) - } + registryCache := NewRegistryRuleSetCache(NewRegexCache()) - c.insertForTest("x") - if got := c.Stats(); got != 1 { - t.Fatalf("expected Len() to remain 1 after duplicate insert, got %d", got) + keepRules := []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + }, } - if !c.Has("x") { - t.Fatalf("expected Has(x)=true after duplicate insert") + removeRules := []rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^docker\.io/library/.*`, + }, + }, } -} -func TestRegistryRuleSetCache_HasFalseForMissingKey(t *testing.T) { - c := NewRegistryRuleSetCache() - - c.insertForTest("a") - if c.Has("b") { - t.Fatalf("expected Has(b)=false when only a exists") + keep, _, err := registryCache.GetOrBuild(keepRules) + if err != nil { + t.Fatalf("expected no error, got %v", err) } -} - -func TestRegistryRuleSetCache_PruneActive_RemovesOnlyInactive(t *testing.T) { - c := NewRegistryRuleSetCache() - c.insertForTest("a") - c.insertForTest("b") - c.insertForTest("c") - - removed := c.PruneActive(set("b")) - if removed != 2 { - t.Fatalf("expected removed=2, got %d", removed) - } - if got := c.Stats(); got != 1 { - t.Fatalf("expected Len()=1 after prune, got %d", got) + remove, _, err := registryCache.GetOrBuild(removeRules) + if err != nil { + t.Fatalf("expected no error, got %v", err) } - if !c.Has("b") { - t.Fatalf("expected b to remain") + if got := registryCache.Stats(); got != 2 { + t.Fatalf("expected 2 cache entries before prune, got %d", got) } - if c.Has("a") || c.Has("c") { - t.Fatalf("expected a and c to be removed") - } -} - -func TestRegistryRuleSetCache_PruneActive_AllActiveNoChange(t *testing.T) { - c := NewRegistryRuleSetCache() - c.insertForTest("a") - c.insertForTest("b") - removed := c.PruneActive(set("a", "b")) + removed := registryCache.PruneActive(map[string]struct{}{ + keep.ID: {}, + }) - if removed != 0 { - t.Fatalf("expected removed=0, got %d", removed) - } - if got := c.Stats(); got != 2 { - t.Fatalf("expected Len()=2, got %d", got) - } - if !c.Has("a") || !c.Has("b") { - t.Fatalf("expected both a and b to remain") + if removed != 1 { + t.Fatalf("expected 1 pruned cache entry, got %d", removed) } -} - -func TestRegistryRuleSetCache_PruneActive_EmptyActivePrunesAll(t *testing.T) { - c := NewRegistryRuleSetCache() - c.insertForTest("a") - c.insertForTest("b") - removed := c.PruneActive(set()) - - if removed != 2 { - t.Fatalf("expected removed=2, got %d", removed) - } - if got := c.Stats(); got != 0 { - t.Fatalf("expected Len()=0 after prune all, got %d", got) + if !registryCache.Has(keep.ID) { + t.Fatalf("expected kept ruleset id %q to remain", keep.ID) } - if c.Has("a") || c.Has("b") { - t.Fatalf("expected cache to be empty after prune all") - } -} - -func TestRegistryRuleSetCache_PruneActive_NilActivePrunesAll(t *testing.T) { - c := NewRegistryRuleSetCache() - c.insertForTest("a") - removed := c.PruneActive(nil) - - if removed != 1 { - t.Fatalf("expected removed=1, got %d", removed) + if registryCache.Has(remove.ID) { + t.Fatalf("expected removed ruleset id %q to be pruned", remove.ID) } - if got := c.Stats(); got != 0 { - t.Fatalf("expected Len()=0 after prune, got %d", got) - } - if c.Has("a") { - t.Fatalf("expected a to be removed") + + if got := registryCache.Stats(); got != 1 { + t.Fatalf("expected 1 cache entry after prune, got %d", got) } } -func TestRegistryRuleSetCache_PruneActive_EmptyCacheNoop(t *testing.T) { - c := NewRegistryRuleSetCache() +func TestRegistryRuleSetCache_Reset(t *testing.T) { + t.Parallel() - removed := c.PruneActive(set("a")) + registryCache := NewRegistryRuleSetCache(NewRegexCache()) - if removed != 0 { - t.Fatalf("expected removed=0 on empty cache, got %d", removed) - } - if got := c.Stats(); got != 0 { - t.Fatalf("expected Len()=0, got %d", got) + rs, _, err := registryCache.GetOrBuild([]rules.OCIRegistry{ + { + RegExpression: api.RegExpression{ + Expression: `^ghcr\.io/projectcapsule/.*`, + }, + }, + }) + if err != nil { + t.Fatalf("expected no error, got %v", err) } -} -func TestRegistryRuleSetCache_PruneActive_Idempotent(t *testing.T) { - c := NewRegistryRuleSetCache() - c.insertForTest("a") - c.insertForTest("b") - c.insertForTest("c") + if got := registryCache.Stats(); got != 1 { + t.Fatalf("expected 1 cache entry, got %d", got) + } - active := set("a") + registryCache.Reset() - removed1 := c.PruneActive(active) - if removed1 != 2 { - t.Fatalf("expected first prune removed=2, got %d", removed1) - } - if got := c.Stats(); got != 1 { - t.Fatalf("expected Len()=1 after first prune, got %d", got) - } - if !c.Has("a") { - t.Fatalf("expected a to remain after first prune") + if got := registryCache.Stats(); got != 0 { + t.Fatalf("expected 0 cache entries after reset, got %d", got) } - removed2 := c.PruneActive(active) - if removed2 != 0 { - t.Fatalf("expected second prune removed=0, got %d", removed2) - } - if got := c.Stats(); got != 1 { - t.Fatalf("expected Len()=1 after second prune, got %d", got) + if registryCache.Has(rs.ID) { + t.Fatalf("expected ruleset id %q to be removed after reset", rs.ID) } } -func TestRegistryRuleSetCache_PruneActive_RemovesCorrectCountWithLargerSet(t *testing.T) { - c := NewRegistryRuleSetCache() +func TestCompiledRule_AllowsPullPolicy(t *testing.T) { + t.Parallel() - // Insert 10 IDs: id0..id9 - for i := 0; i < 10; i++ { - c.insertForTest("id" + itoa(i)) + tests := []struct { + name string + rule CompiledRule + pullPolicy corev1.PullPolicy + want bool + }{ + { + name: "empty policy allows any", + rule: CompiledRule{}, + pullPolicy: corev1.PullAlways, + want: true, + }, + { + name: "configured policy allows matching value", + rule: CompiledRule{ + AllowedPolicy: map[corev1.PullPolicy]struct{}{ + corev1.PullIfNotPresent: {}, + }, + }, + pullPolicy: corev1.PullIfNotPresent, + want: true, + }, + { + name: "configured policy rejects non matching value", + rule: CompiledRule{ + AllowedPolicy: map[corev1.PullPolicy]struct{}{ + corev1.PullIfNotPresent: {}, + }, + }, + pullPolicy: corev1.PullAlways, + want: false, + }, } - // Keep 3: id0,id4,id9 - removed := c.PruneActive(set("id0", "id4", "id9")) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() - if removed != 7 { - t.Fatalf("expected removed=7, got %d", removed) - } - if got := c.Stats(); got != 3 { - t.Fatalf("expected Len()=3, got %d", got) - } - if !c.Has("id0") || !c.Has("id4") || !c.Has("id9") { - t.Fatalf("expected id0,id4,id9 to remain") + if got := tt.rule.AllowsPullPolicy(tt.pullPolicy); got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) } } -// tiny int->string without fmt (faster, no allocations beyond result) -func itoa(i int) string { - // Enough for small test numbers - if i == 0 { - return "0" +func TestCompiledRule_MatchesTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rule CompiledRule + target rules.RegistryValidationTarget + want bool + }{ + { + name: "matches images", + rule: CompiledRule{ + ValidateImages: true, + }, + target: rules.ValidateImages, + want: true, + }, + { + name: "does not match images when only volumes configured", + rule: CompiledRule{ + ValidateVolumes: true, + }, + target: rules.ValidateImages, + want: false, + }, + { + name: "matches volumes", + rule: CompiledRule{ + ValidateVolumes: true, + }, + target: rules.ValidateVolumes, + want: true, + }, + { + name: "does not match unknown target", + rule: CompiledRule{ + ValidateImages: true, + ValidateVolumes: true, + }, + target: rules.RegistryValidationTarget("unknown"), + want: false, + }, } - var buf [20]byte - n := len(buf) - for i > 0 { - n-- - buf[n] = byte('0' + (i % 10)) - i /= 10 + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + if got := tt.rule.MatchesTarget(tt.target); got != tt.want { + t.Fatalf("expected %t, got %t", tt.want, got) + } + }) } - return string(buf[n:]) } diff --git a/internal/controllers/cfg/invalidator/manager.go b/internal/controllers/cfg/invalidator/manager.go index 53558e83d..e0905122b 100644 --- a/internal/controllers/cfg/invalidator/manager.go +++ b/internal/controllers/cfg/invalidator/manager.go @@ -41,6 +41,7 @@ type CacheInvalidator struct { TargetsCache *cache.CompiledTargetsCache[string] JSONPathCache *cache.JSONPathCache ImpersonationCache *cache.ImpersonationCache + RegexCache *cache.RegexCache } func (r *CacheInvalidator) NeedLeaderElection() bool { @@ -171,6 +172,10 @@ func (r *CacheInvalidator) rebuildCaches( ) error { var errs []error + if err := r.rebuildRegexCache(ctx, log); err != nil { + errs = append(errs, fmt.Errorf("rebuild Regex cache: %w", err)) + } + if err := r.rebuildJSONPathCache(ctx, log); err != nil { errs = append(errs, fmt.Errorf("rebuild JSONPath cache: %w", err)) } diff --git a/internal/controllers/cfg/invalidator/regex.go b/internal/controllers/cfg/invalidator/regex.go new file mode 100644 index 000000000..e3ac0373e --- /dev/null +++ b/internal/controllers/cfg/invalidator/regex.go @@ -0,0 +1,79 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package invalidator + +import ( + "context" + "fmt" + + "github.com/go-logr/logr" + + capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" + "github.com/projectcapsule/capsule/internal/cache" + "github.com/projectcapsule/capsule/pkg/api" + "github.com/projectcapsule/capsule/pkg/api/rules" +) + +func (r *CacheInvalidator) rebuildRegexCache(ctx context.Context, log logr.Logger) error { + ruleStatuses := &capsulev1beta2.RuleStatusList{} + if err := r.List(ctx, ruleStatuses); err != nil { + return err + } + + log.V(5).Info("rebuilding regex cache", + "regexesBefore", r.RegexCache.Stats(), + "ruleStatuses", len(ruleStatuses.Items), + ) + + r.RegexCache.Reset() + + expressions := make(map[string]api.RegExpression) + + for i := range ruleStatuses.Items { + rs := &ruleStatuses.Items[i] + + collectRegexExpressionsFromNamespaceRules(expressions, rs.Spec) + collectRegexExpressionsFromNamespaceRules(expressions, rs.Status.Rules) + } + + for _, expr := range expressions { + if _, _, err := r.RegexCache.GetOrCompile(expr); err != nil { + return fmt.Errorf("build regex cache entry %q: %w", expr.Expression, err) + } + } + + log.V(5).Info("rebuilt regex cache", + "uniqueExpressions", len(expressions), + "regexesAfter", r.RegexCache.Stats(), + ) + + return nil +} + +func collectRegexExpressionsFromNamespaceRules( + set map[string]api.RegExpression, + r []*rules.NamespaceRuleBodyNamespace, +) { + for _, rule := range r { + collectRegexExpressionsFromNamespaceRule(set, rule) + } +} + +func collectRegexExpressionsFromNamespaceRule( + set map[string]api.RegExpression, + rule *rules.NamespaceRuleBodyNamespace, +) { + if rule == nil { + return + } + + for _, registry := range rule.Enforce.Registries { + expr := registry.RegExpression + if expr.Expression == "" { + continue + } + + set[cache.HashRegex(expr)] = expr + } +} diff --git a/internal/controllers/cfg/invalidator/registries.go b/internal/controllers/cfg/invalidator/registries.go index 95304d54a..d4cf59bbe 100644 --- a/internal/controllers/cfg/invalidator/registries.go +++ b/internal/controllers/cfg/invalidator/registries.go @@ -5,6 +5,7 @@ package invalidator import ( "context" + "fmt" "github.com/go-logr/logr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -25,26 +26,34 @@ func (r *CacheInvalidator) rebuildRuleStatusRegistryCache(ctx context.Context, l } log.V(5).Info("rebuilding registry cache from existing rules", - "rules", len(rsList.Items), - "cache_rules_before", r.RegistryCache.Stats(), + "ruleStatuses", len(rsList.Items), + "cacheRulesBefore", r.RegistryCache.Stats(), ) r.RegistryCache.Reset() - for _, item := range rsList.Items { - regs := item.Status.Rule.Enforce.Registries - if len(regs) == 0 { - continue - } - - if _, _, err := r.RegistryCache.GetOrBuild(regs); err != nil { - return err + for i := range rsList.Items { + item := &rsList.Items[i] + + for _, rule := range item.Status.Rules { + if rule == nil || len(rule.Enforce.Registries) == 0 { + continue + } + + if _, _, err := r.RegistryCache.GetOrBuild(rule.Enforce.Registries); err != nil { + return fmt.Errorf( + "build registry cache for RuleStatus %s/%s: %w", + item.Namespace, + item.Name, + err, + ) + } } } log.V(5).Info("rebuilt registry cache from existing rules", - "rules", len(rsList.Items), - "cache_rules_after", r.RegistryCache.Stats(), + "ruleStatuses", len(rsList.Items), + "cacheRulesAfter", r.RegistryCache.Stats(), ) return nil diff --git a/internal/controllers/rulestatus/manager.go b/internal/controllers/rulestatus/manager.go index 9aa58a630..e93703b0b 100644 --- a/internal/controllers/rulestatus/manager.go +++ b/internal/controllers/rulestatus/manager.go @@ -26,8 +26,9 @@ 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" "github.com/projectcapsule/capsule/pkg/runtime/configuration" "github.com/projectcapsule/capsule/pkg/runtime/predicates" ) @@ -64,17 +65,17 @@ func (r *Manager) SetupWithManager(mgr ctrl.Manager, ctrlConfig utils.Controller } func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ctrl.Result, err error) { - r.Log = r.Log.WithValues("Request.Name", request.Name) + log := r.Log.WithValues("Request.Name", request.Name) instance := &capsulev1beta2.RuleStatus{} if err = r.Get(ctx, request.NamespacedName, instance); err != nil { if apierrors.IsNotFound(err) { - r.Log.V(5).Info("request object not found, could have been deleted after reconcile request") + log.V(5).Info("request object not found, could have been deleted after reconcile request") return reconcile.Result{}, nil } - r.Log.Error(err, "error reading the object") + log.Error(err, "error reading the object") return result, err } @@ -113,6 +114,15 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct err = nil }() + // Best-Effort for Updating the status + if updateErr := r.updateReconcilingStatus(ctx, instance); updateErr != nil { + if caperrors.IgnoreGone(updateErr) { + return reconcile.Result{}, nil + } + + log.Error(updateErr, "failed to update status") + } + // Reconcile if err = r.reconcile(ctx, instance); err != nil { err = fmt.Errorf("cannot collect available resources: %w", err) @@ -125,27 +135,39 @@ func (r Manager) Reconcile(ctx context.Context, request ctrl.Request) (result ct reconcileError = fmt.Errorf("had errors reconciling") } - r.Log.V(4).Info("reconciling completed") + log.V(4).Info("reconciling completed") return ctrl.Result{}, reconcileError } -func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleStatus) (err error) { - out := api.NamespaceRuleBodyNamespace{} +func (r Manager) reconcile(ctx context.Context, instance *capsulev1beta2.RuleStatus) error { + ruleStatus := make([]*rules.NamespaceRuleBodyNamespace, 0, len(instance.Spec)) for _, rule := range instance.Spec { if rule == nil { continue } - // Merge enforce body (for now: only registries) - // Preserve order: append in the order rules are declared. - if len(rule.Enforce.Registries) > 0 { - out.Enforce.Registries = append(out.Enforce.Registries, rule.Enforce.Registries...) + normalized := *rule + normalized.Enforce = rule.Enforce + + normalized.Enforce.Registries = append( + []rules.OCIRegistry(nil), + rule.Enforce.Registries..., + ) + + // Keep status compact: skip empty enforce blocks. + if len(normalized.Enforce.Registries) == 0 { + continue } + + ruleStatus = append(ruleStatus, &normalized) } - instance.Status.Rule = out + instance.Status.Rules = ruleStatus + + //nolint:staticcheck + instance.Status.Rule = rules.NamespaceRuleBodyNamespace{} return nil } @@ -184,3 +206,16 @@ func (r *Manager) updateStatus(ctx context.Context, instance *capsulev1beta2.Rul return nil }) } + +func (r *Manager) updateReconcilingStatus(ctx context.Context, instance *capsulev1beta2.RuleStatus) error { + return retry.RetryOnConflict(retry.DefaultBackoff, func() (err error) { + latest := &capsulev1beta2.RuleStatus{} + if err = r.reader.Get(ctx, types.NamespacedName{Name: instance.GetName(), Namespace: instance.GetNamespace()}, latest); err != nil { + return err + } + + latest.Status.Conditions.UpdateConditionByType(meta.NewReadyConditionReconcilingReason(instance)) + + return r.Status().Update(ctx, latest) + }) +} diff --git a/internal/controllers/tenant/rulestatus.go b/internal/controllers/tenant/rulestatus.go index 6c8827c61..24104aee9 100644 --- a/internal/controllers/tenant/rulestatus.go +++ b/internal/controllers/tenant/rulestatus.go @@ -13,8 +13,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 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/rules" "github.com/projectcapsule/capsule/pkg/tenant" ) @@ -44,7 +44,7 @@ func (r *Manager) ensureRuleStatus( log logr.Logger, tnt *capsulev1beta2.Tenant, namespace *corev1.Namespace, - body *api.NamespaceRuleBodyNamespace, + body []*rules.NamespaceRuleBodyNamespace, ) error { rule := &capsulev1beta2.RuleStatus{ ObjectMeta: metav1.ObjectMeta{ @@ -65,7 +65,7 @@ func (r *Manager) ensureRuleStatus( rule.SetLabels(labels) if body != nil { - rule.Spec = []*api.NamespaceRuleBodyNamespace{body} + rule.Spec = body } return controllerutil.SetControllerReference(tnt, rule, r.Scheme()) diff --git a/internal/webhook/pod/containerregistry_legacy.go b/internal/webhook/pod/containerregistry_legacy.go index 5508c0643..ca610b312 100644 --- a/internal/webhook/pod/containerregistry_legacy.go +++ b/internal/webhook/pod/containerregistry_legacy.go @@ -12,8 +12,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/api" caperrors "github.com/projectcapsule/capsule/pkg/api/errors" + "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" @@ -37,7 +37,7 @@ func (h *containerRegistryLegacyHandler) OnCreate( _ admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, - _ *api.NamespaceRuleBodyNamespace, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.validate(req, pod, tnt, recorder) @@ -52,7 +52,7 @@ func (h *containerRegistryLegacyHandler) OnUpdate( _ admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, - _ *api.NamespaceRuleBodyNamespace, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.validate(req, pod, tnt, recorder) @@ -66,7 +66,7 @@ func (h *containerRegistryLegacyHandler) OnDelete( admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, - *api.NamespaceRuleBodyNamespace, + []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(context.Context, admission.Request) *admission.Response { return nil diff --git a/internal/webhook/pod/imagepullpolicy.go b/internal/webhook/pod/imagepullpolicy.go index b7a267d69..b34a3acb4 100644 --- a/internal/webhook/pod/imagepullpolicy.go +++ b/internal/webhook/pod/imagepullpolicy.go @@ -12,8 +12,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/api" 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/handlers" @@ -32,7 +32,7 @@ func (h *imagePullPolicy) OnCreate( _ admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, - _ *api.NamespaceRuleBodyNamespace, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.validate(req, pod, tnt, recorder) @@ -47,7 +47,7 @@ func (h *imagePullPolicy) OnUpdate( _ admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, - _ *api.NamespaceRuleBodyNamespace, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.validate(req, pod, tnt, recorder) @@ -61,7 +61,7 @@ func (h *imagePullPolicy) OnDelete( admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, - *api.NamespaceRuleBodyNamespace, + []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(context.Context, admission.Request) *admission.Response { return nil diff --git a/internal/webhook/pod/priorityclass.go b/internal/webhook/pod/priorityclass.go index da2c02092..f291c912d 100644 --- a/internal/webhook/pod/priorityclass.go +++ b/internal/webhook/pod/priorityclass.go @@ -14,8 +14,8 @@ import ( capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" "github.com/projectcapsule/capsule/internal/webhook/utils" - "github.com/projectcapsule/capsule/pkg/api" 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/handlers" @@ -34,7 +34,7 @@ func (h *priorityClass) OnCreate( decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, - _ *api.NamespaceRuleBodyNamespace, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { allowed := tnt.Spec.PriorityClasses @@ -96,7 +96,7 @@ func (h *priorityClass) OnUpdate( admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, - *api.NamespaceRuleBodyNamespace, + []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(context.Context, admission.Request) *admission.Response { return nil @@ -110,7 +110,7 @@ func (h *priorityClass) OnDelete( admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, - *api.NamespaceRuleBodyNamespace, + []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(context.Context, admission.Request) *admission.Response { return nil diff --git a/internal/webhook/pod/registry.go b/internal/webhook/pod/registry.go index 68a937359..cff1c7ad4 100644 --- a/internal/webhook/pod/registry.go +++ b/internal/webhook/pod/registry.go @@ -13,11 +13,12 @@ import ( 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" + "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" @@ -43,25 +44,25 @@ func (h *registryHandler) OnCreate( _ admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, - rule *api.NamespaceRuleBodyNamespace, + ruleBlocks []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(req, pod, tnt, recorder, rule) + return h.validate(ctx, req, pod, tnt, recorder, ruleBlocks) } } func (h *registryHandler) OnUpdate( _ client.Client, _ client.Reader, - old *corev1.Pod, + _ *corev1.Pod, pod *corev1.Pod, _ admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, - rule *api.NamespaceRuleBodyNamespace, + ruleBlocks []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { - return h.validate(req, pod, tnt, recorder, rule) + return h.validate(ctx, req, pod, tnt, recorder, ruleBlocks) } } @@ -72,7 +73,7 @@ func (h *registryHandler) OnDelete( admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, - *api.NamespaceRuleBodyNamespace, + []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(context.Context, admission.Request) *admission.Response { return nil @@ -80,41 +81,45 @@ func (h *registryHandler) OnDelete( } func (h *registryHandler) validate( + ctx context.Context, req admission.Request, pod *corev1.Pod, tnt *capsulev1beta2.Tenant, recorder events.EventRecorder, - rule *api.NamespaceRuleBodyNamespace, + ruleBlocks []*rules.NamespaceRuleBodyNamespace, ) *admission.Response { - if rule == nil || len(rule.Enforce.Registries) == 0 { - resp := admission.Allowed("no registry rules") + if h.cache == nil { + resp := admission.Errored(http.StatusInternalServerError, fmt.Errorf("registry rule set cache is nil")) return &resp } - rs, _, err := h.cache.GetOrBuild(rule.Enforce.Registries) - if err != nil { - resp := admission.Errored(http.StatusInternalServerError, err) + log.FromContext(ctx).V(5).Info( + "handling pod registry rules", + "pod", pod.Name, + "namespace", pod.Namespace, + "rules", len(ruleBlocks), + ) - return &resp + if len(ruleBlocks) == 0 { + return nil } - if rs == nil { - resp := admission.Allowed("no registry rules") + warnings := make([]string, 0) - return &resp + if resp := h.validateContainers(req, pod, tnt, recorder, ruleBlocks, &warnings); resp != nil { + return resp } - if rs.HasImages { - if resp := h.validateContainers(req, pod, tnt, recorder, rs); resp != nil { - return resp - } + if resp := h.validateVolumes(req, pod, tnt, recorder, ruleBlocks, &warnings); resp != nil { + return resp } - if rs.HasVolumes { - if resp := h.validateVolumes(req, pod, tnt, recorder, rs); resp != nil { - return resp - } + if len(warnings) > 0 { + resp := admission.Allowed("registry rules audited") + resp.Warnings = append(resp.Warnings, warnings...) + + return &resp } return nil @@ -125,25 +130,62 @@ func (h *registryHandler) validateContainers( pod *corev1.Pod, tnt *capsulev1beta2.Tenant, recorder events.EventRecorder, - rs *cache.RuleSet, + ruleBlocks []*rules.NamespaceRuleBodyNamespace, + warnings *[]string, ) *admission.Response { for i := range pod.Spec.InitContainers { c := pod.Spec.InitContainers[i] - if resp := h.verifyOCIReference(recorder, req, tnt, pod, rs, api.ValidateImages, c.Image, c.ImagePullPolicy, fmt.Sprintf("initContainers[%d]", i)); resp != nil { + + if resp := h.verifyOCIReference( + recorder, + req, + tnt, + pod, + ruleBlocks, + rules.ValidateImages, + c.Image, + c.ImagePullPolicy, + fmt.Sprintf("initContainers[%d]", i), + warnings, + ); resp != nil { return resp } } for i := range pod.Spec.EphemeralContainers { c := pod.Spec.EphemeralContainers[i] - if resp := h.verifyOCIReference(recorder, req, tnt, pod, rs, api.ValidateImages, c.Image, c.ImagePullPolicy, fmt.Sprintf("ephemeralContainers[%d]", i)); resp != nil { + + if resp := h.verifyOCIReference( + recorder, + req, + tnt, + pod, + ruleBlocks, + rules.ValidateImages, + c.Image, + c.ImagePullPolicy, + fmt.Sprintf("ephemeralContainers[%d]", i), + warnings, + ); resp != nil { return resp } } for i := range pod.Spec.Containers { c := pod.Spec.Containers[i] - if resp := h.verifyOCIReference(recorder, req, tnt, pod, rs, api.ValidateImages, c.Image, c.ImagePullPolicy, fmt.Sprintf("containers[%d]", i)); resp != nil { + + if resp := h.verifyOCIReference( + recorder, + req, + tnt, + pod, + ruleBlocks, + rules.ValidateImages, + c.Image, + c.ImagePullPolicy, + fmt.Sprintf("containers[%d]", i), + warnings, + ); resp != nil { return resp } } @@ -156,7 +198,8 @@ func (h *registryHandler) validateVolumes( pod *corev1.Pod, tnt *capsulev1beta2.Tenant, recorder events.EventRecorder, - rs *cache.RuleSet, + ruleBlocks []*rules.NamespaceRuleBodyNamespace, + warnings *[]string, ) *admission.Response { for i := range pod.Spec.Volumes { v := pod.Spec.Volumes[i] @@ -166,16 +209,26 @@ func (h *registryHandler) validateVolumes( ref := strings.TrimSpace(v.Image.Reference) if ref == "" { - return ad.Deny( + return h.denyWithEvent( + recorder, + tnt, + pod, + evt.ReasonForbiddenContainerRegistry, fmt.Sprintf("volume %q has empty image.reference", v.Name), ) } if resp := h.verifyOCIReference( - recorder, req, tnt, pod, - rs, api.ValidateVolumes, - ref, v.Image.PullPolicy, + recorder, + req, + tnt, + pod, + ruleBlocks, + rules.ValidateVolumes, + ref, + v.Image.PullPolicy, fmt.Sprintf("volumes[%d](%s)", i, v.Name), + warnings, ); resp != nil { return resp } @@ -184,136 +237,275 @@ func (h *registryHandler) validateVolumes( return nil } -type resolvedRegistryConfig struct { - allowed bool - allowedPolicy map[corev1.PullPolicy]struct{} // nil => no restriction -} - -func resolveRegistryConfig( - rules []cache.CompiledRule, - ref string, - target api.RegistryValidationTarget, -) resolvedRegistryConfig { - var res resolvedRegistryConfig - - for i := range rules { - r := rules[i] - - switch target { - case api.ValidateImages: - if !r.ValidateImages { // adjust field name - continue - } - case api.ValidateVolumes: - if !r.ValidateVolumes { // adjust field name - continue - } - } - - if !r.RE.MatchString(ref) { // adjust field name - continue - } - - res.allowed = true - - // only override pullpolicy restriction when explicitly set by a later matching rule - if len(r.AllowedPolicy) > 0 { // adjust field name - res.allowedPolicy = r.AllowedPolicy - } - } - - return res -} - func (h *registryHandler) verifyOCIReference( recorder events.EventRecorder, req admission.Request, tnt *capsulev1beta2.Tenant, pod *corev1.Pod, - rs *cache.RuleSet, - target api.RegistryValidationTarget, + ruleBlocks []*rules.NamespaceRuleBodyNamespace, + target rules.RegistryValidationTarget, reference string, pullPolicy corev1.PullPolicy, where string, + warnings *[]string, ) *admission.Response { ref := strings.TrimSpace(reference) if ref == "" { - msg := fmt.Sprintf("%s has empty reference", where) - - recorder.Eventf( - pod, + return h.denyWithEvent( + recorder, tnt, - corev1.EventTypeWarning, + pod, evt.ReasonForbiddenContainerRegistry, - evt.ActionValidationDenied, - msg, + 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, ) - return ad.Deny(msg) + h.auditWithEvent(recorder, tnt, pod, msg) + + if warnings != nil { + *warnings = append(*warnings, msg) + } } - // Match rules against the FULL OCI reference string. - // This avoids relying on parsing logic and supports nested paths, digests, etc. - cfg := resolveRegistryConfig(rs.Compiled, ref, target) - if !cfg.allowed { - msg := fmt.Sprintf("%s reference %q is not allowed", where, ref) + if evaluation.Decision == nil { + return nil + } - recorder.Eventf( + 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, - corev1.EventTypeWarning, + pod, evt.ReasonForbiddenContainerRegistry, - evt.ActionValidationDenied, msg, ) - return ad.Deny(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 } +} - // No defaulting: enforce only if restricted; empty pullPolicy is rejected under restriction. - if cfg.allowedPolicy != nil { - allowed := formatAllowedPullPolicies(cfg.allowedPolicy) +type registryDecision struct { + rules.RuleDecision - if pullPolicy == "" { - msg := fmt.Sprintf( - "%s reference %q must explicitly set pullPolicy (allowed: %s)", - where, ref, allowed, - ) + Matched *cache.CompiledRule +} - recorder.Eventf( - pod, - tnt, - corev1.EventTypeWarning, - evt.ReasonForbiddenPullPolicy, - evt.ActionValidationDenied, - msg, - ) +type registryEvaluation struct { + Decision *registryDecision + Audits []*registryDecision +} - return ad.Deny(msg) +func (h *registryHandler) evaluateOCIReference( + ruleBlocks []*rules.NamespaceRuleBodyNamespace, + target rules.RegistryValidationTarget, + ref string, +) (*registryEvaluation, error) { + evaluation := ®istryEvaluation{} + + for _, rule := range ruleBlocks { + if rule == nil || len(rule.Enforce.Registries) == 0 { + continue } - if _, ok := cfg.allowedPolicy[pullPolicy]; !ok { - msg := fmt.Sprintf( - "%s reference %q uses pullPolicy=%s which is not allowed (allowed: %s)", - where, ref, pullPolicy, allowed, - ) + rs, _, err := h.cache.GetOrBuild(rule.Enforce.Registries) + if err != nil { + return nil, err + } - recorder.Eventf( - pod, - tnt, - corev1.EventTypeWarning, - evt.ReasonForbiddenPullPolicy, - evt.ActionValidationDenied, - msg, - ) + if rs == nil { + continue + } - return ad.Deny(msg) + matched, err := h.cache.MatchReference(rs, ref, target) + if err != nil { + return nil, err } + + if matched == nil { + continue + } + + action := rule.Enforce.Action + if action == "" { + action = rules.ActionTypeDeny + } + + 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 "" diff --git a/internal/webhook/pod/runtimeclass.go b/internal/webhook/pod/runtimeclass.go index 9508a07f6..01d6a85e2 100644 --- a/internal/webhook/pod/runtimeclass.go +++ b/internal/webhook/pod/runtimeclass.go @@ -15,8 +15,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2" - "github.com/projectcapsule/capsule/pkg/api" 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/handlers" @@ -35,7 +35,7 @@ func (h *runtimeClass) OnCreate( decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, - _ *api.NamespaceRuleBodyNamespace, + _ []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(ctx context.Context, req admission.Request) *admission.Response { return h.validate(ctx, reader, recorder, req, pod, tnt) @@ -50,7 +50,7 @@ func (h *runtimeClass) OnUpdate( admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, - *api.NamespaceRuleBodyNamespace, + []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(context.Context, admission.Request) *admission.Response { return nil @@ -64,7 +64,7 @@ func (h *runtimeClass) OnDelete( admission.Decoder, events.EventRecorder, *capsulev1beta2.Tenant, - *api.NamespaceRuleBodyNamespace, + []*rules.NamespaceRuleBodyNamespace, ) handlers.Func { return func(context.Context, admission.Request) *admission.Response { return nil diff --git a/internal/webhook/tenant/validation/rule_validator.go b/internal/webhook/tenant/validation/rule_validator.go index ea449cb24..1eba8f33e 100644 --- a/internal/webhook/tenant/validation/rule_validator.go +++ b/internal/webhook/tenant/validation/rule_validator.go @@ -74,13 +74,11 @@ func ValidateRule(tnt *capsulev1beta2.Tenant, req admission.Request) *admission. return nil } - // Validate Rules for i, rule := range tnt.Spec.Rules { if rule == nil { continue } - // Validate NamespaceSelector (if provided) if rule.NamespaceSelector != nil { if _, err := metav1.LabelSelectorAsSelector(rule.NamespaceSelector); err != nil { return ad.Deny( @@ -89,11 +87,24 @@ func ValidateRule(tnt *capsulev1beta2.Tenant, req admission.Request) *admission. } } - // Validate Registries - for _, r := range rule.Enforce.Registries { - if _, err := regexp.Compile(r.Registry); err != nil { + for j, registry := range rule.Enforce.Registries { + expr := registry.Expression() + + if expr.Expression == "" { + return ad.Deny( + fmt.Sprintf("rules[%d].enforce.registries[%d].exp must not be empty", i, j), + ) + } + + if _, err := regexp.Compile(expr.Expression); err != nil { return ad.Deny( - fmt.Sprintf("unable to compile regex %q: %v", r.Registry, err), + fmt.Sprintf( + "rules[%d].enforce.registries[%d].exp %q is invalid: %v", + i, + j, + expr.Expression, + err, + ), ) } } diff --git a/pkg/api/namespace_rule_type.go b/pkg/api/namespace_rule_type.go deleted file mode 100644 index 9ca86b66c..000000000 --- a/pkg/api/namespace_rule_type.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright 2020-2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package api - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// For future implementation where users might manage RuleStatus CRs themselves -// +kubebuilder:object:generate=true -type NamespaceRuleBodyNamespace struct { - // Enforcement for given rule - //+optional - Enforce NamespaceRuleEnforceBody `json:"enforce,omitzero"` -} - -// Rules Distributed via Tenants -// +kubebuilder:object:generate=true -type NamespaceRuleBodyTenant struct { - NamespaceRuleBodyNamespace `json:",inline"` - - // Select namespaces which are going to be targeted with this rule - NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"` - - // Permissions for given rule - //+optional - Permissions NamespaceRulePermissionBody `json:"permissions,omitzero"` -} - -// +kubebuilder:object:generate=true -type NamespaceRuleEnforceBody 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 - Registries []OCIRegistry `json:"registries,omitempty"` -} - -// +kubebuilder:object:generate=true -type NamespaceRulePermissionBody struct { - // Define Promotion Rules which distributed additional ClusterRoles across the Tenant - // for promoted ServiceAccounts. - Promotions []*NamespaceRulePromotionRule `json:"rules,omitempty"` -} - -// +kubebuilder:object:generate=true -type NamespaceRulePromotionRule struct { - // ClusterRoles granted to the promoted ServiceAccounts across the Tenant - // kubebuilder:validation:Minimum=1 - ClusterRoles []string `json:"clusterRoles,omitempty"` - - // Match ServiceAccounts which are promoted which are granted these additional ClusterRoles - // across the Tenant - Selector *metav1.LabelSelector `json:"selector,omitempty"` -} diff --git a/pkg/api/regex.go b/pkg/api/regex.go new file mode 100644 index 000000000..fa2f338de --- /dev/null +++ b/pkg/api/regex.go @@ -0,0 +1,13 @@ +// 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/action_type.go b/pkg/api/rules/action_type.go new file mode 100644 index 000000000..009986337 --- /dev/null +++ b/pkg/api/rules/action_type.go @@ -0,0 +1,18 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +const ( + ActionTypeAllow ActionType = "allow" + ActionTypeDeny ActionType = "deny" + ActionTypeAudit ActionType = "audit" +) + +// +kubebuilder:validation:Enum=allow;deny;audit +type ActionType string + +type RuleDecision struct { + Action ActionType + Rule *NamespaceRuleBodyNamespace +} diff --git a/pkg/api/rules/enforce_registry_types.go b/pkg/api/rules/enforce_registry_types.go new file mode 100644 index 000000000..aa07c6558 --- /dev/null +++ b/pkg/api/rules/enforce_registry_types.go @@ -0,0 +1,55 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + corev1 "k8s.io/api/core/v1" + + "github.com/projectcapsule/capsule/pkg/api" +) + +// +kubebuilder:validation:Enum=Always;Never;IfNotPresent +type ImagePullPolicySpec string + +func (i ImagePullPolicySpec) String() string { + return string(i) +} + +// +kubebuilder:validation:Enum=pod/images;pod/volumes +type RegistryValidationTarget string + +const ( + ValidateImages RegistryValidationTarget = "pod/images" + ValidateVolumes RegistryValidationTarget = "pod/volumes" +) + +// +kubebuilder:object:generate=true +type OCIRegistry struct { + api.RegExpression `json:",inline"` + + // Deprecated: Use exp field + // + // OCI Registry endpoint, is treated as regular expression. + Registry string `json:"url,omitempty"` + + // 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"` + + // Requesting Resources + //+kubebuilder:default:={pod/images,pod/volumes} + Validation []RegistryValidationTarget `json:"validation,omitempty"` +} + +func (r OCIRegistry) Expression() api.RegExpression { + if r.RegExpression.Expression != "" { + return r.RegExpression + } + + return api.RegExpression{ + Expression: r.Registry, + Negate: false, + } +} diff --git a/pkg/api/rules/enforce_types.go b/pkg/api/rules/enforce_types.go new file mode 100644 index 000000000..3a5130570 --- /dev/null +++ b/pkg/api/rules/enforce_types.go @@ -0,0 +1,18 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +// +kubebuilder:object:generate=true +type NamespaceRuleEnforceBody struct { + // Declare the action being performed on the enforcement rule: + // deny: On match, deny admission request + // allow: On match, allowed admission request + // audit: On match, audit (post event) of admission request + //+kubebuilder:default:=deny + Action ActionType `json:"action,omitempty"` + + // 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 + Registries []OCIRegistry `json:"registries,omitempty"` +} diff --git a/pkg/api/rules/permission_types.go b/pkg/api/rules/permission_types.go new file mode 100644 index 000000000..658048fc2 --- /dev/null +++ b/pkg/api/rules/permission_types.go @@ -0,0 +1,26 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:object:generate=true +type NamespaceRulePermissionBody struct { + // Define Promotion Rules which distributed additional ClusterRoles across the Tenant + // for promoted ServiceAccounts. + Promotions []*NamespaceRulePromotionRule `json:"rules,omitempty"` +} + +// +kubebuilder:object:generate=true +type NamespaceRulePromotionRule struct { + // ClusterRoles granted to the promoted ServiceAccounts across the Tenant + // kubebuilder:validation:Minimum=1 + ClusterRoles []string `json:"clusterRoles,omitempty"` + + // Match ServiceAccounts which are promoted which are granted these additional ClusterRoles + // across the Tenant + Selector *metav1.LabelSelector `json:"selector,omitempty"` +} diff --git a/pkg/api/rules/rule_body_types.go b/pkg/api/rules/rule_body_types.go new file mode 100644 index 000000000..db1bdf875 --- /dev/null +++ b/pkg/api/rules/rule_body_types.go @@ -0,0 +1,29 @@ +// Copyright 2020-2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package rules + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// For future implementation where users might manage RuleStatus CRs themselves +// +kubebuilder:object:generate=true +type NamespaceRuleBodyNamespace struct { + // Enforcement for given rule + //+optional + Enforce NamespaceRuleEnforceBody `json:"enforce,omitzero"` +} + +// Rules Distributed via Tenants +// +kubebuilder:object:generate=true +type NamespaceRuleBodyTenant struct { + NamespaceRuleBodyNamespace `json:",inline"` + + // Select namespaces which are going to be targeted with this rule + NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"` + + // Permissions for given rule + //+optional + Permissions NamespaceRulePermissionBody `json:"permissions,omitzero"` +} diff --git a/pkg/api/rules/zz_generated.deepcopy.go b/pkg/api/rules/zz_generated.deepcopy.go new file mode 100644 index 000000000..9b2e46cd8 --- /dev/null +++ b/pkg/api/rules/zz_generated.deepcopy.go @@ -0,0 +1,150 @@ +//go:build !ignore_autogenerated + +// Copyright 2020-2023 Project Capsule Authors. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by controller-gen. DO NOT EDIT. + +package rules + +import ( + "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceRuleBodyNamespace) DeepCopyInto(out *NamespaceRuleBodyNamespace) { + *out = *in + in.Enforce.DeepCopyInto(&out.Enforce) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleBodyNamespace. +func (in *NamespaceRuleBodyNamespace) DeepCopy() *NamespaceRuleBodyNamespace { + if in == nil { + return nil + } + out := new(NamespaceRuleBodyNamespace) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceRuleBodyTenant) DeepCopyInto(out *NamespaceRuleBodyTenant) { + *out = *in + in.NamespaceRuleBodyNamespace.DeepCopyInto(&out.NamespaceRuleBodyNamespace) + if in.NamespaceSelector != nil { + in, out := &in.NamespaceSelector, &out.NamespaceSelector + *out = new(metav1.LabelSelector) + (*in).DeepCopyInto(*out) + } + in.Permissions.DeepCopyInto(&out.Permissions) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleBodyTenant. +func (in *NamespaceRuleBodyTenant) DeepCopy() *NamespaceRuleBodyTenant { + if in == nil { + return nil + } + out := new(NamespaceRuleBodyTenant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody) { + *out = *in + if in.Registries != nil { + in, out := &in.Registries, &out.Registries + *out = make([]OCIRegistry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody. +func (in *NamespaceRuleEnforceBody) DeepCopy() *NamespaceRuleEnforceBody { + if in == nil { + return nil + } + out := new(NamespaceRuleEnforceBody) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceRulePermissionBody) DeepCopyInto(out *NamespaceRulePermissionBody) { + *out = *in + if in.Promotions != nil { + in, out := &in.Promotions, &out.Promotions + *out = make([]*NamespaceRulePromotionRule, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(NamespaceRulePromotionRule) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRulePermissionBody. +func (in *NamespaceRulePermissionBody) DeepCopy() *NamespaceRulePermissionBody { + if in == nil { + return nil + } + out := new(NamespaceRulePermissionBody) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceRulePromotionRule) DeepCopyInto(out *NamespaceRulePromotionRule) { + *out = *in + if in.ClusterRoles != nil { + in, out := &in.ClusterRoles, &out.ClusterRoles + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(metav1.LabelSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRulePromotionRule. +func (in *NamespaceRulePromotionRule) DeepCopy() *NamespaceRulePromotionRule { + if in == nil { + return nil + } + out := new(NamespaceRulePromotionRule) + in.DeepCopyInto(out) + return out +} + +// 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 + if in.Policy != nil { + in, out := &in.Policy, &out.Policy + *out = make([]v1.PullPolicy, len(*in)) + copy(*out, *in) + } + if in.Validation != nil { + in, out := &in.Validation, &out.Validation + *out = make([]RegistryValidationTarget, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OCIRegistry. +func (in *OCIRegistry) DeepCopy() *OCIRegistry { + if in == nil { + return nil + } + out := new(OCIRegistry) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index 69764557e..a1c045986 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -204,117 +204,6 @@ func (in *LimitRangesSpec) DeepCopy() *LimitRangesSpec { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NamespaceRuleBodyNamespace) DeepCopyInto(out *NamespaceRuleBodyNamespace) { - *out = *in - in.Enforce.DeepCopyInto(&out.Enforce) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleBodyNamespace. -func (in *NamespaceRuleBodyNamespace) DeepCopy() *NamespaceRuleBodyNamespace { - if in == nil { - return nil - } - out := new(NamespaceRuleBodyNamespace) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NamespaceRuleBodyTenant) DeepCopyInto(out *NamespaceRuleBodyTenant) { - *out = *in - in.NamespaceRuleBodyNamespace.DeepCopyInto(&out.NamespaceRuleBodyNamespace) - if in.NamespaceSelector != nil { - in, out := &in.NamespaceSelector, &out.NamespaceSelector - *out = new(v1.LabelSelector) - (*in).DeepCopyInto(*out) - } - in.Permissions.DeepCopyInto(&out.Permissions) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleBodyTenant. -func (in *NamespaceRuleBodyTenant) DeepCopy() *NamespaceRuleBodyTenant { - if in == nil { - return nil - } - out := new(NamespaceRuleBodyTenant) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NamespaceRuleEnforceBody) DeepCopyInto(out *NamespaceRuleEnforceBody) { - *out = *in - if in.Registries != nil { - in, out := &in.Registries, &out.Registries - *out = make([]OCIRegistry, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRuleEnforceBody. -func (in *NamespaceRuleEnforceBody) DeepCopy() *NamespaceRuleEnforceBody { - if in == nil { - return nil - } - out := new(NamespaceRuleEnforceBody) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NamespaceRulePermissionBody) DeepCopyInto(out *NamespaceRulePermissionBody) { - *out = *in - if in.Promotions != nil { - in, out := &in.Promotions, &out.Promotions - *out = make([]*NamespaceRulePromotionRule, len(*in)) - for i := range *in { - if (*in)[i] != nil { - in, out := &(*in)[i], &(*out)[i] - *out = new(NamespaceRulePromotionRule) - (*in).DeepCopyInto(*out) - } - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRulePermissionBody. -func (in *NamespaceRulePermissionBody) DeepCopy() *NamespaceRulePermissionBody { - if in == nil { - return nil - } - out := new(NamespaceRulePermissionBody) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NamespaceRulePromotionRule) DeepCopyInto(out *NamespaceRulePromotionRule) { - *out = *in - if in.ClusterRoles != nil { - in, out := &in.ClusterRoles, &out.ClusterRoles - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Selector != nil { - in, out := &in.Selector, &out.Selector - *out = new(v1.LabelSelector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceRulePromotionRule. -func (in *NamespaceRulePromotionRule) DeepCopy() *NamespaceRulePromotionRule { - if in == nil { - return nil - } - out := new(NamespaceRulePromotionRule) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkPolicySpec) DeepCopyInto(out *NetworkPolicySpec) { *out = *in @@ -399,6 +288,21 @@ 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/runtime/handlers/typed_tenant_ruleset.go b/pkg/runtime/handlers/typed_tenant_ruleset.go index 5568caa8f..ec5714104 100644 --- a/pkg/runtime/handlers/typed_tenant_ruleset.go +++ b/pkg/runtime/handlers/typed_tenant_ruleset.go @@ -15,15 +15,42 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" 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/rules" "github.com/projectcapsule/capsule/pkg/tenant" ) type TypedHandlerWithTenantWithRuleset[T client.Object] interface { - OnCreate(c client.Client, reader client.Reader, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, rule *api.NamespaceRuleBodyNamespace) Func - OnUpdate(c client.Client, reader client.Reader, obj T, old T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, rule *api.NamespaceRuleBodyNamespace) Func - OnDelete(c client.Client, reader client.Reader, obj T, decoder admission.Decoder, recorder events.EventRecorder, tnt *capsulev1beta2.Tenant, rule *api.NamespaceRuleBodyNamespace) Func + OnCreate( + c client.Client, + reader client.Reader, + obj T, + decoder admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + ruleBlocks []*rules.NamespaceRuleBodyNamespace, + ) Func + + OnUpdate( + c client.Client, + reader client.Reader, + old T, + obj T, + decoder admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + ruleBlocks []*rules.NamespaceRuleBodyNamespace, + ) Func + + OnDelete( + c client.Client, + reader client.Reader, + obj T, + decoder admission.Decoder, + recorder events.EventRecorder, + tnt *capsulev1beta2.Tenant, + ruleBlocks []*rules.NamespaceRuleBodyNamespace, + ) Func } type TypedTenantWithRulesetHandler[T client.Object] struct { @@ -31,7 +58,12 @@ type TypedTenantWithRulesetHandler[T client.Object] struct { Handlers []TypedHandlerWithTenantWithRuleset[T] } -func (h *TypedTenantWithRulesetHandler[T]) OnCreate(c client.Client, reader client.Reader, decoder admission.Decoder, recorder events.EventRecorder) Func { +func (h *TypedTenantWithRulesetHandler[T]) OnCreate( + c client.Client, + reader client.Reader, + decoder admission.Decoder, + recorder events.EventRecorder, +) Func { return func(ctx context.Context, req admission.Request) *admission.Response { tnt, err := h.resolveTenant(ctx, reader, req) if err != nil { @@ -47,13 +79,13 @@ func (h *TypedTenantWithRulesetHandler[T]) OnCreate(c client.Client, reader clie return ErroredResponse(err) } - rule, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt) + ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt) if err != nil { return ErroredResponse(err) } for _, hndl := range h.Handlers { - if response := hndl.OnCreate(c, reader, obj, decoder, recorder, tnt, rule)(ctx, req); response != nil { + if response := hndl.OnCreate(c, reader, obj, decoder, recorder, tnt, ruleBlocks)(ctx, req); response != nil { return response } } @@ -62,7 +94,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnCreate(c client.Client, reader clie } } -func (h *TypedTenantWithRulesetHandler[T]) OnUpdate(c client.Client, reader client.Reader, decoder admission.Decoder, recorder events.EventRecorder) Func { +func (h *TypedTenantWithRulesetHandler[T]) OnUpdate( + c client.Client, + reader client.Reader, + decoder admission.Decoder, + recorder events.EventRecorder, +) Func { return func(ctx context.Context, req admission.Request) *admission.Response { tnt, err := h.resolveTenant(ctx, c, req) if err != nil { @@ -83,13 +120,13 @@ func (h *TypedTenantWithRulesetHandler[T]) OnUpdate(c client.Client, reader clie return ErroredResponse(err) } - rule, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt) + ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt) if err != nil { return ErroredResponse(err) } for _, hndl := range h.Handlers { - if response := hndl.OnUpdate(c, reader, oldObj, newObj, decoder, recorder, tnt, rule)(ctx, req); response != nil { + if response := hndl.OnUpdate(c, reader, oldObj, newObj, decoder, recorder, tnt, ruleBlocks)(ctx, req); response != nil { return response } } @@ -98,7 +135,12 @@ func (h *TypedTenantWithRulesetHandler[T]) OnUpdate(c client.Client, reader clie } } -func (h *TypedTenantWithRulesetHandler[T]) OnDelete(c client.Client, reader client.Reader, decoder admission.Decoder, recorder events.EventRecorder) Func { +func (h *TypedTenantWithRulesetHandler[T]) OnDelete( + c client.Client, + reader client.Reader, + decoder admission.Decoder, + recorder events.EventRecorder, +) Func { return func(ctx context.Context, req admission.Request) *admission.Response { tnt, err := h.resolveTenant(ctx, reader, req) if err != nil { @@ -114,13 +156,13 @@ func (h *TypedTenantWithRulesetHandler[T]) OnDelete(c client.Client, reader clie return ErroredResponse(err) } - rule, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt) + ruleBlocks, err := h.resolveRuleset(ctx, c, req, req.Namespace, tnt) if err != nil { return ErroredResponse(err) } for _, hndl := range h.Handlers { - if response := hndl.OnDelete(c, reader, obj, decoder, recorder, tnt, rule)(ctx, req); response != nil { + if response := hndl.OnDelete(c, reader, obj, decoder, recorder, tnt, ruleBlocks)(ctx, req); response != nil { return response } } @@ -129,7 +171,11 @@ func (h *TypedTenantWithRulesetHandler[T]) OnDelete(c client.Client, reader clie } } -func (h *TypedTenantWithRulesetHandler[T]) resolveTenant(ctx context.Context, c client.Reader, req admission.Request) (*capsulev1beta2.Tenant, error) { +func (h *TypedTenantWithRulesetHandler[T]) resolveTenant( + ctx context.Context, + c client.Reader, + req admission.Request, +) (*capsulev1beta2.Tenant, error) { if req.Namespace == "" { return nil, nil } @@ -137,15 +183,15 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveTenant(ctx context.Context, c return tenant.GetTenantByNamespace(ctx, c, req.Namespace) } -// Resolve the corresponding managed ruleset for this namespace -// If not yet present try to calculate it. +// Resolve the corresponding managed ruleset for this namespace. +// If not yet present, try to calculate it. func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset( ctx context.Context, c client.Reader, req admission.Request, namespace string, tnt *capsulev1beta2.Tenant, -) (*api.NamespaceRuleBodyNamespace, error) { +) ([]*rules.NamespaceRuleBodyNamespace, error) { rs := &capsulev1beta2.RuleStatus{} key := types.NamespacedName{ Namespace: namespace, @@ -153,7 +199,7 @@ func (h *TypedTenantWithRulesetHandler[T]) resolveRuleset( } if err := c.Get(ctx, key, rs); err == nil { - return &rs.Status.Rule, nil + return rs.Status.Rules, nil } else if !apierrors.IsNotFound(err) { return nil, err } diff --git a/pkg/tenant/rules.go b/pkg/tenant/rules.go index a333c443e..1bf64f9b7 100644 --- a/pkg/tenant/rules.go +++ b/pkg/tenant/rules.go @@ -13,8 +13,8 @@ import ( "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/rules" "github.com/projectcapsule/capsule/pkg/runtime/selectors" ) @@ -41,21 +41,19 @@ func BuildNamespaceRuleBodyStatus( c client.Reader, ns *corev1.Namespace, tnt *capsulev1beta2.Tenant, -) (*api.NamespaceRuleBodyNamespace, error) { - out := &api.NamespaceRuleBodyNamespace{} - +) ([]*rules.NamespaceRuleBodyNamespace, error) { if tnt == nil || ns == nil { - return out, nil + return nil, nil } // Treat nil labels map as empty. - var nsLabels labels.Set + nsLabels := labels.Set{} if ns.Labels != nil { nsLabels = labels.Set(ns.Labels) - } else { - nsLabels = labels.Set{} } + out := make([]*rules.NamespaceRuleBodyNamespace, 0, len(tnt.Spec.Rules)) + for i, rule := range tnt.Spec.Rules { if rule == nil { continue @@ -72,11 +70,25 @@ func BuildNamespaceRuleBodyStatus( } } - // Merge enforce body (for now: only registries) - // Preserve order: append in the order rules are declared. - if len(rule.Enforce.Registries) > 0 { - out.Enforce.Registries = append(out.Enforce.Registries, rule.Enforce.Registries...) + normalized := rules.NamespaceRuleBodyNamespace{ + Enforce: rules.NamespaceRuleEnforceBody{ + Action: rule.Enforce.Action, + Registries: append( + []rules.OCIRegistry(nil), + rule.Enforce.Registries..., + ), + }, + } + + if normalized.Enforce.Action == "" { + normalized.Enforce.Action = rules.ActionTypeDeny } + + if len(normalized.Enforce.Registries) == 0 { + continue + } + + out = append(out, &normalized) } return out, nil