diff --git a/packages/cli/src/modules/redaction/__tests__/redaction-enforcement.service.test.ts b/packages/cli/src/modules/redaction/__tests__/redaction-enforcement.service.test.ts index a7991d7cd11..5e59c3b2f47 100644 --- a/packages/cli/src/modules/redaction/__tests__/redaction-enforcement.service.test.ts +++ b/packages/cli/src/modules/redaction/__tests__/redaction-enforcement.service.test.ts @@ -1,53 +1,98 @@ +import type { RedactionEnforcementSettings, RedactionFloor } from '@n8n/api-types'; +import { mock } from 'jest-mock-extended'; +import type { WorkflowSettings } from 'n8n-workflow'; + +import { floorToSettings } from '../redaction-enforcement-mapper'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; -import { RedactionConfig } from '../redaction.config'; +import type { InstanceRedactionEnforcementService } from '../instance-redaction-enforcement.service'; import { RedactionEnforcementService } from '../redaction-enforcement.service'; describe('RedactionEnforcementService', () => { - function createService(enforcement: boolean) { - const config = new RedactionConfig(); - config.enforcement = enforcement; - return new RedactionEnforcementService(config); + function createService(floor: RedactionFloor) { + const instanceRedactionEnforcementService = mock(); + const settings: RedactionEnforcementSettings = floorToSettings(floor); + instanceRedactionEnforcementService.get.mockResolvedValue(settings); + return new RedactionEnforcementService(instanceRedactionEnforcementService); } - describe('isEnforced()', () => { - test('returns true when env flag is on', () => { - expect(createService(true).isEnforced()).toBe(true); - }); - - test('returns false when env flag is off', () => { - expect(createService(false).isEnforced()).toBe(false); - }); - }); - describe('assertPolicyChangeAllowed()', () => { - test('does nothing when enforcement is off', () => { - const service = createService(false); - expect(() => service.assertPolicyChangeAllowed('none', 'all')).not.toThrow(); + test('allows when incoming policy is undefined (field not in payload)', async () => { + const service = createService('all'); + await expect(service.assertPolicyChangeAllowed('none', undefined)).resolves.toBeUndefined(); }); - test('does nothing when incoming policy is undefined (field not in payload)', () => { - const service = createService(true); - expect(() => service.assertPolicyChangeAllowed('none', undefined)).not.toThrow(); + test('allows when incoming policy matches current policy (preserves legacy below-floor state)', async () => { + const service = createService('all'); + await expect(service.assertPolicyChangeAllowed('none', 'none')).resolves.toBeUndefined(); }); - test('does nothing when incoming policy matches current policy', () => { - const service = createService(true); - expect(() => service.assertPolicyChangeAllowed('all', 'all')).not.toThrow(); + describe('floor=off', () => { + test.each(['none', 'manual-only', 'non-manual', 'all'])( + 'allows any change to %s', + async (incoming) => { + const service = createService('off'); + await expect(service.assertPolicyChangeAllowed('all', incoming)).resolves.toBeUndefined(); + }, + ); }); - test('throws 422 when incoming policy differs from current and enforcement is on', () => { - const service = createService(true); - expect(() => service.assertPolicyChangeAllowed('none', 'all')).toThrow( + describe('floor=production', () => { + test.each(['non-manual', 'all'])( + 'allows change to %s (meets or exceeds floor)', + async (incoming) => { + const service = createService('production'); + await expect(service.assertPolicyChangeAllowed('all', incoming)).resolves.toBeUndefined(); + }, + ); + + test.each(['none', 'manual-only'])( + 'rejects change to %s (does not redact production)', + async (incoming) => { + const service = createService('production'); + await expect(service.assertPolicyChangeAllowed('all', incoming)).rejects.toThrow( + UnprocessableRequestError, + ); + }, + ); + }); + + describe('floor=all', () => { + test('allows change to all (matches floor)', async () => { + const service = createService('all'); + await expect( + service.assertPolicyChangeAllowed('non-manual', 'all'), + ).resolves.toBeUndefined(); + }); + + test.each(['none', 'manual-only', 'non-manual'])( + 'rejects change to %s (does not redact both channels)', + async (incoming) => { + const service = createService('all'); + await expect(service.assertPolicyChangeAllowed('all', incoming)).rejects.toThrow( + UnprocessableRequestError, + ); + }, + ); + }); + + test('error message names the floor as the reason', async () => { + const service = createService('production'); + await expect(service.assertPolicyChangeAllowed('all', 'none')).rejects.toThrow( + 'Workflow redaction policy cannot be weaker than the instance floor.', + ); + }); + + test('rejects upgrade from undefined current to below-floor incoming', async () => { + const service = createService('production'); + await expect(service.assertPolicyChangeAllowed(undefined, 'none')).rejects.toThrow( UnprocessableRequestError, ); }); - test('throws when current is undefined and incoming is defined', () => { - const service = createService(true); - expect(() => service.assertPolicyChangeAllowed(undefined, 'all')).toThrow( - UnprocessableRequestError, - ); + test('allows upgrade from undefined current to meets-floor incoming', async () => { + const service = createService('production'); + await expect(service.assertPolicyChangeAllowed(undefined, 'all')).resolves.toBeUndefined(); }); }); }); diff --git a/packages/cli/src/modules/redaction/__tests__/redaction-policy.test.ts b/packages/cli/src/modules/redaction/__tests__/redaction-policy.test.ts new file mode 100644 index 00000000000..71f7b24018c --- /dev/null +++ b/packages/cli/src/modules/redaction/__tests__/redaction-policy.test.ts @@ -0,0 +1,47 @@ +import type { RedactionFloor } from '@n8n/api-types'; +import type { WorkflowSettings } from 'n8n-workflow'; + +import { policyForFloor, policyMeetsFloor } from '../redaction-policy'; + +type Policy = WorkflowSettings.RedactionPolicy; + +describe('policyMeetsFloor', () => { + // Exhaustive: every policy × every floor. Single source of truth for the matrix. + const CASES: Array<[RedactionFloor, Policy, boolean]> = [ + ['off', 'none', true], + ['off', 'manual-only', true], + ['off', 'non-manual', true], + ['off', 'all', true], + + ['production', 'none', false], + ['production', 'manual-only', false], + ['production', 'non-manual', true], + ['production', 'all', true], + + ['all', 'none', false], + ['all', 'manual-only', false], + ['all', 'non-manual', false], + ['all', 'all', true], + ]; + + it.each(CASES)('floor=%s, policy=%s → %s', (floor, policy, expected) => { + expect(policyMeetsFloor(policy, floor)).toBe(expected); + }); +}); + +describe('policyForFloor', () => { + it.each<[RedactionFloor, Policy | undefined]>([ + ['off', undefined], + ['production', 'non-manual'], + ['all', 'all'], + ])('floor=%s seeds %s', (floor, expected) => { + expect(policyForFloor(floor)).toBe(expected); + }); + + // Invariant: a seeded policy always satisfies its own floor. + it.each(['production', 'all'])('seed for floor=%s meets that floor', (floor) => { + const seed = policyForFloor(floor); + expect(seed).toBeDefined(); + expect(policyMeetsFloor(seed as Policy, floor)).toBe(true); + }); +}); diff --git a/packages/cli/src/modules/redaction/redaction-enforcement.service.ts b/packages/cli/src/modules/redaction/redaction-enforcement.service.ts index b3cc0b4c201..a39e0d5087d 100644 --- a/packages/cli/src/modules/redaction/redaction-enforcement.service.ts +++ b/packages/cli/src/modules/redaction/redaction-enforcement.service.ts @@ -1,36 +1,44 @@ import { Service } from '@n8n/di'; import type { WorkflowSettings } from 'n8n-workflow'; +import { settingsToFloor } from './redaction-enforcement-mapper'; import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error'; -import { RedactionConfig } from './redaction.config'; +import { InstanceRedactionEnforcementService } from './instance-redaction-enforcement.service'; +import { policyMeetsFloor, REDACTION_FLOOR_VIOLATION_MESSAGE } from './redaction-policy'; /** - * Reports whether the workflow redaction policy is enforced at the instance level - * and asserts that incoming updates do not modify the policy when enforcement is on. + * Reports the active instance redaction floor and asserts that incoming + * workflow updates do not weaken the policy below it. * - * The current check reads the env feature flag directly. When the enforcement cache - * lands, this is the single place to swap in the cache lookup so call sites stay - * unchanged. + * Progressive restriction model: workflows may match or exceed the floor; + * changes that would drop the policy below the floor are rejected with 422. + * Pre-existing below-floor state is preserved (no retroactive application) — + * an update only fails when the *incoming* policy violates the floor. */ @Service() export class RedactionEnforcementService { - constructor(private readonly config: RedactionConfig) {} + constructor( + private readonly instanceRedactionEnforcementService: InstanceRedactionEnforcementService, + ) {} - isEnforced(): boolean { - return this.config.enforcement; + private async getFloor() { + const settings = await this.instanceRedactionEnforcementService.get(); + return settingsToFloor(settings); } - assertPolicyChangeAllowed( + async assertPolicyChangeAllowed( currentPolicy: WorkflowSettings.RedactionPolicy | undefined, incomingPolicy: WorkflowSettings.RedactionPolicy | undefined, - ): void { - if (!this.isEnforced()) return; + ): Promise { + // Field absent from payload: nothing to validate. if (incomingPolicy === undefined) return; + // Unchanged: preserve legacy below-floor state (no retroactive application). if (incomingPolicy === currentPolicy) return; - throw new UnprocessableRequestError( - 'Workflow redaction policy is enforced at the instance level and cannot be modified.', - ); + const floor = await this.getFloor(); + if (!policyMeetsFloor(incomingPolicy, floor)) { + throw new UnprocessableRequestError(REDACTION_FLOOR_VIOLATION_MESSAGE); + } } } diff --git a/packages/cli/src/modules/redaction/redaction-policy.ts b/packages/cli/src/modules/redaction/redaction-policy.ts new file mode 100644 index 00000000000..82048527d97 --- /dev/null +++ b/packages/cli/src/modules/redaction/redaction-policy.ts @@ -0,0 +1,50 @@ +import type { RedactionFloor } from '@n8n/api-types'; +import type { WorkflowSettings } from 'n8n-workflow'; + +type RedactionPolicy = WorkflowSettings.RedactionPolicy; +type RedactionScope = { production: boolean; manual: boolean }; + +/** What each workflow redaction policy actually redacts. */ +const POLICY_SCOPE: Record = { + none: { production: false, manual: false }, + 'manual-only': { production: false, manual: true }, + 'non-manual': { production: true, manual: false }, + all: { production: true, manual: true }, +}; + +/** What each instance floor requires be redacted. */ +const FLOOR_REQUIREMENTS: Record = { + off: { production: false, manual: false }, + production: { production: true, manual: false }, + all: { production: true, manual: true }, +}; + +/** Minimum policy that satisfies a floor — used to seed new workflows. */ +const FLOOR_SEED_POLICY: Record = { + off: undefined, + production: 'non-manual', + all: 'all', +}; + +/** Canonical 422 message. Single source of truth — do not inline elsewhere. */ +export const REDACTION_FLOOR_VIOLATION_MESSAGE = + 'Workflow redaction policy cannot be weaker than the instance floor.'; + +/** + * True when `policy` redacts at least everything `floor` requires. + * Progressive-restriction model: equal-or-stricter passes, weaker fails. + */ +export function policyMeetsFloor(policy: RedactionPolicy, floor: RedactionFloor): boolean { + const required = FLOOR_REQUIREMENTS[floor]; + const scope = POLICY_SCOPE[policy]; + return (!required.production || scope.production) && (!required.manual || scope.manual); +} + +/** + * Minimum policy a new workflow should be seeded with for `floor`, + * or `undefined` when the floor requires nothing (no seed). + * Invariant: the returned policy always satisfies `policyMeetsFloor(_, floor)`. + */ +export function policyForFloor(floor: RedactionFloor): RedactionPolicy | undefined { + return FLOOR_SEED_POLICY[floor]; +} diff --git a/packages/cli/src/modules/redaction/redaction.config.ts b/packages/cli/src/modules/redaction/redaction.config.ts deleted file mode 100644 index 156e656ddbe..00000000000 --- a/packages/cli/src/modules/redaction/redaction.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Config, Env } from '@n8n/config'; - -@Config -export class RedactionConfig { - /** Whether the workflow redaction policy is enforced at the instance level. When on, the policy cannot be modified through any workflow update path. */ - @Env('N8N_ENV_FEAT_REDACTION_ENFORCEMENT') - enforcement: boolean = false; -} diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts index ef8fe2ec560..69ac80b3207 100644 --- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts +++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-import.service.ee.test.ts @@ -1267,16 +1267,14 @@ describe('SourceControlImportService', () => { }), ); - redactionEnforcementService.assertPolicyChangeAllowed.mockImplementationOnce(() => { - throw new Error( - 'Workflow redaction policy is enforced at the instance level and cannot be modified.', - ); - }); + redactionEnforcementService.assertPolicyChangeAllowed.mockRejectedValueOnce( + new Error('Workflow redaction policy cannot be weaker than the instance floor.'), + ); const candidates = [mock({ file: mockWorkflowFile, id: '1' })]; await expect(service.importWorkflowFromWorkFolder(candidates, mockUserId)).rejects.toThrow( - 'Workflow redaction policy is enforced at the instance level', + 'Workflow redaction policy cannot be weaker than the instance floor.', ); expect(redactionEnforcementService.assertPolicyChangeAllowed).toHaveBeenCalledWith( diff --git a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts index 5596c2c1872..d31d5d45995 100644 --- a/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts +++ b/packages/cli/src/modules/source-control.ee/source-control-import.service.ee.ts @@ -773,7 +773,7 @@ export class SourceControlImportService { } const existingWorkflow = existingWorkflows.find((e) => e.id === id); - this.redactionEnforcementService.assertPolicyChangeAllowed( + await this.redactionEnforcementService.assertPolicyChangeAllowed( existingWorkflow?.settings?.redactionPolicy, importedWorkflow.settings?.redactionPolicy, ); diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowSettings.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowSettings.yml index 42edb65a855..8f72fd28aa5 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowSettings.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/schemas/workflowSettings.yml @@ -49,6 +49,22 @@ properties: timeSavedPerExecution: type: number description: Estimated time saved per execution in minutes + redactionPolicy: + type: string + enum: ['none', 'non-manual', 'manual-only', 'all'] + description: | + Controls whether execution data is redacted for this workflow. + + Available options: + - `none` (default): No redaction — all execution data is stored. + - `non-manual`: Redact production (non-manually triggered) executions only. + - `manual-only`: Redact manually triggered executions only. + - `all`: Redact all executions (manual and production). + + When the instance has a redaction floor configured, the policy must be equal to + or stricter than the floor. Requests that would weaken the policy below the + instance floor are rejected with 422. + example: non-manual availableInMCP: type: boolean description: | diff --git a/packages/cli/src/workflows/__tests__/workflow.service.test.ts b/packages/cli/src/workflows/__tests__/workflow.service.test.ts index 648c5b28a4f..5e54a5344f3 100644 --- a/packages/cli/src/workflows/__tests__/workflow.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow.service.test.ts @@ -461,13 +461,13 @@ describe('WorkflowService', () => { ); }); - test('should reject update with 422 when enforcement is on and redactionPolicy is changing', async () => { + test('should reject update with 422 when redactionPolicy change violates the instance floor', async () => { setupExistingWorkflow({ redactionPolicy: 'none' }); - redactionEnforcementServiceMock.assertPolicyChangeAllowed.mockImplementationOnce(() => { - throw new UnprocessableRequestError( - 'Workflow redaction policy is enforced at the instance level and cannot be modified.', - ); - }); + redactionEnforcementServiceMock.assertPolicyChangeAllowed.mockRejectedValueOnce( + new UnprocessableRequestError( + 'Workflow redaction policy cannot be weaker than the instance floor.', + ), + ); const user = mock(); await expect( diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index 411749aec65..d1366402d25 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -346,7 +346,7 @@ export class WorkflowService { throw new BadRequestError('Cannot update an archived workflow.'); } - this.redactionEnforcementService.assertPolicyChangeAllowed( + await this.redactionEnforcementService.assertPolicyChangeAllowed( workflow.settings?.redactionPolicy, workflowUpdateData.settings?.redactionPolicy, );