diff --git a/central/policy/matcher/cluster.go b/central/policy/matcher/cluster.go index 623a74c2b7a5c..7196d41589c9b 100644 --- a/central/policy/matcher/cluster.go +++ b/central/policy/matcher/cluster.go @@ -48,7 +48,9 @@ func (m *clusterMatcher) anyExclusionMatches(exclusions []*storage.Exclusion) bo } func (m *clusterMatcher) exclusionMatches(exclusion *storage.Exclusion) bool { - if !appliesToDeployments(exclusion) { + // A kind exclusion does not take the policy off the cluster. Image-only exclusions have no + // deployment scope, and a nil scope would otherwise match every cluster. + if exclusion.GetDeployment() == nil { return false } diff --git a/central/policy/matcher/deployment.go b/central/policy/matcher/deployment.go index 2098b4090e8ce..86e035585a65f 100644 --- a/central/policy/matcher/deployment.go +++ b/central/policy/matcher/deployment.go @@ -59,6 +59,11 @@ func (m *deploymentMatcher) exclusionMatches(ctx context.Context, exclusion *sto if !appliesToDeployments(exclusion) { return false } + // Kind exclusions have no deployment scope. Falling through would treat the nil scope as + // matching every deployment. + if exclusion.GetExcludeByKind() != nil { + return kindExclusionMatches(exclusion, m.deployment.GetType()) + } // If excluded scope does not match the deployment then no need to check for deployment name if !m.scopeMatches(ctx, exclusion.GetDeployment().GetScope()) { diff --git a/central/policy/matcher/matcher.go b/central/policy/matcher/matcher.go index 4ecf17b5645f1..c94097cd287c7 100644 --- a/central/policy/matcher/matcher.go +++ b/central/policy/matcher/matcher.go @@ -4,6 +4,8 @@ import ( "context" "github.com/stackrox/rox/generated/storage" + "github.com/stackrox/rox/pkg/features" + "github.com/stackrox/rox/pkg/kubernetes" ) // Matcher provides functionality to evaluate whether or not policies are applicable to an entity. @@ -12,9 +14,31 @@ type Matcher interface { IsPolicyApplicable(ctx context.Context, policy *storage.Policy) bool } -// appliesToDeployments reports whether the exclusion has a deployment part. Image-only exclusions -// are applied to images, so they must not exclude deployments, namespaces, or clusters. Without this -// check, the nil deployment scope matches everything and the policy looks inapplicable everywhere. +// appliesToDeployments reports whether the exclusion can take a deployment out of policy scope. +// A deployment name/scope exclusion and a workload-kind exclusion both can. An image-only exclusion +// cannot: its nil deployment scope matches every deployment and would make the policy look +// inapplicable everywhere. func appliesToDeployments(exclusion *storage.Exclusion) bool { - return exclusion.GetDeployment() != nil + return exclusion.GetDeployment() != nil || exclusion.GetExcludeByKind() != nil +} + +// kindExclusionMatches reports whether a kind exclusion excludes this deployment. +// The feature flag gates it. A disabled flag leaves the policy applicable, matching detection. +func kindExclusionMatches(exclusion *storage.Exclusion, deploymentType string) bool { + if !features.PolicyWorkloadKindExclusion.Enabled() { + return false + } + for _, kind := range exclusion.GetExcludeByKind().GetKinds() { + switch kind { + case storage.Exclusion_CRON_JOB: + if deploymentType == kubernetes.CronJob { + return true + } + case storage.Exclusion_JOB: + if deploymentType == kubernetes.Job { + return true + } + } + } + return false } diff --git a/central/policy/matcher/matcher_test.go b/central/policy/matcher/matcher_test.go index 3eaa0b906233f..0642a5308378c 100644 --- a/central/policy/matcher/matcher_test.go +++ b/central/policy/matcher/matcher_test.go @@ -5,6 +5,9 @@ import ( "testing" "github.com/stackrox/rox/generated/storage" + "github.com/stackrox/rox/pkg/features" + "github.com/stackrox/rox/pkg/kubernetes" + "github.com/stackrox/rox/pkg/testutils" "github.com/stretchr/testify/assert" ) @@ -36,3 +39,41 @@ func TestImageOnlyExclusionDoesNotExcludeEntities(t *testing.T) { }) } } + +func TestKindExclusionExcludesMatchingDeploymentsOnly(t *testing.T) { + testutils.MustUpdateFeature(t, features.PolicyWorkloadKindExclusion, true) + ctx := context.Background() + namespace := &storage.NamespaceMetadata{Name: "shop", ClusterId: "cluster1"} + cluster := &storage.Cluster{Id: "cluster1"} + policy := &storage.Policy{Exclusions: []*storage.Exclusion{{ + Matcher: &storage.Exclusion_ExcludeByKind_{ + ExcludeByKind: &storage.Exclusion_ExcludeByKind{ + Kinds: []storage.Exclusion_WorkloadKind{storage.Exclusion_JOB, storage.Exclusion_CRON_JOB}, + }, + }, + }}} + + job := &storage.Deployment{Name: "batch", Type: kubernetes.Job, ClusterId: "cluster1", Namespace: "shop"} + cronJob := &storage.Deployment{Name: "nightly", Type: kubernetes.CronJob, ClusterId: "cluster1", Namespace: "shop"} + deploy := &storage.Deployment{Name: "web", Type: kubernetes.Deployment, ClusterId: "cluster1", Namespace: "shop"} + + assert.False(t, NewDeploymentMatcher(job, nil, nil).IsPolicyApplicable(ctx, policy)) + assert.False(t, NewDeploymentMatcher(cronJob, nil, nil).IsPolicyApplicable(ctx, policy)) + assert.True(t, NewDeploymentMatcher(deploy, nil, nil).IsPolicyApplicable(ctx, policy)) + assert.True(t, NewNamespaceMatcher(namespace).IsPolicyApplicable(ctx, policy)) + assert.True(t, NewClusterMatcher(cluster, []*storage.NamespaceMetadata{namespace}).IsPolicyApplicable(ctx, policy)) +} + +func TestKindExclusionFlagOffLeavesPolicyApplicable(t *testing.T) { + testutils.MustUpdateFeature(t, features.PolicyWorkloadKindExclusion, false) + job := &storage.Deployment{Name: "batch", Type: kubernetes.Job, ClusterId: "cluster1", Namespace: "shop"} + policy := &storage.Policy{Exclusions: []*storage.Exclusion{{ + Matcher: &storage.Exclusion_ExcludeByKind_{ + ExcludeByKind: &storage.Exclusion_ExcludeByKind{ + Kinds: []storage.Exclusion_WorkloadKind{storage.Exclusion_JOB}, + }, + }, + }}} + + assert.True(t, NewDeploymentMatcher(job, nil, nil).IsPolicyApplicable(context.Background(), policy)) +} diff --git a/central/policy/matcher/namespace.go b/central/policy/matcher/namespace.go index 4d4f182056dde..6bd6ee7e7f2aa 100644 --- a/central/policy/matcher/namespace.go +++ b/central/policy/matcher/namespace.go @@ -46,7 +46,13 @@ func (m *namespaceMatcher) anyExclusionMatches(exclusions []*storage.Exclusion) } func (m *namespaceMatcher) exclusionMatches(exclusion *storage.Exclusion) bool { - return appliesToDeployments(exclusion) && m.scopeMatches(exclusion.GetDeployment().GetScope()) + // A kind exclusion does not take the policy off the namespace: other workload kinds there + // are still in scope. Image-only exclusions have no deployment scope, and a nil scope would + // otherwise match every namespace. + if exclusion.GetDeployment() == nil { + return false + } + return m.scopeMatches(exclusion.GetDeployment().GetScope()) } func (m *namespaceMatcher) anyScopeMatches(scopes []*storage.Scope) bool { diff --git a/central/policy/service/validator.go b/central/policy/service/validator.go index f9ee90b0afdb4..38c93dec4bb9a 100644 --- a/central/policy/service/validator.go +++ b/central/policy/service/validator.go @@ -286,7 +286,7 @@ func (s *policyValidator) validateExclusions(policy *storage.Policy) error { } func (s *policyValidator) validateExclusion(policy *storage.Policy, exclusion *storage.Exclusion) error { - if exclusion.GetDeployment() == nil && exclusion.GetImage() == nil { + if exclusion.GetDeployment() == nil && exclusion.GetImage() == nil && exclusion.GetExcludeByKind() == nil { return errors.New("all excluded scopes must have some criteria to match on") } if exclusion.GetDeployment() != nil { @@ -297,6 +297,17 @@ func (s *policyValidator) validateExclusion(policy *storage.Policy, exclusion *s return err } } + if exclusion.GetExcludeByKind() != nil { + if !features.PolicyWorkloadKindExclusion.Enabled() { + return errors.New("exclude_by_kind requires feature flag ROX_POLICY_WORKLOAD_KIND_EXCLUSION to be enabled") + } + if !policies.AppliesAtDeployTime(policy) && !policies.AppliesAtRunTime(policy) { + return errors.New("excluding by workload kind is only valid during the DEPLOY and RUNTIME lifecycles") + } + if err := validateExcludeByKind(exclusion.GetExcludeByKind()); err != nil { + return err + } + } if exclusion.GetImage() != nil { if !policies.AppliesAtBuildTime(policy) { return errors.New("excluding an image is only valid during the BUILD lifecycle") @@ -308,6 +319,22 @@ func (s *policyValidator) validateExclusion(policy *storage.Policy, exclusion *s return nil } +func validateExcludeByKind(excludeByKind *storage.Exclusion_ExcludeByKind) error { + kinds := excludeByKind.GetKinds() + if len(kinds) == 0 { + return errors.New("exclude_by_kind must specify at least one workload kind") + } + for _, kind := range kinds { + if kind == storage.Exclusion_WORKLOAD_KIND_UNSPECIFIED { + return errors.New("exclude_by_kind cannot include WORKLOAD_KIND_UNSPECIFIED") + } + if kind != storage.Exclusion_CRON_JOB && kind != storage.Exclusion_JOB { + return errors.Errorf("exclude_by_kind contains unknown workload kind %s", kind) + } + } + return nil +} + func (s *policyValidator) validateDeploymentExclusion(exclusion *storage.Exclusion) error { deployment := exclusion.GetDeployment() if deployment.GetScope() == nil && deployment.GetName() == "" { diff --git a/central/policy/service/validator_test.go b/central/policy/service/validator_test.go index 0eef5184601f5..784601206355f 100644 --- a/central/policy/service/validator_test.go +++ b/central/policy/service/validator_test.go @@ -714,6 +714,79 @@ func (s *PolicyValidatorTestSuite) TestValidateExclusions() { s.NoError(s.validator.validateExclusions(policy)) } +func (s *PolicyValidatorTestSuite) TestValidateExcludeByKind() { + kindExclusion := func(kinds ...storage.Exclusion_WorkloadKind) *storage.Exclusion { + return &storage.Exclusion{ + Matcher: &storage.Exclusion_ExcludeByKind_{ + ExcludeByKind: &storage.Exclusion_ExcludeByKind{Kinds: kinds}, + }, + } + } + deployPolicy := func(exclusion *storage.Exclusion) *storage.Policy { + return &storage.Policy{ + LifecycleStages: []storage.LifecycleStage{storage.LifecycleStage_DEPLOY}, + Exclusions: []*storage.Exclusion{exclusion}, + } + } + + s.ErrorContains(s.validator.validateExclusions(deployPolicy(kindExclusion(storage.Exclusion_JOB))), "ROX_POLICY_WORKLOAD_KIND_EXCLUSION") + + testutils.MustUpdateFeature(s.T(), features.PolicyWorkloadKindExclusion, true) + + for name, tc := range map[string]struct { + policy *storage.Policy + errContains string + }{ + "JOB is valid": { + policy: deployPolicy(kindExclusion(storage.Exclusion_JOB)), + }, + "CRON_JOB is valid": { + policy: deployPolicy(kindExclusion(storage.Exclusion_CRON_JOB)), + }, + "JOB and CRON_JOB are valid": { + policy: deployPolicy(kindExclusion(storage.Exclusion_CRON_JOB, storage.Exclusion_JOB)), + }, + "empty kind list is rejected": { + policy: deployPolicy(kindExclusion()), + errContains: "at least one workload kind", + }, + "UNSPECIFIED is rejected": { + policy: deployPolicy(kindExclusion(storage.Exclusion_WORKLOAD_KIND_UNSPECIFIED)), + errContains: "WORKLOAD_KIND_UNSPECIFIED", + }, + "unknown kind is rejected": { + policy: deployPolicy(kindExclusion(storage.Exclusion_WorkloadKind(99))), + errContains: "unknown workload kind", + }, + "mixed valid and unknown kinds are rejected": { + policy: deployPolicy(kindExclusion(storage.Exclusion_JOB, storage.Exclusion_WorkloadKind(99))), + errContains: "unknown workload kind", + }, + "RUNTIME lifecycle is valid": { + policy: &storage.Policy{ + LifecycleStages: []storage.LifecycleStage{storage.LifecycleStage_RUNTIME}, + Exclusions: []*storage.Exclusion{kindExclusion(storage.Exclusion_JOB)}, + }, + }, + "BUILD lifecycle is rejected": { + policy: &storage.Policy{ + LifecycleStages: []storage.LifecycleStage{storage.LifecycleStage_BUILD}, + Exclusions: []*storage.Exclusion{kindExclusion(storage.Exclusion_JOB)}, + }, + errContains: "DEPLOY and RUNTIME", + }, + } { + s.T().Run(name, func(t *testing.T) { + err := s.validator.validateExclusions(tc.policy) + if tc.errContains == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.errContains) + } + }) + } +} + func (s *PolicyValidatorTestSuite) TestValidateExclusionRejectsLabels() { for name, tc := range map[string]struct { exclusion *storage.Exclusion diff --git a/config-controller/api/v1alpha1/policy_types.go b/config-controller/api/v1alpha1/policy_types.go index a85baed44e69e..6c777f83d3e10 100644 --- a/config-controller/api/v1alpha1/policy_types.go +++ b/config-controller/api/v1alpha1/policy_types.go @@ -91,7 +91,8 @@ type SecurityPolicySpec struct { } type Exclusion struct { - Name string `json:"name,omitempty"` + Name string `json:"name,omitempty"` + // +optional Deployment Deployment `json:"deployment,omitempty"` Image Image `json:"image,omitempty"` // +optional diff --git a/pkg/detection/compiled_policy.go b/pkg/detection/compiled_policy.go index 5f64900b4f543..351f03c45caf6 100644 --- a/pkg/detection/compiled_policy.go +++ b/pkg/detection/compiled_policy.go @@ -9,9 +9,12 @@ import ( "github.com/stackrox/rox/pkg/booleanpolicy/augmentedobjs" "github.com/stackrox/rox/pkg/booleanpolicy/filter" "github.com/stackrox/rox/pkg/booleanpolicy/policyfields" + "github.com/stackrox/rox/pkg/features" + "github.com/stackrox/rox/pkg/kubernetes" "github.com/stackrox/rox/pkg/policies" "github.com/stackrox/rox/pkg/regexutils" "github.com/stackrox/rox/pkg/scopecomp" + "github.com/stackrox/rox/pkg/set" ) // CompiledPolicy is a compiled policy, which means it can match a policy, as well as check whether a policy is applicable. @@ -46,11 +49,14 @@ func newCompiledPolicy(policy *storage.Policy, clusterLabelProvider scopecomp.Cl exclusions := make([]*compiledExclusion, 0, len(policy.GetExclusions())) for _, w := range policy.GetExclusions() { - w, err := newCompiledExclusion(w) + if w.GetExcludeByKind() != nil && !features.PolicyWorkloadKindExclusion.Enabled() { + continue + } + cx, err := newCompiledExclusion(w) if err != nil { return nil, errors.Wrapf(err, "compiling exclusion list %+v for policy %s", w, policy.GetName()) } - exclusions = append(exclusions, w) + exclusions = append(exclusions, cx) } scopes := make([]*scopecomp.CompiledScope, 0, len(policy.GetScope())) @@ -477,6 +483,7 @@ type compiledExclusion struct { exclusion *storage.Exclusion deploymentNameMatcher regexutils.StringMatcher cs *scopecomp.CompiledScope + excludedKinds set.Set[string] } type alwaysFalseMatcher struct{} @@ -489,6 +496,18 @@ func newCompiledExclusion(exclusion *storage.Exclusion) (*compiledExclusion, err cx := &compiledExclusion{ exclusion: exclusion, } + if exclusion.GetExcludeByKind() != nil { + if features.PolicyWorkloadKindExclusion.Enabled() { + excludedKinds, err := kubernetesKindsForExcludeByKind(exclusion.GetExcludeByKind()) + if err != nil { + return nil, err + } + cx.excludedKinds = excludedKinds + } else { + cx.excludedKinds = set.NewSet[string]() + } + return cx, nil + } if name := exclusion.GetDeployment().GetName(); name != "" { deploymentNameMatcher, err := regexutils.CompileWholeStringMatcher(name, regexutils.Flags{CaseInsensitive: true}) if err != nil { @@ -514,11 +533,12 @@ func newCompiledExclusion(exclusion *storage.Exclusion) (*compiledExclusion, err return cx, nil } -// appliesToDeployments reports whether the exclusion has a deployment part. Image-only exclusions are -// applied to images, so they must not match deployments or audit events. Without this check, the nil -// deployment scope matches everything and the exclusion turns the policy off for every deployment. +// appliesToDeployments reports whether the exclusion can match a deployment. +// Image-only exclusions are applied to images, so they must not match deployments or audit events. +// Without this check, the nil deployment scope matches everything and the exclusion turns the policy off for every deployment. +// Kind exclusions have no deployment matcher, but they still apply to deployments of the selected kinds. func (cw *compiledExclusion) appliesToDeployments() bool { - return cw.exclusion.GetDeployment() != nil + return cw.exclusion.GetDeployment() != nil || cw.excludedKinds != nil } func (cw *compiledExclusion) MatchesDeployment(ctx context.Context, deployment *storage.Deployment) bool { @@ -526,6 +546,10 @@ func (cw *compiledExclusion) MatchesDeployment(ctx context.Context, deployment * return false } + if cw.excludedKinds != nil { + return cw.excludedKinds.Contains(deployment.GetType()) + } + if cw.deploymentNameMatcher != nil && !cw.deploymentNameMatcher.MatchString(deployment.GetName()) { return false } @@ -537,12 +561,35 @@ func (cw *compiledExclusion) MatchesAuditEvent(ctx context.Context, auditEvent * if !cw.appliesToDeployments() || exclusionIsExpired(cw.exclusion) { return false } + // Kind exclusions apply to workload detections, not audit-log events. + if cw.excludedKinds != nil { + return false + } if !cw.cs.MatchesAuditEvent(ctx, auditEvent) { return false } return true } +func kubernetesKindsForExcludeByKind(excludeByKind *storage.Exclusion_ExcludeByKind) (set.Set[string], error) { + kinds := excludeByKind.GetKinds() + if len(kinds) == 0 { + return nil, errors.New("exclude_by_kind must specify at least one workload kind") + } + kubernetesKinds := set.NewSet[string]() + for _, kind := range kinds { + switch kind { + case storage.Exclusion_CRON_JOB: + kubernetesKinds.Add(kubernetes.CronJob) + case storage.Exclusion_JOB: + kubernetesKinds.Add(kubernetes.Job) + default: + return nil, errors.Errorf("exclude_by_kind contains unknown workload kind %s", kind) + } + } + return kubernetesKinds, nil +} + // Predicate for deployments. type deploymentPredicate struct { exclusions []*compiledExclusion diff --git a/pkg/detection/compiled_policy_test.go b/pkg/detection/compiled_policy_test.go index 0b0a544552597..4ea21b0d5e949 100644 --- a/pkg/detection/compiled_policy_test.go +++ b/pkg/detection/compiled_policy_test.go @@ -11,6 +11,8 @@ import ( "github.com/stackrox/rox/pkg/booleanpolicy/policyversion" "github.com/stackrox/rox/pkg/features" "github.com/stackrox/rox/pkg/fixtures" + "github.com/stackrox/rox/pkg/kubernetes" + "github.com/stackrox/rox/pkg/testutils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -106,6 +108,60 @@ func TestCompiledPolicyScopesAndExclusions(t *testing.T) { } } +func TestCompiledPolicyWorkloadKindExclusion(t *testing.T) { + testutils.MustUpdateFeature(t, features.PolicyWorkloadKindExclusion, true) + + cronJob := newDeployment("CRONJOB") + cronJob.Name = "batch-cron" + cronJob.Type = kubernetes.CronJob + regular := newDeployment("DEPLOYMENT") + regular.Name = "web" + regular.Type = kubernetes.Deployment + + policy := constructPolicy(nil, []*storage.Exclusion{ + { + Matcher: &storage.Exclusion_ExcludeByKind_{ + ExcludeByKind: &storage.Exclusion_ExcludeByKind{ + Kinds: []storage.Exclusion_WorkloadKind{storage.Exclusion_CRON_JOB}, + }, + }, + }, + { + Matcher: &storage.Exclusion_Deployment_{ + Deployment: &storage.Exclusion_Deployment{Name: regular.GetName()}, + }, + }, + }) + compiled, err := CompilePolicy(policy, nil, nil) + require.NoError(t, err) + assert.False(t, compiled.AppliesTo(context.Background(), cronJob)) + assert.False(t, compiled.AppliesTo(context.Background(), regular)) + + other := newDeployment("OTHER") + other.Name = "other" + other.Type = kubernetes.DaemonSet + assert.True(t, compiled.AppliesTo(context.Background(), other)) +} + +func TestCompiledPolicyWorkloadKindExclusionFlagOff(t *testing.T) { + testutils.MustUpdateFeature(t, features.PolicyWorkloadKindExclusion, false) + + cronJob := newDeployment("CRONJOB") + cronJob.Type = kubernetes.CronJob + policy := constructPolicy(nil, []*storage.Exclusion{ + { + Matcher: &storage.Exclusion_ExcludeByKind_{ + ExcludeByKind: &storage.Exclusion_ExcludeByKind{ + Kinds: []storage.Exclusion_WorkloadKind{storage.Exclusion_CRON_JOB}, + }, + }, + }, + }) + compiled, err := CompilePolicy(policy, nil, nil) + require.NoError(t, err) + assert.True(t, compiled.AppliesTo(context.Background(), cronJob)) +} + // TestProcessAndFileAccessMatchers verifies that when a policy contains both Process and FileAccess fields, // only the file access matcher is created, not both matchers. func TestProcessAndFileAccessMatchers(t *testing.T) { diff --git a/pkg/detection/exclusion_test.go b/pkg/detection/exclusion_test.go index e60937e0ec8cd..c96474e6189bc 100644 --- a/pkg/detection/exclusion_test.go +++ b/pkg/detection/exclusion_test.go @@ -6,8 +6,11 @@ import ( "time" "github.com/stackrox/rox/generated/storage" + "github.com/stackrox/rox/pkg/features" "github.com/stackrox/rox/pkg/fixtures" + "github.com/stackrox/rox/pkg/kubernetes" "github.com/stackrox/rox/pkg/protoconv" + "github.com/stackrox/rox/pkg/testutils" "github.com/stackrox/rox/pkg/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -160,6 +163,126 @@ func TestMatchesDeploymentExclusion(t *testing.T) { } } +func TestMatchesWorkloadKindExclusion(t *testing.T) { + cronJob := fixtures.GetDeployment() + cronJob.Type = kubernetes.CronJob + job := fixtures.GetDeployment() + job.Type = kubernetes.Job + deployment := fixtures.GetDeployment() + deployment.Type = kubernetes.Deployment + + kindExclusion := func(kinds ...storage.Exclusion_WorkloadKind) *storage.Exclusion { + return &storage.Exclusion{ + Matcher: &storage.Exclusion_ExcludeByKind_{ + ExcludeByKind: &storage.Exclusion_ExcludeByKind{Kinds: kinds}, + }, + } + } + nameExclusion := func(name string) *storage.Exclusion { + return &storage.Exclusion{ + Matcher: &storage.Exclusion_Deployment_{ + Deployment: &storage.Exclusion_Deployment{Name: name}, + }, + } + } + + compile := func(t *testing.T, exclusions ...*storage.Exclusion) []*compiledExclusion { + t.Helper() + compiled := make([]*compiledExclusion, 0, len(exclusions)) + for _, exclusion := range exclusions { + cw, err := newCompiledExclusion(exclusion) + require.NoError(t, err) + compiled = append(compiled, cw) + } + return compiled + } + + t.Run("flag off does not exclude by kind", func(t *testing.T) { + testutils.MustUpdateFeature(t, features.PolicyWorkloadKindExclusion, false) + compiled := compile(t, kindExclusion(storage.Exclusion_CRON_JOB, storage.Exclusion_JOB)) + assert.False(t, deploymentMatchesExclusions(context.Background(), cronJob, compiled)) + assert.False(t, deploymentMatchesExclusions(context.Background(), job, compiled)) + assert.False(t, deploymentMatchesExclusions(context.Background(), deployment, compiled)) + }) + + testutils.MustUpdateFeature(t, features.PolicyWorkloadKindExclusion, true) + + cases := map[string]struct { + exclusions []*storage.Exclusion + workload *storage.Deployment + shouldMatch bool + }{ + "cronjob matches cronjob kind": { + exclusions: []*storage.Exclusion{kindExclusion(storage.Exclusion_CRON_JOB)}, + workload: cronJob, + shouldMatch: true, + }, + "deployment does not match cronjob kind": { + exclusions: []*storage.Exclusion{kindExclusion(storage.Exclusion_CRON_JOB)}, + workload: deployment, + shouldMatch: false, + }, + "job matches job and cronjob kinds": { + exclusions: []*storage.Exclusion{kindExclusion(storage.Exclusion_CRON_JOB, storage.Exclusion_JOB)}, + workload: job, + shouldMatch: true, + }, + "OR with name/scope exclusion": { + exclusions: []*storage.Exclusion{ + kindExclusion(storage.Exclusion_JOB), + nameExclusion(deployment.GetName()), + }, + workload: deployment, + shouldMatch: true, + }, + "kind exclusion does not match unrelated name": { + exclusions: []*storage.Exclusion{kindExclusion(storage.Exclusion_JOB)}, + workload: deployment, + shouldMatch: false, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + compiled := compile(t, c.exclusions...) + got := deploymentMatchesExclusions(context.Background(), c.workload, compiled) + assert.Equal(t, c.shouldMatch, got) + if c.shouldMatch { + for _, exclusion := range c.exclusions { + exclusion.Expiration = protoconv.MustConvertTimeToTimestamp(time.Now().Add(-1 * time.Hour)) + } + assert.False(t, deploymentMatchesExclusions(context.Background(), c.workload, compiled)) + } + }) + } + + for name, tc := range map[string]struct { + kinds []storage.Exclusion_WorkloadKind + errContains string + }{ + "empty kind list is rejected": { + errContains: "at least one workload kind", + }, + "unknown kind is rejected": { + kinds: []storage.Exclusion_WorkloadKind{storage.Exclusion_WorkloadKind(99)}, + errContains: "unknown workload kind", + }, + "mixed valid and unknown kinds are rejected": { + kinds: []storage.Exclusion_WorkloadKind{storage.Exclusion_JOB, storage.Exclusion_WorkloadKind(99)}, + errContains: "unknown workload kind", + }, + "UNSPECIFIED is rejected": { + kinds: []storage.Exclusion_WorkloadKind{storage.Exclusion_WORKLOAD_KIND_UNSPECIFIED}, + errContains: "unknown workload kind", + }, + } { + t.Run(name, func(t *testing.T) { + _, err := newCompiledExclusion(kindExclusion(tc.kinds...)) + require.ErrorContains(t, err, tc.errContains) + }) + } +} + func TestMatchesImageExclusion(t *testing.T) { cases := []struct { name string