feat(core): Override workflow redaction policy at execution time when instance enforcement is on (#31069)

Co-authored-by: Yuliia Pominchuk <yuliia.pominchuk@n8n.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thanasis G 2026-05-28 17:08:30 +03:00 committed by GitHub
parent 5099287c5e
commit d6d0effddc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 329 additions and 149 deletions

View File

@ -0,0 +1,126 @@
import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended';
import {
ExecutionContextHookRegistry,
ExecutionContextService,
establishExecutionContext,
type Cipher,
} from 'n8n-core';
import {
createRunExecutionData,
type INode,
type IWorkflowExecuteAdditionalData,
type Workflow,
type WorkflowSettings,
} from 'n8n-workflow';
import type { InstanceRedactionEnforcementService } from '../instance-redaction-enforcement.service';
import { RedactionContextHook } from '../redaction-context-hook';
/**
* Wires the real RedactionContextHook, ExecutionContextService and
* ExecutionContextHookRegistry together and drives them through
* establishExecutionContext. Proves the end-to-end contract: at execution-context
* establishment time, instance enforcement overrides the workflow-configured
* redaction policy, and absent enforcement the workflow setting wins.
*/
describe('RedactionContextHook integration with establishExecutionContext', () => {
const buildWorkflow = (redactionPolicy?: WorkflowSettings.RedactionPolicy) =>
mock<Workflow>({ id: 'wf-1', settings: { redactionPolicy } });
const buildRunExecutionData = () => {
const startNode = mock<INode>({ name: 'Start', type: 'n8n-nodes-base.manualTrigger' });
return createRunExecutionData({
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [{ node: startNode, data: { main: [[{ json: {} }]] }, source: null }],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
});
};
const additionalData = mock<IWorkflowExecuteAdditionalData>({
webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting',
formWaitingBaseUrl: 'http://localhost:5678/form-waiting',
encryptedRunnerIdentity: undefined,
});
const passthroughCipher = mock<Cipher>({
encryptV2: async (data) => (typeof data === 'string' ? data : JSON.stringify(data)),
decryptV2: async (data) => data,
});
let enforcementService: ReturnType<typeof mock<InstanceRedactionEnforcementService>>;
beforeEach(() => {
enforcementService = mock<InstanceRedactionEnforcementService>();
const hook = new RedactionContextHook(enforcementService);
const hookRegistry = mock<ExecutionContextHookRegistry>();
hookRegistry.getGlobalHooks.mockReturnValue([hook]);
const executionContextService = new ExecutionContextService(
mock(),
hookRegistry,
passthroughCipher,
);
Container.set(ExecutionContextHookRegistry, hookRegistry);
Container.set(ExecutionContextService, executionContextService);
});
afterEach(() => {
Container.reset();
});
it('overrides workflow.settings.redactionPolicy when enforcement is active', async () => {
enforcementService.buildContext.mockResolvedValue({
enforcement: { enforced: true, manual: true, production: true },
});
const workflow = buildWorkflow('non-manual');
const runExecutionData = buildRunExecutionData();
await establishExecutionContext(workflow, runExecutionData, additionalData, 'manual');
expect(runExecutionData.executionData!.runtimeData!.redaction).toEqual({
version: 1,
policy: 'all',
});
});
it('falls back to workflow.settings.redactionPolicy when enforcement is inactive', async () => {
enforcementService.buildContext.mockResolvedValue({
enforcement: { enforced: false, manual: true, production: true },
});
const workflow = buildWorkflow('non-manual');
const runExecutionData = buildRunExecutionData();
await establishExecutionContext(workflow, runExecutionData, additionalData, 'manual');
expect(runExecutionData.executionData!.runtimeData!.redaction).toEqual({
version: 1,
policy: 'non-manual',
});
});
it('defaults to "none" when neither enforcement nor workflow setting applies', async () => {
enforcementService.buildContext.mockResolvedValue(undefined);
const workflow = buildWorkflow(undefined);
const runExecutionData = buildRunExecutionData();
await establishExecutionContext(workflow, runExecutionData, additionalData, 'manual');
expect(runExecutionData.executionData!.runtimeData!.redaction).toEqual({
version: 1,
policy: 'none',
});
});
});

View File

@ -0,0 +1,125 @@
import type { RedactionEnforcementSettings } from '@n8n/api-types';
import type { ContextEstablishmentOptions } from '@n8n/decorators';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { Workflow, WorkflowSettings } from 'n8n-workflow';
import type { InstanceRedactionEnforcementService } from '../instance-redaction-enforcement.service';
import { RedactionContextHook } from '../redaction-context-hook';
describe('RedactionContextHook', () => {
let service: MockProxy<InstanceRedactionEnforcementService>;
let hook: RedactionContextHook;
const buildOptions = (
workflowRedactionPolicy?: WorkflowSettings.RedactionPolicy,
): ContextEstablishmentOptions =>
mock<ContextEstablishmentOptions>({
workflow: mock<Workflow>({
settings: { redactionPolicy: workflowRedactionPolicy },
}),
});
const setEnforcement = (enforcement: RedactionEnforcementSettings | undefined) => {
service.buildContext.mockResolvedValue(enforcement ? { enforcement } : undefined);
};
beforeEach(() => {
service = mock<InstanceRedactionEnforcementService>();
hook = new RedactionContextHook(service);
});
describe('when enforcement is active', () => {
it('emits "all" when manual:true production:true', async () => {
setEnforcement({ enforced: true, manual: true, production: true });
const result = await hook.execute(buildOptions('none'));
expect(result).toEqual({
contextUpdate: { redaction: { version: 1, policy: 'all' } },
});
});
it('emits "non-manual" when manual:false production:true', async () => {
setEnforcement({ enforced: true, manual: false, production: true });
const result = await hook.execute(buildOptions('none'));
expect(result).toEqual({
contextUpdate: { redaction: { version: 1, policy: 'non-manual' } },
});
});
it('emits "manual-only" when manual:true production:false', async () => {
setEnforcement({ enforced: true, manual: true, production: false });
const result = await hook.execute(buildOptions('all'));
expect(result).toEqual({
contextUpdate: { redaction: { version: 1, policy: 'manual-only' } },
});
});
it('emits "none" when manual:false production:false', async () => {
setEnforcement({ enforced: true, manual: false, production: false });
const result = await hook.execute(buildOptions('all'));
expect(result).toEqual({
contextUpdate: { redaction: { version: 1, policy: 'none' } },
});
});
it('overrides the workflow-configured policy', async () => {
setEnforcement({ enforced: true, manual: true, production: true });
const result = await hook.execute(buildOptions('non-manual'));
expect(result).toEqual({
contextUpdate: { redaction: { version: 1, policy: 'all' } },
});
});
});
describe('when enforcement is inactive', () => {
it('falls back to workflow.settings.redactionPolicy when enforced:false', async () => {
setEnforcement({ enforced: false, manual: true, production: true });
const result = await hook.execute(buildOptions('non-manual'));
expect(result).toEqual({
contextUpdate: { redaction: { version: 1, policy: 'non-manual' } },
});
});
it('falls back to workflow.settings.redactionPolicy when buildContext returns undefined', async () => {
setEnforcement(undefined);
const result = await hook.execute(buildOptions('all'));
expect(result).toEqual({
contextUpdate: { redaction: { version: 1, policy: 'all' } },
});
});
it('defaults to "none" when workflow has no redactionPolicy setting', async () => {
setEnforcement(undefined);
const result = await hook.execute(buildOptions(undefined));
expect(result).toEqual({
contextUpdate: { redaction: { version: 1, policy: 'none' } },
});
});
});
describe('hook metadata', () => {
it('is named RedactionContextHook', () => {
expect(hook.hookDescription.name).toBe('RedactionContextHook');
});
it('reports isApplicableToTriggerNode === false (global, not user-facing)', () => {
expect(hook.isApplicableToTriggerNode('n8n-nodes-base.webhook')).toBe(false);
});
});
});

View File

@ -24,7 +24,15 @@ export class InstanceRedactionEnforcementService {
async get(): Promise<RedactionEnforcementSettings> {
if (!isRedactionEnforcementEnabled()) return REDACTION_ENFORCEMENT_DEFAULTS;
return await this.load();
}
async buildContext(): Promise<{ enforcement: RedactionEnforcementSettings } | undefined> {
if (!isRedactionEnforcementEnabled()) return undefined;
return { enforcement: await this.load() };
}
private async load(): Promise<RedactionEnforcementSettings> {
const raw = await this.cacheService.get<string>(KEY, {
refreshFn: async () => await this.loadFromDatabase(),
});

View File

@ -0,0 +1,66 @@
import type { RedactionEnforcementSettings } from '@n8n/api-types';
import {
ContextEstablishmentHook,
ContextEstablishmentOptions,
ContextEstablishmentResult,
HookDescription,
IContextEstablishmentHook,
} from '@n8n/decorators';
import type { WorkflowSettings } from 'n8n-workflow';
import { InstanceRedactionEnforcementService } from './instance-redaction-enforcement.service';
/**
* | manual | production | policy |
* |--------|------------|---------------|
* | true | true | 'all' |
* | false | true | 'non-manual' |
* | true | false | 'manual-only' |
* | false | false | 'none' |
*/
function deriveEnforcedPolicy(
enforcement: RedactionEnforcementSettings,
): WorkflowSettings.RedactionPolicy {
const { manual, production } = enforcement;
if (manual && production) return 'all';
if (!manual && production) return 'non-manual';
if (manual && !production) return 'manual-only';
return 'none';
}
@ContextEstablishmentHook({
alwaysExecute: true,
})
export class RedactionContextHook implements IContextEstablishmentHook {
constructor(
private readonly instanceRedactionEnforcementService: InstanceRedactionEnforcementService,
) {}
hookDescription: HookDescription = {
name: 'RedactionContextHook',
};
isApplicableToTriggerNode(_nodeType: string): boolean {
// Global hook, never user-facing.
return false;
}
async execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
const context = await this.instanceRedactionEnforcementService.buildContext();
const policy: WorkflowSettings.RedactionPolicy = context?.enforcement.enforced
? deriveEnforcedPolicy(context.enforcement)
: (options.workflow.settings?.redactionPolicy ?? 'none');
return {
contextUpdate: {
redaction: {
version: 1,
policy,
},
},
};
}
}

View File

@ -7,6 +7,7 @@ import { ExecutionRedactionServiceProxy } from '@/executions/execution-redaction
@BackendModule({ name: 'redaction', instanceTypes: ['main'] })
export class RedactionModule implements ModuleInterface {
async init() {
await import('./redaction-context-hook');
const { ExecutionRedactionService } = await import('./executions/execution-redaction.service');
const executionRedactionService = Container.get(ExecutionRedactionService);
await executionRedactionService.init();

View File

@ -187,7 +187,7 @@ describe('establishExecutionContext', () => {
const context = runExecutionData.executionData!.runtimeData;
// Verify context has only basic properties (no start-node-specific extraction)
expect(Object.keys(context!)).toEqual(['version', 'establishedAt', 'source', 'redaction']);
expect(Object.keys(context!)).toEqual(['version', 'establishedAt', 'source']);
expect(typeof context!.version).toBe('number');
expect(typeof context!.establishedAt).toBe('number');
expect(context!.source).toBe('manual');
@ -244,13 +244,7 @@ describe('establishExecutionContext', () => {
const context = runExecutionData.executionData!.runtimeData;
// Verify context has only expected properties
expect(Object.keys(context!)).toEqual([
'version',
'establishedAt',
'source',
'redaction',
'triggerNode',
]);
expect(Object.keys(context!)).toEqual(['version', 'establishedAt', 'source', 'triggerNode']);
expect(typeof context!.version).toBe('number');
expect(typeof context!.establishedAt).toBe('number');
expect(context!.source).toBe('manual');
@ -1043,142 +1037,7 @@ describe('establishExecutionContext', () => {
});
});
describe('redaction policy capture', () => {
it('should default redaction policy to none when workflow has no setting', async () => {
const workflowWithoutRedaction = mock<Workflow>({
id: 'test-workflow-id',
settings: { redactionPolicy: undefined },
});
const startNode = mock<INode>({ name: 'Start', type: 'n8n-nodes-base.manualTrigger' });
const runExecutionData = createRunExecutionData({
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [{ node: startNode, data: { main: [[{ json: {} }]] }, source: null }],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
});
await establishExecutionContext(
workflowWithoutRedaction,
runExecutionData,
mockAdditionalData,
mockMode,
);
const context = runExecutionData.executionData!.runtimeData!;
expect(context.redaction).toEqual({ version: 1, policy: 'none' });
});
it('should capture redaction policy "all" from workflow settings', async () => {
const workflowWithRedaction = mock<Workflow>({
id: 'test-workflow-id',
settings: { redactionPolicy: 'all' },
});
const startNode = mock<INode>({ name: 'Start', type: 'n8n-nodes-base.manualTrigger' });
const runExecutionData = createRunExecutionData({
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [{ node: startNode, data: { main: [[{ json: {} }]] }, source: null }],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
});
await establishExecutionContext(
workflowWithRedaction,
runExecutionData,
mockAdditionalData,
mockMode,
);
const context = runExecutionData.executionData!.runtimeData!;
expect(context.redaction).toEqual({ version: 1, policy: 'all' });
});
it('should capture redaction policy "non-manual" from workflow settings', async () => {
const workflowWithRedaction = mock<Workflow>({
id: 'test-workflow-id',
settings: { redactionPolicy: 'non-manual' },
});
const startNode = mock<INode>({ name: 'Start', type: 'n8n-nodes-base.manualTrigger' });
const runExecutionData = createRunExecutionData({
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [{ node: startNode, data: { main: [[{ json: {} }]] }, source: null }],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
});
await establishExecutionContext(
workflowWithRedaction,
runExecutionData,
mockAdditionalData,
mockMode,
);
const context = runExecutionData.executionData!.runtimeData!;
expect(context.redaction).toEqual({ version: 1, policy: 'non-manual' });
});
it('should use child workflow redaction policy over parent in sub-workflows', async () => {
const parentContext: IExecutionContext = {
version: 1,
establishedAt: 1000000000,
source: 'manual',
credentials: 'parent-credentials',
redaction: { version: 1, policy: 'all' },
};
const childWorkflow = mock<Workflow>({
id: 'child-workflow-id',
settings: { redactionPolicy: 'non-manual' },
});
const parentExecution: RelatedExecution = {
executionId: 'parent-execution-id',
workflowId: 'parent-workflow-id',
executionContext: parentContext,
};
const runExecutionData = createRunExecutionData({
startData: {},
resultData: { runData: {} },
executionData: {
contextData: {},
nodeExecutionStack: [],
metadata: {},
waitingExecution: {},
waitingExecutionSource: {},
},
parentExecution,
});
await establishExecutionContext(
childWorkflow,
runExecutionData,
mockAdditionalData,
'trigger',
);
const context = runExecutionData.executionData!.runtimeData!;
// Child workflow's redaction policy should take precedence
expect(context.redaction).toEqual({ version: 1, policy: 'non-manual' });
// But parent credentials should still be inherited
expect(context.credentials).toBe('parent-credentials');
});
describe('redaction policy on resume', () => {
it('should preserve existing redaction setting on webhook resume', async () => {
const existingContext: IExecutionContext = {
version: 1,
@ -1202,7 +1061,6 @@ describe('establishExecutionContext', () => {
await establishExecutionContext(mockWorkflow, runExecutionData, mockAdditionalData, 'manual');
// Context should remain unchanged
expect(runExecutionData.executionData!.runtimeData!.redaction).toEqual({
version: 1,
policy: 'all',

View File

@ -124,10 +124,6 @@ export const establishExecutionContext = async (
version: 1,
establishedAt: Date.now(),
source: mode,
redaction: {
version: 1,
policy: workflow.settings?.redactionPolicy ?? 'none',
},
};
if (mode === 'manual' && additionalData?.encryptedRunnerIdentity) {