mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 08:46:58 +02:00
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:
parent
5099287c5e
commit
d6d0effddc
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
66
packages/cli/src/modules/redaction/redaction-context-hook.ts
Normal file
66
packages/cli/src/modules/redaction/redaction-context-hook.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user