From 29e618e608e7f02b67191d8fe2f5f5f4b803b0bc Mon Sep 17 00:00:00 2001 From: Khushboo Sancheti Date: Sat, 17 Oct 2026 10:23:46 -0700 Subject: [PATCH] ROX-37157: add workload kind policy exclusion in wizard Co-authored-by: Cursor --- .../policies/policyWizard.helpers.ts | 39 +++++- .../policyWorkloadKindExclusion.test.ts | 120 +++++++++++++++++ .../Detail/ExclusionDeploymentDetails.tsx | 4 +- .../Detail/ExclusionWorkloadKindDetails.tsx | 21 +++ .../Policies/Detail/InclusionScopeDetails.tsx | 2 +- .../Policies/Detail/PolicyScopeSection.tsx | 25 +++- .../Wizard/Step2/PolicyBehaviorForm.tsx | 2 + .../Wizard/Step4/ExclusionScopeCard.tsx | 12 +- .../Wizard/Step4/InclusionScopeCard.tsx | 6 +- .../Wizard/Step4/PolicyScopeCardLegacy.tsx | 8 +- .../Policies/Wizard/Step4/PolicyScopeForm.tsx | 123 ++++++++++++++++-- .../Wizard/policyValidationSchemas.test.ts | 32 +++++ .../Wizard/policyValidationSchemas.ts | 10 ++ .../Policies/policies.utils.test.ts | 74 +++++++++++ .../src/Containers/Policies/policies.utils.ts | 75 ++++++++++- ui/apps/platform/src/types/featureFlag.ts | 1 + ui/apps/platform/src/types/policy.proto.ts | 30 ++++- 17 files changed, 549 insertions(+), 35 deletions(-) create mode 100644 ui/apps/platform/cypress/integration/policies/policyWorkloadKindExclusion.test.ts create mode 100644 ui/apps/platform/src/Containers/Policies/Detail/ExclusionWorkloadKindDetails.tsx diff --git a/ui/apps/platform/cypress/integration/policies/policyWizard.helpers.ts b/ui/apps/platform/cypress/integration/policies/policyWizard.helpers.ts index cfee56b961dd4..feb3417e97efc 100644 --- a/ui/apps/platform/cypress/integration/policies/policyWizard.helpers.ts +++ b/ui/apps/platform/cypress/integration/policies/policyWizard.helpers.ts @@ -1,5 +1,6 @@ -import { selectors } from './Policies.selectors'; import DndSimulatorDataTransfer from '../../helpers/dndSimulatorDataTransfer'; +import { hasFeatureFlag } from '../../helpers/features'; +import { selectors } from './Policies.selectors'; // --- Form Interaction --- @@ -61,7 +62,41 @@ export function addInclusionWithNamespace(namespace: string) { export function addExclusionWithDeployment(deploymentName: string) { cy.contains('button', 'Add exclusion').click(); - cy.get('[aria-label="Deployment name"]').type(deploymentName); + cy.get('[aria-label="Workload name"]').type(deploymentName); +} + +function workloadKindCheckboxId(label: 'CronJobs' | 'Jobs') { + return label === 'CronJobs' ? 'exclude-workload-kind-CRON_JOB' : 'exclude-workload-kind-JOB'; +} + +export function toggleWorkloadKindExclusion(label: 'CronJobs' | 'Jobs') { + cy.contains('h4', 'By kind'); + // "Jobs" is a substring of "CronJobs", so a label text match clicks the wrong checkbox. + cy.get(`label[for="${workloadKindCheckboxId(label)}"]`).click(); +} + +export function assertWorkloadKindExclusion(label: 'CronJobs' | 'Jobs', isChecked: boolean) { + cy.get(`#${workloadKindCheckboxId(label)}`).should(isChecked ? 'be.checked' : 'not.be.checked'); +} + +export function goToWizardStep(stepName: string) { + // Nested steps such as Resources stay hidden until their expandable parent is open. + cy.contains('button.pf-v6-c-wizard__nav-link', stepName).then(($step) => { + const collapsedParent = $step + .parents('.pf-v6-c-wizard__nav-item.pf-m-expandable:not(.pf-m-expanded)') + .filter((_, el) => !Cypress.$(el).children('.pf-v6-c-wizard__nav-link').is($step)); + if (collapsedParent.length > 0) { + cy.wrap(collapsedParent.last()).children('button.pf-v6-c-wizard__nav-link').click(); + } + }); + cy.contains('button.pf-v6-c-wizard__nav-link', stepName).click(); +} + +export function skipFiltersStepIfPresent() { + if (hasFeatureFlag('ROX_EVALUATION_FILTER') && hasFeatureFlag('ROX_INIT_CONTAINER_SUPPORT')) { + assertStepHeading('Filters'); + clickNext(); + } } // --- Step 5: Actions --- diff --git a/ui/apps/platform/cypress/integration/policies/policyWorkloadKindExclusion.test.ts b/ui/apps/platform/cypress/integration/policies/policyWorkloadKindExclusion.test.ts new file mode 100644 index 0000000000000..cc54a210b0681 --- /dev/null +++ b/ui/apps/platform/cypress/integration/policies/policyWorkloadKindExclusion.test.ts @@ -0,0 +1,120 @@ +import withAuth from '../../helpers/basicAuth'; +import { hasFeatureFlag } from '../../helpers/features'; +import { getInputByLabel } from '../../helpers/formHelpers'; +import { deletePolicyIfExists, doPolicyPageAction, visitPolicies } from './Policies.helpers'; +import { selectors } from './Policies.selectors'; +import { + addExclusionWithDeployment, + assertStepHeading, + assertWorkloadKindExclusion, + clickNext, + clickSave, + dragFieldIntoSection, + expandCriteriaCategory, + goToWizardStep, + selectCategory, + skipFiltersStepIfPresent, + startPolicyWizard, + toggleWorkloadKindExclusion, + verifyPolicyDetails, + verifyPolicyInTable, +} from './policyWizard.helpers'; + +const POLICY_NAME = 'CYPRESS_TEST_WORKLOAD_KIND_EXCLUSION'; +const EXCLUDED_WORKLOAD_NAME = 'system-admin'; + +describe('Policy workload kind exclusion', () => { + withAuth(); + + before(function () { + if (!hasFeatureFlag('ROX_POLICY_WORKLOAD_KIND_EXCLUSION')) { + this.skip(); + } + }); + + beforeEach(() => { + deletePolicyIfExists(POLICY_NAME); + }); + + afterEach(() => { + deletePolicyIfExists(POLICY_NAME); + }); + + it('should exclude CronJobs, persist on reopen, and combine with a workload name exclusion', () => { + visitPolicies(); + startPolicyWizard(); + + getInputByLabel('Name').type(POLICY_NAME); + cy.contains('label', 'High').click(); + selectCategory('Privileges'); + getInputByLabel('Description').type( + 'Exclude CronJobs and a named workload from this policy' + ); + clickNext(); + + assertStepHeading('Lifecycle'); + cy.contains('label', 'Deploy').click(); + clickNext(); + + assertStepHeading('Rules'); + expandCriteriaCategory('Container configuration'); + dragFieldIntoSection(`${selectors.step3.policyCriteria.key}:contains("Privileged")`); + cy.get(selectors.step3.policyCriteria.groupCards).should('have.length.gte', 1); + clickNext(); + + assertStepHeading('Resources'); + cy.contains( + 'Define one or more clusters, namespaces or workloads (if applicable) to be excluded from this policy.' + ); + toggleWorkloadKindExclusion('CronJobs'); + assertWorkloadKindExclusion('CronJobs', true); + assertWorkloadKindExclusion('Jobs', false); + cy.contains('Policy will exclude CronJobs.'); + addExclusionWithDeployment(EXCLUDED_WORKLOAD_NAME); + clickNext(); + + skipFiltersStepIfPresent(); + + assertStepHeading('Actions'); + clickNext(); + + assertStepHeading('Review policy'); + cy.contains('dt', 'Workload kind').next('dd').should('contain', 'CronJobs'); + cy.contains(EXCLUDED_WORKLOAD_NAME); + clickSave(); + + verifyPolicyInTable(POLICY_NAME); + verifyPolicyDetails(POLICY_NAME, { + severity: 'High', + lifecycle: 'Deploy', + categories: 'Privileges', + description: 'Exclude CronJobs and a named workload from this policy', + response: 'Inform', + scope: ['Workload kind', 'CronJobs', EXCLUDED_WORKLOAD_NAME], + }); + + doPolicyPageAction('Edit policy'); + goToWizardStep('Resources'); + assertWorkloadKindExclusion('CronJobs', true); + assertWorkloadKindExclusion('Jobs', false); + cy.get('[aria-label="Workload name"]').should('have.value', EXCLUDED_WORKLOAD_NAME); + + toggleWorkloadKindExclusion('Jobs'); + assertWorkloadKindExclusion('Jobs', true); + cy.contains('Policy will exclude CronJobs and Jobs.'); + + goToWizardStep('Review'); + cy.contains('dt', 'Workload kind').next('dd').should('have.text', 'CronJobs, Jobs'); + clickSave(); + + visitPolicies(); + verifyPolicyDetails(POLICY_NAME, { + severity: 'High', + lifecycle: 'Deploy', + categories: 'Privileges', + description: 'Exclude CronJobs and a named workload from this policy', + response: 'Inform', + scope: ['Workload kind', 'CronJobs, Jobs', EXCLUDED_WORKLOAD_NAME], + }); + }); +}); diff --git a/ui/apps/platform/src/Containers/Policies/Detail/ExclusionDeploymentDetails.tsx b/ui/apps/platform/src/Containers/Policies/Detail/ExclusionDeploymentDetails.tsx index d1cccaa0a6cb0..71a63de96a106 100644 --- a/ui/apps/platform/src/Containers/Policies/Detail/ExclusionDeploymentDetails.tsx +++ b/ui/apps/platform/src/Containers/Policies/Detail/ExclusionDeploymentDetails.tsx @@ -43,9 +43,9 @@ function ExclusionDeploymentDetails({ desc={`${namespaceLabel.key}=${namespaceLabel.value}`} /> )} - {deploymentName && } + {deploymentName && } {label && ( - + )} ); diff --git a/ui/apps/platform/src/Containers/Policies/Detail/ExclusionWorkloadKindDetails.tsx b/ui/apps/platform/src/Containers/Policies/Detail/ExclusionWorkloadKindDetails.tsx new file mode 100644 index 0000000000000..c62d317d9eac0 --- /dev/null +++ b/ui/apps/platform/src/Containers/Policies/Detail/ExclusionWorkloadKindDetails.tsx @@ -0,0 +1,21 @@ +import type { ReactElement } from 'react'; +import { DescriptionList } from '@patternfly/react-core'; + +import DescriptionListItem from 'Components/DescriptionListItem'; +import type { PolicyWorkloadKind } from 'types/policy.proto'; + +import { formatWorkloadKindList } from '../policies.utils'; + +type ExclusionWorkloadKindDetailsProps = { + kinds: PolicyWorkloadKind[]; +}; + +function ExclusionWorkloadKindDetails({ kinds }: ExclusionWorkloadKindDetailsProps): ReactElement { + return ( + + + + ); +} + +export default ExclusionWorkloadKindDetails; diff --git a/ui/apps/platform/src/Containers/Policies/Detail/InclusionScopeDetails.tsx b/ui/apps/platform/src/Containers/Policies/Detail/InclusionScopeDetails.tsx index 00e31440fd32f..04ba968b147c5 100644 --- a/ui/apps/platform/src/Containers/Policies/Detail/InclusionScopeDetails.tsx +++ b/ui/apps/platform/src/Containers/Policies/Detail/InclusionScopeDetails.tsx @@ -34,7 +34,7 @@ function InclusionScopeDetails({ clusters, scope }: InclusionScopeDetailsProps): /> )} {label && ( - + )} ); diff --git a/ui/apps/platform/src/Containers/Policies/Detail/PolicyScopeSection.tsx b/ui/apps/platform/src/Containers/Policies/Detail/PolicyScopeSection.tsx index a25099dea38fd..d404fb8e93cbd 100644 --- a/ui/apps/platform/src/Containers/Policies/Detail/PolicyScopeSection.tsx +++ b/ui/apps/platform/src/Containers/Policies/Detail/PolicyScopeSection.tsx @@ -14,7 +14,12 @@ import useFetchClustersForPermissions from 'hooks/useFetchClustersForPermissions import type { PolicyExclusion, PolicyScope } from 'types/policy.proto'; import InclusionScopeDetails from './InclusionScopeDetails'; import ExclusionDeploymentDetails from './ExclusionDeploymentDetails'; -import { getExcludedDeployments, getExcludedImageNames } from '../policies.utils'; +import ExclusionWorkloadKindDetails from './ExclusionWorkloadKindDetails'; +import { + getExcludedDeployments, + getExcludedImageNames, + getExcludedWorkloadKinds, +} from '../policies.utils'; type PolicyScopeSectionProps = { scope: PolicyScope[]; @@ -24,12 +29,15 @@ type PolicyScopeSectionProps = { function PolicyScopeSection({ scope, exclusions }: PolicyScopeSectionProps): ReactElement { const { clusters } = useFetchClustersForPermissions(['Deployment']); const excludedDeployments = getExcludedDeployments(exclusions); + const excludedWorkloadKinds = getExcludedWorkloadKinds(exclusions); const imageExclusionNames = getExcludedImageNames(exclusions); const hasIncludedScope = scope?.length > 0; const hasExcludedDeployments = excludedDeployments.length > 0; + const hasExcludedWorkloadKinds = excludedWorkloadKinds.length > 0; const hasImageExclusions = imageExclusionNames.length > 0; - const hasAnyResources = hasIncludedScope || hasExcludedDeployments || hasImageExclusions; + const hasExcludedResources = hasExcludedDeployments || hasExcludedWorkloadKinds; + const hasAnyResources = hasIncludedScope || hasExcludedResources || hasImageExclusions; return ( <> @@ -54,10 +62,21 @@ function PolicyScopeSection({ scope, exclusions }: PolicyScopeSectionProps): Rea )} - {hasExcludedDeployments && ( + {hasExcludedResources && ( <> Excluded resources + {hasExcludedWorkloadKinds && ( + + + + + + + + )} {excludedDeployments.map((excludedDeployment, index) => ( // eslint-disable-next-line react/no-array-index-key diff --git a/ui/apps/platform/src/Containers/Policies/Wizard/Step2/PolicyBehaviorForm.tsx b/ui/apps/platform/src/Containers/Policies/Wizard/Step2/PolicyBehaviorForm.tsx index 0e979dff3dfa3..14d5bcd310314 100644 --- a/ui/apps/platform/src/Containers/Policies/Wizard/Step2/PolicyBehaviorForm.tsx +++ b/ui/apps/platform/src/Containers/Policies/Wizard/Step2/PolicyBehaviorForm.tsx @@ -97,6 +97,7 @@ function PolicyBehaviorForm({ hasActiveViolations }: PolicyBehaviorFormProps) { // Do not validate the following, because changed values are on other steps. setFieldValue('excludedImageNames', [], false); + setFieldValue('excludedWorkloadKinds', [], false); values.scope.forEach(({ label, ...rest }, idx) => { if (label) { setFieldValue(`scope[${idx}]`, { ...rest }, false); @@ -130,6 +131,7 @@ function PolicyBehaviorForm({ hasActiveViolations }: PolicyBehaviorFormProps) { scope: [], excludedImageNames: [], excludedDeploymentScopes: [], + excludedWorkloadKinds: [], enforcementActions: [], }); } diff --git a/ui/apps/platform/src/Containers/Policies/Wizard/Step4/ExclusionScopeCard.tsx b/ui/apps/platform/src/Containers/Policies/Wizard/Step4/ExclusionScopeCard.tsx index 8ebfd6f66255c..7fad607fe8aa0 100644 --- a/ui/apps/platform/src/Containers/Policies/Wizard/Step4/ExclusionScopeCard.tsx +++ b/ui/apps/platform/src/Containers/Policies/Wizard/Step4/ExclusionScopeCard.tsx @@ -59,20 +59,20 @@ function ExclusionScopeCard({ value={excludedDeploymentScope.scope?.namespace ?? ''} /> - + - + - + {type === 'exclusion' && ( - + @@ -173,7 +173,7 @@ function PolicyScopeCardLegacy({ )} - + (); const { scope, excludedDeploymentScopes, excludedImageNames } = values; + const excludedWorkloadKinds = values.excludedWorkloadKinds ?? []; + + const showWorkloadKindExclusion = isFeatureFlagEnabled('ROX_POLICY_WORKLOAD_KIND_EXCLUSION'); const hasAuditLogEventSource = values.eventSource === 'AUDIT_LOG_EVENT'; const hasNodeEventSource = values.eventSource === 'NODE_EVENT'; @@ -86,6 +97,21 @@ function PolicyScopeForm(): ReactElement { const hasResults = filteredImages?.length > 0 || shouldShowCreateOption; const isAllScopingDisabled = hasNodeEventSource; + const isWorkloadKindExclusionDisabled = + isAllScopingDisabled || hasAuditLogEventSource || !hasDeployOrRuntimeLifecycle; + + function getWorkloadKindExclusionInfo(): string { + if (hasNodeEventSource) { + return 'The selected event source does not support resource targeting.'; + } + if (hasAuditLogEventSource) { + return 'Workload kind exclusions are not available for audit log event sources.'; + } + if (!hasDeployOrRuntimeLifecycle) { + return 'Workload kind exclusions require the Deploy or Runtime lifecycle stage.'; + } + return formatWorkloadKindExclusionMessage(excludedWorkloadKinds); + } function addNewInclusionScope() { setFieldValue('scope', [...scope, initialScope]); @@ -125,6 +151,10 @@ function PolicyScopeForm(): ReactElement { setFilterValue(''); } + function handleChangeWorkloadKind(kind: PolicyWorkloadKind) { + setFieldValue('excludedWorkloadKinds', toggleItemInArray(excludedWorkloadKinds, kind)); + } + useEffect(() => { getImages() .then((response) => { @@ -135,6 +165,12 @@ function PolicyScopeForm(): ReactElement { }); }, []); + useEffect(() => { + if (isWorkloadKindExclusionDisabled && excludedWorkloadKinds.length > 0) { + setFieldValue('excludedWorkloadKinds', []); + } + }, [isWorkloadKindExclusionDisabled, excludedWorkloadKinds.length, setFieldValue]); + // @TODO: Consider using a custom component for the multi-select typeahead dropdown. PolicyCategoriesSelectField.tsx is a good example too. return ( @@ -216,19 +252,50 @@ function PolicyScopeForm(): ReactElement { Define one or more clusters, namespaces or workloads (if applicable) to be excluded from this policy. - + {!showWorkloadKindExclusion && } - - - + + + )} + {showWorkloadKindExclusion && ( + + + + By scope +
+ Define one or more clusters, namespaces or workloads (if + applicable) to be excluded from this policy. +
+ +
+
+ + + +
+ )} {excludedDeploymentScopes?.map((_, index) => ( @@ -256,6 +323,40 @@ function PolicyScopeForm(): ReactElement { ))} + {showWorkloadKindExclusion && ( + <> + + + + By kind +
Choose workload kinds to be excluded from this policy.
+ + + + {policyWorkloadKinds.map((kind) => ( + handleChangeWorkloadKind(kind)} + /> + ))} + + + + +
+
+ + )} diff --git a/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.test.ts b/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.test.ts index d44b191e9dd19..a4760bc055b83 100644 --- a/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.test.ts +++ b/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.test.ts @@ -443,6 +443,38 @@ describe('Step 4', () => { }).toThrow(); }); }); + + describe('excludedWorkloadKinds', () => { + it('passes if kinds are empty or known workload kinds', () => { + const empty: WizardPolicyStep4 = { + scope: [], + excludedDeploymentScopes: [], + excludedImageNames: [], + excludedWorkloadKinds: [], + }; + expect(validationSchemaStep4.validateSync(empty)).toEqual(empty); + + const value: WizardPolicyStep4 = { + scope: [], + excludedDeploymentScopes: [], + excludedImageNames: [], + excludedWorkloadKinds: ['CRON_JOB', 'JOB'], + }; + expect(validationSchemaStep4.validateSync(value)).toEqual(value); + }); + + it('throws if a kind is not a supported workload kind', () => { + const value = { + scope: [], + excludedDeploymentScopes: [], + excludedImageNames: [], + excludedWorkloadKinds: ['DAEMON_SET'], + }; + expect(() => { + validationSchemaStep4.validateSync(value); + }).toThrow(); + }); + }); }); describe('Step 5 (Actions) - enforcement validation', () => { diff --git a/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.ts b/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.ts index 9dfab4f919a25..8684ca6cfb619 100644 --- a/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.ts +++ b/ui/apps/platform/src/Containers/Policies/Wizard/policyValidationSchemas.ts @@ -1,5 +1,6 @@ import * as yup from 'yup'; +import { policyWorkloadKinds } from 'types/policy.proto'; import type { ClientPolicy } from 'types/policy.proto'; import { @@ -248,6 +249,15 @@ export const validationSchemaStep4: yup.ObjectSchema = yup.ob ) .required(), excludedImageNames: yup.array().of(yup.string().trim().required()).required(), + excludedWorkloadKinds: yup + .array() + .of( + yup + .string() + .oneOf([...policyWorkloadKinds]) + .required() + ) + .optional(), }); export const validationSchemaStep5 = yup.object().shape({ diff --git a/ui/apps/platform/src/Containers/Policies/policies.utils.test.ts b/ui/apps/platform/src/Containers/Policies/policies.utils.test.ts index b081f25c24a2f..6ca9261d5290e 100644 --- a/ui/apps/platform/src/Containers/Policies/policies.utils.test.ts +++ b/ui/apps/platform/src/Containers/Policies/policies.utils.test.ts @@ -1,11 +1,14 @@ import type { ClientPolicy, Policy } from 'types/policy.proto'; import { containerTypeFilterApplies, + formatWorkloadKindExclusionMessage, + formatWorkloadKindList, getClientWizardPolicy, getLifeCyclesUpdates, getPolicyOriginLabel, getServerPolicy, initialExcludedDeployment, + initialPolicy, initialScope, isExcludedDeploymentScopeEmpty, } from './policies.utils'; @@ -288,6 +291,7 @@ describe('policies.utils', () => { }, }, ], + excludedWorkloadKinds: [], serverPolicySections: [ { sectionName: 'Policy Section 1', @@ -622,6 +626,7 @@ describe('policies.utils', () => { }, }, ], + excludedWorkloadKinds: [], serverPolicySections: [ { sectionName: 'Policy Section 1', @@ -702,6 +707,7 @@ describe('policies.utils', () => { scope: initialExcludedDeployment.scope, }, ], + excludedWorkloadKinds: [], serverPolicySections: [], policySections: [], severity: 'LOW_SEVERITY' as const, @@ -752,6 +758,7 @@ describe('policies.utils', () => { }, }, ], + excludedWorkloadKinds: [], serverPolicySections: [], policySections: [], severity: 'LOW_SEVERITY' as const, @@ -772,6 +779,52 @@ describe('policies.utils', () => { expect(getServerPolicy(clientPolicy).exclusions?.[0]?.deployment?.scope).toBeNull(); }); + + test('serializes selected workload kinds as a single exclude-by-kind exclusion', () => { + const serverPolicy = getServerPolicy({ + ...initialPolicy, + excludedWorkloadKinds: ['JOB', 'CRON_JOB'], + }); + + expect(serverPolicy.exclusions).toEqual([ + { + deployment: null, + image: null, + excludeByKind: { kinds: ['CRON_JOB', 'JOB'] }, + }, + ]); + expect(getClientWizardPolicy(serverPolicy).excludedWorkloadKinds).toEqual([ + 'CRON_JOB', + 'JOB', + ]); + }); + + test('splits a combined image and kind exclusion into client fields', () => { + const serverPolicy = getServerPolicy(initialPolicy); + serverPolicy.exclusions = [ + { + deployment: null, + image: { name: 'nginx' }, + excludeByKind: { kinds: ['JOB'] }, + }, + ]; + + const clientPolicy = getClientWizardPolicy(serverPolicy); + + expect(clientPolicy.excludedImageNames).toEqual(['nginx']); + expect(clientPolicy.excludedWorkloadKinds).toEqual(['JOB']); + expect(getServerPolicy(clientPolicy).exclusions).toEqual([ + { + deployment: null, + image: null, + excludeByKind: { kinds: ['JOB'] }, + }, + { + image: { name: 'nginx' }, + deployment: null, + }, + ]); + }); }); describe('isExcludedDeploymentScopeEmpty', () => { @@ -837,6 +890,7 @@ describe('policies.utils', () => { eventSource: 'NOT_APPLICABLE', enforcementActions: ['FAIL_BUILD_ENFORCEMENT'], excludedImageNames: ['docker.io/library/archlinux:latest'], + excludedWorkloadKinds: ['CRON_JOB'], }, ['BUILD', 'DEPLOY'] ) @@ -845,6 +899,7 @@ describe('policies.utils', () => { eventSource: 'NOT_APPLICABLE', enforcementActions: ['FAIL_BUILD_ENFORCEMENT'], excludedImageNames: ['docker.io/library/archlinux:latest'], + excludedWorkloadKinds: ['CRON_JOB'], }); }); @@ -855,6 +910,7 @@ describe('policies.utils', () => { lifecycleStages: ['BUILD', 'DEPLOY'], eventSource: 'NOT_APPLICABLE', excludedImageNames: ['docker.io/library/archlinux:latest'], + excludedWorkloadKinds: ['JOB'], enforcementActions: ['FAIL_BUILD_ENFORCEMENT', 'SCALE_TO_ZERO_ENFORCEMENT'], }, ['DEPLOY'] @@ -864,6 +920,7 @@ describe('policies.utils', () => { eventSource: 'NOT_APPLICABLE', enforcementActions: ['SCALE_TO_ZERO_ENFORCEMENT'], excludedImageNames: [], + excludedWorkloadKinds: ['JOB'], }); }); @@ -875,6 +932,7 @@ describe('policies.utils', () => { eventSource: 'NOT_APPLICABLE', enforcementActions: ['FAIL_BUILD_ENFORCEMENT', 'SCALE_TO_ZERO_ENFORCEMENT'], excludedImageNames: ['docker.io/library/archlinux:latest'], + excludedWorkloadKinds: ['CRON_JOB', 'JOB'], }, ['BUILD'] ) @@ -883,6 +941,7 @@ describe('policies.utils', () => { eventSource: 'NOT_APPLICABLE', enforcementActions: ['FAIL_BUILD_ENFORCEMENT'], excludedImageNames: ['docker.io/library/archlinux:latest'], + excludedWorkloadKinds: [], }); }); }); @@ -899,4 +958,19 @@ describe('policies.utils', () => { expect(containerTypeFilterApplies([], 'NOT_APPLICABLE')).toBe(false); }); }); + + describe('workload kind exclusion copy', () => { + test('formats review labels and wizard info text', () => { + expect(formatWorkloadKindList(['JOB', 'CRON_JOB'])).toEqual('CronJobs, Jobs'); + expect(formatWorkloadKindExclusionMessage([])).toEqual( + 'Policy will not exclude any workload kinds.' + ); + expect(formatWorkloadKindExclusionMessage(['JOB'])).toEqual( + 'Policy will exclude Jobs.' + ); + expect(formatWorkloadKindExclusionMessage(['CRON_JOB', 'JOB'])).toEqual( + 'Policy will exclude CronJobs and Jobs.' + ); + }); + }); }); diff --git a/ui/apps/platform/src/Containers/Policies/policies.utils.ts b/ui/apps/platform/src/Containers/Policies/policies.utils.ts index 20564da4ed15e..be621db9f1f06 100644 --- a/ui/apps/platform/src/Containers/Policies/policies.utils.ts +++ b/ui/apps/platform/src/Containers/Policies/policies.utils.ts @@ -8,6 +8,11 @@ import { notifierIntegrationsDescriptors } from 'Containers/Integrations/utils/i import { eventSourceLabels, lifecycleStageLabels } from 'messages/common'; import type { ClusterScopeObject } from 'services/RolesService'; import type { NotifierIntegration } from 'types/notifier.proto'; +import { + isPolicyWorkloadKind, + policyWorkloadKindLabels, + policyWorkloadKinds, +} from 'types/policy.proto'; import type { ClientPolicy, EnforcementAction, @@ -22,6 +27,7 @@ import type { PolicyImageExclusion, PolicyScope, PolicyScopeLabel, + PolicyWorkloadKind, ValueObj, } from 'types/policy.proto'; import type { SearchFilter } from 'types/search'; @@ -60,6 +66,7 @@ export const initialPolicy: ClientPolicy = { enforcementActions: [], excludedImageNames: [], excludedDeploymentScopes: [], + excludedWorkloadKinds: [], SORTName: '', // For internal use only. SORTLifecycleStage: '', // For internal use only. SORTEnforcement: false, // For internal use only. @@ -206,6 +213,41 @@ export function getExcludedImageNames(exclusions: PolicyExclusion[]): string[] { return excludedImageNames; } +export function getExcludedWorkloadKinds(exclusions: PolicyExclusion[]): PolicyWorkloadKind[] { + const selected = new Set(); + + exclusions.forEach((exclusion) => { + exclusion.excludeByKind?.kinds?.forEach((kind) => { + if (isPolicyWorkloadKind(kind)) { + selected.add(kind); + } + }); + }); + + return policyWorkloadKinds.filter((kind) => selected.has(kind)); +} + +export function formatWorkloadKindList(kinds: PolicyWorkloadKind[]): string { + return policyWorkloadKinds + .filter((kind) => kinds.includes(kind)) + .map((kind) => policyWorkloadKindLabels[kind]) + .join(', '); +} + +export function formatWorkloadKindExclusionMessage(kinds: PolicyWorkloadKind[]): string { + const labels = policyWorkloadKinds + .filter((kind) => kinds.includes(kind)) + .map((kind) => policyWorkloadKindLabels[kind]); + + if (labels.length === 0) { + return 'Policy will not exclude any workload kinds.'; + } + if (labels.length === 1) { + return `Policy will exclude ${labels[0]}.`; + } + return `Policy will exclude ${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}.`; +} + // lifecycleStages export function formatLifecycleStages(lifecycleStages: LifecycleStage[]): string { @@ -288,6 +330,7 @@ export type WizardPolicyStep4 = { scope: PolicyScope[]; excludedDeploymentScopes: PolicyExcludedDeployment[]; excludedImageNames: string[]; + excludedWorkloadKinds?: PolicyWorkloadKind[]; }; // The exclusion UI does not show cluster or namespace label yet, but inclusion and exclusion both @@ -442,6 +485,7 @@ export function formatValueStr(valueObj: ValueObj, fieldName: string): string { function getExclusionFields({ exclusions }: Policy): { excludedImageNames: ClientPolicy['excludedImageNames']; excludedDeploymentScopes: ClientPolicy['excludedDeploymentScopes']; + excludedWorkloadKinds: ClientPolicy['excludedWorkloadKinds']; } { const excludedImageNames = exclusions .filter((o): o is PolicyImageExclusion => !!o.image) @@ -454,6 +498,7 @@ function getExclusionFields({ exclusions }: Policy): { return { excludedImageNames, excludedDeploymentScopes, + excludedWorkloadKinds: getExcludedWorkloadKinds(exclusions), }; } @@ -492,6 +537,17 @@ export function isExcludedDeploymentScopeEmpty(scope: PolicyScope | null | undef export function getServerPolicyExclusions(policy: ClientPolicy): Policy['exclusions'] { const exclusions: Policy['exclusions'] = []; + const excludedWorkloadKinds = policyWorkloadKinds.filter((kind) => + (policy.excludedWorkloadKinds ?? []).includes(kind) + ); + if (excludedWorkloadKinds.length > 0) { + exclusions.push({ + deployment: null, + image: null, + excludeByKind: { kinds: excludedWorkloadKinds }, + }); + } + policy.excludedDeploymentScopes.forEach((deployment) => { const deploymentForServer = isExcludedDeploymentScopeEmpty(deployment.scope) ? { ...deployment, scope: null } @@ -641,13 +697,15 @@ function trimClientWizardPolicy(policyUntrimmed: ClientPolicy): ClientPolicy { } export function getClientWizardPolicy(policy: Policy): ClientPolicy { - const { excludedImageNames, excludedDeploymentScopes } = getExclusionFields(policy); + const { excludedImageNames, excludedDeploymentScopes, excludedWorkloadKinds } = + getExclusionFields(policy); const { serverPolicySections, policySections } = getFormattedClientPolicyFields(policy); const clientPolicy = { ...cloneDeep(policy), excludedImageNames, excludedDeploymentScopes, + excludedWorkloadKinds, serverPolicySections, policySections, }; @@ -664,6 +722,7 @@ export function getServerPolicy(policyUntrimmed: ClientPolicy): Policy { const fieldsToOmit = [ 'excludedImageNames', 'excludedDeploymentScopes', + 'excludedWorkloadKinds', 'serverPolicySections', ] as const; @@ -698,7 +757,11 @@ export function isRuntimePolicy(lifecycleStages: LifecycleStage[]): lifecycleSta export function getLifeCyclesUpdates< T extends Pick< ClientPolicy, - 'lifecycleStages' | 'eventSource' | 'excludedImageNames' | 'enforcementActions' + | 'lifecycleStages' + | 'eventSource' + | 'excludedImageNames' + | 'excludedWorkloadKinds' + | 'enforcementActions' >, >(values: T, selectedStages: ValidPolicyLifeCycle): T { /* @@ -715,6 +778,14 @@ export function getLifeCyclesUpdates< changedValues.excludedImageNames = []; } + if ( + !isDeployPolicy(selectedStages) && + !isBuildAndDeployPolicy(selectedStages) && + !isRuntimePolicy(selectedStages) + ) { + changedValues.excludedWorkloadKinds = []; + } + values.lifecycleStages.forEach((stage) => { if (!selectedStages.some((validStage) => validStage === stage)) { changedValues.enforcementActions = filterEnforcementActionsForRemovedLifecycleStage( diff --git a/ui/apps/platform/src/types/featureFlag.ts b/ui/apps/platform/src/types/featureFlag.ts index 5e8ac920c9a48..5686ef737e959 100644 --- a/ui/apps/platform/src/types/featureFlag.ts +++ b/ui/apps/platform/src/types/featureFlag.ts @@ -10,6 +10,7 @@ export type FeatureFlagEnvVar = | 'ROX_LEGACY_SCANNER' | 'ROX_LIGHTSPEED_RISK_SUMMARY' | 'ROX_NODE_INDEX_ENABLED' + | 'ROX_POLICY_WORKLOAD_KIND_EXCLUSION' | 'ROX_SCANNER_V4' | 'ROX_SENSITIVE_FILE_ACTIVITY' | 'ROX_UI_SECRETS_PAGE_MIGRATION' diff --git a/ui/apps/platform/src/types/policy.proto.ts b/ui/apps/platform/src/types/policy.proto.ts index 4d5d16350adb6..9c94ec2a0c780 100644 --- a/ui/apps/platform/src/types/policy.proto.ts +++ b/ui/apps/platform/src/types/policy.proto.ts @@ -52,6 +52,7 @@ export type BasePolicy = { export type ClientPolicy = { excludedImageNames: string[]; // For internal use only. excludedDeploymentScopes: PolicyExcludedDeployment[]; // For internal use only. + excludedWorkloadKinds: PolicyWorkloadKind[]; // For internal use only. serverPolicySections: PolicySection[]; // For internal use only. policySections: ClientPolicySection[]; // value strings converted into objects } & BasePolicy; @@ -60,11 +61,23 @@ export type Policy = { policySections: PolicySection[]; // values are strings } & BasePolicy; -export type PolicyExclusion = PolicyDeploymentExclusion | PolicyImageExclusion; +export const policyWorkloadKinds = ['CRON_JOB', 'JOB'] as const; +export type PolicyWorkloadKind = (typeof policyWorkloadKinds)[number]; + +export const policyWorkloadKindLabels: Record = { + CRON_JOB: 'CronJobs', + JOB: 'Jobs', +}; + +export type PolicyExclusion = + | PolicyDeploymentExclusion + | PolicyImageExclusion + | PolicyExcludeByKindExclusion; export type PolicyDeploymentExclusion = { deployment: PolicyExcludedDeployment; image: null; + excludeByKind?: null; } & PolicyBaseExclusion; export type PolicyExcludedDeployment = { @@ -77,8 +90,23 @@ export type PolicyImageExclusion = { image: { name: string; }; + excludeByKind?: { + kinds: PolicyWorkloadKind[]; + } | null; } & PolicyBaseExclusion; +export type PolicyExcludeByKindExclusion = { + deployment: null; + image: null; + excludeByKind: { + kinds: PolicyWorkloadKind[]; + }; +} & PolicyBaseExclusion; + +export function isPolicyWorkloadKind(value: string): value is PolicyWorkloadKind { + return (policyWorkloadKinds as readonly string[]).includes(value); +} + // TODO prefer initial values instead of optional properties while adding a new policy? // TODO These are undefined at runtime - make optional here? export type PolicyBaseExclusion = {