diff --git a/packages/cli/src/modules/redaction/__tests__/redaction-context-hook.integration.test.ts b/packages/cli/src/modules/redaction/__tests__/redaction-context-hook.integration.test.ts new file mode 100644 index 00000000000..04baeb92a2b --- /dev/null +++ b/packages/cli/src/modules/redaction/__tests__/redaction-context-hook.integration.test.ts @@ -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({ id: 'wf-1', settings: { redactionPolicy } }); + + const buildRunExecutionData = () => { + const startNode = mock({ 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({ + webhookWaitingBaseUrl: 'http://localhost:5678/webhook-waiting', + formWaitingBaseUrl: 'http://localhost:5678/form-waiting', + encryptedRunnerIdentity: undefined, + }); + + const passthroughCipher = mock({ + encryptV2: async (data) => (typeof data === 'string' ? data : JSON.stringify(data)), + decryptV2: async (data) => data, + }); + + let enforcementService: ReturnType>; + + beforeEach(() => { + enforcementService = mock(); + + const hook = new RedactionContextHook(enforcementService); + + const hookRegistry = mock(); + 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', + }); + }); +}); diff --git a/packages/cli/src/modules/redaction/__tests__/redaction-context-hook.test.ts b/packages/cli/src/modules/redaction/__tests__/redaction-context-hook.test.ts new file mode 100644 index 00000000000..0bfad240f17 --- /dev/null +++ b/packages/cli/src/modules/redaction/__tests__/redaction-context-hook.test.ts @@ -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; + let hook: RedactionContextHook; + + const buildOptions = ( + workflowRedactionPolicy?: WorkflowSettings.RedactionPolicy, + ): ContextEstablishmentOptions => + mock({ + workflow: mock({ + settings: { redactionPolicy: workflowRedactionPolicy }, + }), + }); + + const setEnforcement = (enforcement: RedactionEnforcementSettings | undefined) => { + service.buildContext.mockResolvedValue(enforcement ? { enforcement } : undefined); + }; + + beforeEach(() => { + service = mock(); + 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); + }); + }); +}); diff --git a/packages/cli/src/modules/redaction/instance-redaction-enforcement.service.ts b/packages/cli/src/modules/redaction/instance-redaction-enforcement.service.ts index 6c3af043d65..d43e7f73bbc 100644 --- a/packages/cli/src/modules/redaction/instance-redaction-enforcement.service.ts +++ b/packages/cli/src/modules/redaction/instance-redaction-enforcement.service.ts @@ -24,7 +24,15 @@ export class InstanceRedactionEnforcementService { async get(): Promise { 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 { const raw = await this.cacheService.get(KEY, { refreshFn: async () => await this.loadFromDatabase(), }); diff --git a/packages/cli/src/modules/redaction/redaction-context-hook.ts b/packages/cli/src/modules/redaction/redaction-context-hook.ts new file mode 100644 index 00000000000..cc68d3e5a11 --- /dev/null +++ b/packages/cli/src/modules/redaction/redaction-context-hook.ts @@ -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 { + 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, + }, + }, + }; + } +} diff --git a/packages/cli/src/modules/redaction/redaction.module.ts b/packages/cli/src/modules/redaction/redaction.module.ts index b4940e345f6..e3054fd1e1f 100644 --- a/packages/cli/src/modules/redaction/redaction.module.ts +++ b/packages/cli/src/modules/redaction/redaction.module.ts @@ -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(); diff --git a/packages/core/src/execution-engine/__tests__/execution-context.test.ts b/packages/core/src/execution-engine/__tests__/execution-context.test.ts index 3561627fafd..ead8b995124 100644 --- a/packages/core/src/execution-engine/__tests__/execution-context.test.ts +++ b/packages/core/src/execution-engine/__tests__/execution-context.test.ts @@ -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({ - id: 'test-workflow-id', - settings: { redactionPolicy: undefined }, - }); - const startNode = mock({ 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({ - id: 'test-workflow-id', - settings: { redactionPolicy: 'all' }, - }); - const startNode = mock({ 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({ - id: 'test-workflow-id', - settings: { redactionPolicy: 'non-manual' }, - }); - const startNode = mock({ 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({ - 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', diff --git a/packages/core/src/execution-engine/execution-context.ts b/packages/core/src/execution-engine/execution-context.ts index 976617e9b86..cdfc74e7a11 100644 --- a/packages/core/src/execution-engine/execution-context.ts +++ b/packages/core/src/execution-engine/execution-context.ts @@ -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) {