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 = {