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:
Thanasis G
2026-06-03 20:34:02 +00:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 868e988c09
commit 58b0965f66
5 changed files with 145 additions and 15 deletions
@@ -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 });