mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-04 10:39:23 +02:00
feat(core): Allow stricter-than-floor workflow redaction updates (#31304)
This commit is contained in:
parent
364c250ceb
commit
de95eb84ae
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
packages/cli/src/modules/redaction/redaction-policy.ts
Normal file
50
packages/cli/src/modules/redaction/redaction-policy.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user