feat(core): Allow stricter-than-floor workflow redaction updates (#31304)

This commit is contained in:
Csaba Tuncsik 2026-06-03 11:59:41 +02:00 committed by GitHub
parent 364c250ceb
commit de95eb84ae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 225 additions and 69 deletions

View File

@ -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<InstanceRedactionEnforcementService>();
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<WorkflowSettings.RedactionPolicy>(['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<WorkflowSettings.RedactionPolicy>(['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<WorkflowSettings.RedactionPolicy>(['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<WorkflowSettings.RedactionPolicy>(['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();
});
});
});

View File

@ -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<RedactionFloor>(['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);
});
});

View File

@ -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<void> {
// 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);
}
}
}

View File

@ -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<RedactionPolicy, RedactionScope> = {
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<RedactionFloor, RedactionScope> = {
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<RedactionFloor, RedactionPolicy | undefined> = {
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];
}

View File

@ -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;
}

View File

@ -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<SourceControlledFile>({ 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(

View File

@ -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,
);

View File

@ -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: |

View File

@ -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<User>();
await expect(

View File

@ -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,
);