mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-17 17:42:47 +08:00
feat(core): Track redaction policy source on execution runtime data (#31079)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
868e988c09
commit
58b0965f66
+31
-5
@@ -1,4 +1,5 @@
|
||||
import { Container } from '@n8n/di';
|
||||
import { parse as flattedParse, stringify as flattedStringify } from 'flatted';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import {
|
||||
ExecutionContextHookRegistry,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import {
|
||||
createRunExecutionData,
|
||||
type INode,
|
||||
type IRunExecutionData,
|
||||
type IWorkflowExecuteAdditionalData,
|
||||
type Workflow,
|
||||
type WorkflowSettings,
|
||||
@@ -92,43 +94,67 @@ describe('RedactionContextHook integration with establishExecutionContext', () =
|
||||
return runExecutionData.executionData!.runtimeData!.redaction;
|
||||
};
|
||||
|
||||
it("floor 'production' + workflow default → production redacted, manual not", async () => {
|
||||
it("floor 'production' + workflow default → production redacted, manual not (source: instance)", async () => {
|
||||
expect(await establishWith('production', undefined)).toEqual({
|
||||
version: 2,
|
||||
production: true,
|
||||
manual: false,
|
||||
source: 'instance',
|
||||
});
|
||||
});
|
||||
|
||||
it("floor 'production' + workflow redacts manual → both redacted (stricter workflow preserved)", async () => {
|
||||
it("floor 'production' + workflow redacts manual → both redacted (source: workflow)", async () => {
|
||||
expect(await establishWith('production', 'all')).toEqual({
|
||||
version: 2,
|
||||
production: true,
|
||||
manual: true,
|
||||
source: 'workflow',
|
||||
});
|
||||
});
|
||||
|
||||
it("floor 'all' → both channels redacted regardless of workflow setting", async () => {
|
||||
it("floor 'all' → both channels redacted regardless of workflow setting (source: instance)", async () => {
|
||||
expect(await establishWith('all', 'none')).toEqual({
|
||||
version: 2,
|
||||
production: true,
|
||||
manual: true,
|
||||
source: 'instance',
|
||||
});
|
||||
});
|
||||
|
||||
it("floor 'off' → workflow setting applies", async () => {
|
||||
it("floor 'off' → workflow setting applies (source: workflow)", async () => {
|
||||
expect(await establishWith('off', 'non-manual')).toEqual({
|
||||
version: 2,
|
||||
production: true,
|
||||
manual: false,
|
||||
source: 'workflow',
|
||||
});
|
||||
});
|
||||
|
||||
it("floor 'off' + no workflow setting → nothing redacted", async () => {
|
||||
it("floor 'off' + no workflow setting → nothing redacted (source: workflow)", async () => {
|
||||
expect(await establishWith('off', undefined)).toEqual({
|
||||
version: 2,
|
||||
production: false,
|
||||
manual: false,
|
||||
source: 'workflow',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves redaction.source through flatted serialization (persistence round-trip)', async () => {
|
||||
enforcementService.get.mockResolvedValue('production');
|
||||
|
||||
const workflow = buildWorkflow(undefined);
|
||||
const runExecutionData = buildRunExecutionData();
|
||||
|
||||
await establishExecutionContext(workflow, runExecutionData, additionalData, 'manual');
|
||||
|
||||
const serialized = flattedStringify(runExecutionData);
|
||||
const restored = flattedParse(serialized) as IRunExecutionData;
|
||||
|
||||
expect(restored.executionData!.runtimeData!.redaction).toEqual({
|
||||
version: 2,
|
||||
production: true,
|
||||
manual: false,
|
||||
source: 'instance',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,8 +24,12 @@ describe('RedactionContextHook', () => {
|
||||
service.get.mockResolvedValue(floor);
|
||||
};
|
||||
|
||||
const expectChannels = (production: boolean, manual: boolean) => ({
|
||||
contextUpdate: { redaction: { version: 2, production, manual } },
|
||||
const expectChannels = (
|
||||
production: boolean,
|
||||
manual: boolean,
|
||||
source: 'workflow' | 'instance' = 'workflow',
|
||||
) => ({
|
||||
contextUpdate: { redaction: { version: 2, production, manual, source } },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -46,7 +50,7 @@ describe('RedactionContextHook', () => {
|
||||
expect(result).toEqual(expectChannels(production, manual));
|
||||
});
|
||||
|
||||
it('clamps a manual-only workflow policy up to production+manual', async () => {
|
||||
it('upgrades a legacy manual-only workflow to redact both channels (safety net)', async () => {
|
||||
setFloor('off');
|
||||
|
||||
const result = await hook.execute(buildOptions('manual-only'));
|
||||
@@ -69,7 +73,7 @@ describe('RedactionContextHook', () => {
|
||||
|
||||
const result = await hook.execute(buildOptions('none'));
|
||||
|
||||
expect(result).toEqual(expectChannels(true, false));
|
||||
expect(result).toEqual(expectChannels(true, false, 'instance'));
|
||||
});
|
||||
|
||||
it('preserves a stricter workflow policy that also redacts manual', async () => {
|
||||
@@ -77,7 +81,7 @@ describe('RedactionContextHook', () => {
|
||||
|
||||
const result = await hook.execute(buildOptions('all'));
|
||||
|
||||
expect(result).toEqual(expectChannels(true, true));
|
||||
expect(result).toEqual(expectChannels(true, true, 'workflow'));
|
||||
});
|
||||
|
||||
it('stays production-only when workflow matches the floor', async () => {
|
||||
@@ -85,23 +89,79 @@ describe('RedactionContextHook', () => {
|
||||
|
||||
const result = await hook.execute(buildOptions('non-manual'));
|
||||
|
||||
expect(result).toEqual(expectChannels(true, false));
|
||||
expect(result).toEqual(expectChannels(true, false, 'workflow'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("floor 'all' — both channels enforced regardless of workflow", () => {
|
||||
it.each(['none', 'non-manual', 'manual-only', 'all', undefined] as const)(
|
||||
'redacts both channels for workflow policy %s',
|
||||
async (policy) => {
|
||||
it.each([
|
||||
['none', 'instance'],
|
||||
['non-manual', 'instance'],
|
||||
['manual-only', 'instance'],
|
||||
['all', 'workflow'],
|
||||
[undefined, 'instance'],
|
||||
] as const)(
|
||||
'redacts both channels for workflow policy %s (source: %s)',
|
||||
async (policy, source) => {
|
||||
setFloor('all');
|
||||
|
||||
const result = await hook.execute(buildOptions(policy));
|
||||
|
||||
expect(result).toEqual(expectChannels(true, true));
|
||||
expect(result).toEqual(expectChannels(true, true, source));
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("source attribution: 'instance' only when the floor adds redaction the workflow did not ask for", () => {
|
||||
it("attributes 'workflow' when floor is off", async () => {
|
||||
setFloor('off');
|
||||
|
||||
const result = await hook.execute(buildOptions('none'));
|
||||
|
||||
expect(result.contextUpdate!.redaction).toMatchObject({ source: 'workflow' });
|
||||
});
|
||||
|
||||
it("attributes 'instance' when floor adds production the workflow did not", async () => {
|
||||
setFloor('production');
|
||||
|
||||
const result = await hook.execute(buildOptions('none'));
|
||||
|
||||
expect(result.contextUpdate!.redaction).toMatchObject({ source: 'instance' });
|
||||
});
|
||||
|
||||
it("attributes 'workflow' when the workflow already redacts the floor's channel", async () => {
|
||||
setFloor('production');
|
||||
|
||||
const result = await hook.execute(buildOptions('non-manual'));
|
||||
|
||||
expect(result.contextUpdate!.redaction).toMatchObject({ source: 'workflow' });
|
||||
});
|
||||
|
||||
it("attributes 'workflow' when the workflow exceeds the floor", async () => {
|
||||
setFloor('production');
|
||||
|
||||
const result = await hook.execute(buildOptions('all'));
|
||||
|
||||
expect(result.contextUpdate!.redaction).toMatchObject({ source: 'workflow' });
|
||||
});
|
||||
|
||||
it("attributes 'instance' when floor='all' raises an off workflow", async () => {
|
||||
setFloor('all');
|
||||
|
||||
const result = await hook.execute(buildOptions('none'));
|
||||
|
||||
expect(result.contextUpdate!.redaction).toMatchObject({ source: 'instance' });
|
||||
});
|
||||
|
||||
it("attributes 'workflow' for a manual-only workflow when floor is off (the legacy safety upgrade is workflow-side, not floor-side)", async () => {
|
||||
setFloor('off');
|
||||
|
||||
const result = await hook.execute(buildOptions('manual-only'));
|
||||
|
||||
expect(result.contextUpdate!.redaction).toMatchObject({ source: 'workflow' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('hook metadata', () => {
|
||||
it('is named RedactionContextHook', () => {
|
||||
expect(hook.hookDescription.name).toBe('RedactionContextHook');
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HookDescription,
|
||||
IContextEstablishmentHook,
|
||||
} from '@n8n/decorators';
|
||||
import type { RedactionSource } from 'n8n-workflow';
|
||||
|
||||
import { InstanceRedactionEnforcementService } from './instance-redaction-enforcement.service';
|
||||
import { policyToChannels } from './redaction-channels';
|
||||
@@ -52,12 +53,22 @@ export class RedactionContextHook implements IContextEstablishmentHook {
|
||||
// manual-implies-production invariant; past executions keep their V1 snapshot.
|
||||
const production = workflow.production || floorEnforcesProduction || manual;
|
||||
|
||||
// Attribution: `'instance'` when the floor enforced redaction the workflow did
|
||||
// not ask for, `'workflow'` otherwise. The workflow's manual-implies-production
|
||||
// clamp is workflow-side, so a manual-only workflow that produces production
|
||||
// redaction is still attributed to the workflow, not the floor.
|
||||
const floorRaisedTheBar =
|
||||
(floorEnforcesProduction && !workflow.production) ||
|
||||
(floorEnforcesManual && !workflow.manual);
|
||||
const source: RedactionSource = floorRaisedTheBar ? 'instance' : 'workflow';
|
||||
|
||||
return {
|
||||
contextUpdate: {
|
||||
redaction: {
|
||||
version: 2,
|
||||
production,
|
||||
manual,
|
||||
source,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -120,16 +120,26 @@ const RedactionSettingSchemaV1 = z.object({
|
||||
|
||||
export type IRedactionSettingV1 = z.output<typeof RedactionSettingSchemaV1>;
|
||||
|
||||
const RedactionSourceSchema = z.union([z.literal('workflow'), z.literal('instance')]);
|
||||
|
||||
export type RedactionSource = z.output<typeof RedactionSourceSchema>;
|
||||
|
||||
/**
|
||||
* Per-channel redaction snapshot. Each channel records, independently, whether
|
||||
* execution data is redacted for production and manual executions. This is the
|
||||
* strictest-per-channel resolution of the workflow setting and the instance floor,
|
||||
* captured at execution time.
|
||||
*
|
||||
* `source` records which layer raised the bar:
|
||||
* - `'instance'` when the floor enforced redaction the workflow did not ask for.
|
||||
* - `'workflow'` otherwise (workflow setting met or exceeded the floor, including
|
||||
* the floor='off' case).
|
||||
*/
|
||||
const RedactionSettingSchemaV2 = z.object({
|
||||
version: z.literal(2),
|
||||
production: z.boolean(),
|
||||
manual: z.boolean(),
|
||||
source: RedactionSourceSchema.optional(),
|
||||
});
|
||||
|
||||
export type IRedactionSettingV2 = z.output<typeof RedactionSettingSchemaV2>;
|
||||
|
||||
@@ -25,6 +25,29 @@ describe('toExecutionContext — redaction snapshot', () => {
|
||||
expect(parsed.redaction).toEqual({ version: 2, production: true, manual: false });
|
||||
});
|
||||
|
||||
it('parses a V2 redaction snapshot with source attribution', () => {
|
||||
const parsed = toExecutionContext({
|
||||
...baseContext,
|
||||
redaction: { version: 2, production: true, manual: false, source: 'instance' },
|
||||
});
|
||||
|
||||
expect(parsed.redaction).toEqual({
|
||||
version: 2,
|
||||
production: true,
|
||||
manual: false,
|
||||
source: 'instance',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unknown source value', () => {
|
||||
expect(() =>
|
||||
toExecutionContext({
|
||||
...baseContext,
|
||||
redaction: { version: 2, production: true, manual: false, source: 'project' },
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('parses a context without a redaction snapshot', () => {
|
||||
const parsed = toExecutionContext({ ...baseContext });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user