feat(core): Add globally-registered execution context hooks and inbound-secrets stripper (no-changelog) (#30119)

This commit is contained in:
Andreas Fitzek
2026-05-18 18:27:30 +02:00
committed by GitHub
parent a0864307af
commit 4e8cb9d475
14 changed files with 786 additions and 38 deletions
@@ -2,6 +2,14 @@ import { Container, Service } from '@n8n/di';
import { ContextEstablishmentHookClass } from './context-establishment-hook';
type ContextEstablishmentHookOptions = {
/**
* If true, the hook executes on every workflow execution regardless of the
* trigger node type (i.e. `isApplicableToTriggerNode` is not consulted).
*/
alwaysExecute: boolean;
};
/**
* Registry entry for a context establishment hook.
*
@@ -14,6 +22,7 @@ import { ContextEstablishmentHookClass } from './context-establishment-hook';
type ContextEstablishmentHookEntry = {
/** The hook class constructor for DI container instantiation */
class: ContextEstablishmentHookClass;
options?: ContextEstablishmentHookOptions;
};
/**
@@ -92,6 +101,12 @@ export class ContextEstablishmentHookMetadata {
getClasses() {
return [...this.contextEstablishmentHooks.values()].map((entry) => entry.class);
}
getGlobalClasses() {
return [...this.contextEstablishmentHooks.values()]
.filter((entry) => entry.options?.alwaysExecute ?? false)
.map((entry) => entry.class);
}
}
/**
@@ -165,11 +180,12 @@ export class ContextEstablishmentHookMetadata {
* @returns A class decorator function that registers and enables DI for the hook
*/
export const ContextEstablishmentHook =
<T extends ContextEstablishmentHookClass>() =>
<T extends ContextEstablishmentHookClass>(options?: ContextEstablishmentHookOptions) =>
(target: T) => {
// Register hook class in metadata for discovery by Hook Registry
Container.get(ContextEstablishmentHookMetadata).register({
class: target,
options,
});
// Enable dependency injection for the hook class
@@ -0,0 +1,44 @@
import type { ContextEstablishmentOptions } from '@n8n/decorators';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { INode, INodeExecutionData, Workflow } from 'n8n-workflow';
import { InboundSecretContextHook } from '../inbound-secrets-context-hook';
import type { InboundSecretsService } from '../inbound-secrets.service';
describe('InboundSecretContextHook', () => {
let service: MockProxy<InboundSecretsService>;
let hook: InboundSecretContextHook;
const optionsWith = (triggerItems: INodeExecutionData[] | null): ContextEstablishmentOptions => ({
triggerNode: { type: 'n8n-nodes-base.webhook' } as INode,
workflow: mock<Workflow>(),
triggerItems,
context: mock(),
options: {},
});
beforeEach(() => {
service = mock<InboundSecretsService>();
hook = new InboundSecretContextHook(service);
});
it('delegates to service.strip and wraps the result as triggerItems', async () => {
const input: INodeExecutionData[] = [{ json: { headers: { authorization: 'x' } } }];
const stripped: INodeExecutionData[] = [{ json: { headers: { authorization: undefined } } }];
service.strip.mockReturnValue(stripped);
const result = await hook.execute(optionsWith(input));
expect(service.strip).toHaveBeenCalledWith(input, 'n8n-nodes-base.webhook');
expect(result).toEqual({ triggerItems: stripped });
});
it('passes an empty array to service.strip when triggerItems is null', async () => {
service.strip.mockImplementation((items) => items);
await hook.execute(optionsWith(null));
expect(service.strip).toHaveBeenCalledWith([], 'n8n-nodes-base.webhook');
});
});
@@ -0,0 +1,178 @@
import { mockLogger } from '@n8n/backend-test-utils';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import type { IDataObject, INodeExecutionData } from 'n8n-workflow';
import type { InboundSecretsConfig } from '../inbound-secrets.config';
import { InboundSecretsService } from '../inbound-secrets.service';
const item = (json: IDataObject): INodeExecutionData => ({ json });
describe('InboundSecretsService', () => {
let service: InboundSecretsService;
let config: MockProxy<InboundSecretsConfig>;
beforeEach(() => {
config = mock<InboundSecretsConfig>();
service = new InboundSecretsService(mockLogger(), config);
});
describe('init', () => {
it.each([
['default empty object', '{}'],
['valid universal rule', '{"*":["headers.authorization"]}'],
['valid type-specific rule', '{"n8n-nodes-base.webhook":["body.token"]}'],
[
'valid combined rules',
'{"*":["headers.authorization"],"n8n-nodes-base.formTrigger":["body.password"]}',
],
])('accepts %s', (_name, raw) => {
config.sensitiveFieldRules = raw;
expect(() => service.init()).not.toThrow();
});
it.each([
['malformed JSON', 'not json at all'],
['valid JSON but not an object', '"a string"'],
['valid JSON but value is not an array', '{"*":"not-an-array"}'],
['valid JSON but path entry is not a string', '{"*":[123]}'],
['empty key', '{"":["x"]}'],
['empty path string', '{"*":[""]}'],
])('throws on %s', (_name, raw) => {
config.sensitiveFieldRules = raw;
expect(() => service.init()).toThrow(/N8N_SECURITY_SENSITIVE_FIELD_RULES/);
});
});
describe('strip', () => {
const initWith = (rules: Record<string, string[]>) => {
config.sensitiveFieldRules = JSON.stringify(rules);
service.init();
};
it('is a no-op when no rules are configured', () => {
initWith({});
const items = [item({ headers: { authorization: 'x' } })];
const result = service.strip(items, 'n8n-nodes-base.webhook');
expect(result).toBe(items);
expect(items[0].json).toEqual({ headers: { authorization: 'x' } });
});
it('applies universal `*` rule across any trigger type and leaves siblings untouched', () => {
initWith({ '*': ['headers.authorization'] });
const items = [item({ headers: { authorization: 'secret', other: 'keep-me' } })];
service.strip(items, 'n8n-nodes-base.webhook');
expect(items[0].json).toEqual({
headers: { authorization: undefined, other: 'keep-me' },
});
});
it('applies type-specific rules only when the trigger type matches', () => {
initWith({ 'n8n-nodes-base.formTrigger': ['body.password'] });
const items = [item({ body: { password: 'p' } })];
service.strip(items, 'n8n-nodes-base.formTrigger');
expect(items[0].json).toEqual({ body: { password: undefined } });
});
it('does not apply type-specific rules to other trigger types', () => {
initWith({ 'n8n-nodes-base.formTrigger': ['body.password'] });
const items = [item({ body: { password: 'p' } })];
service.strip(items, 'n8n-nodes-base.webhook');
expect(items[0].json).toEqual({ body: { password: 'p' } });
});
it('unions universal and type-specific rules when both match', () => {
initWith({
'*': ['headers.authorization'],
'n8n-nodes-base.formTrigger': ['body.password'],
});
const items = [
item({
headers: { authorization: 'a' },
body: { password: 'p' },
}),
];
service.strip(items, 'n8n-nodes-base.formTrigger');
expect(items[0].json).toEqual({
headers: { authorization: undefined },
body: { password: undefined },
});
});
it('only applies the universal rule when the type-specific rule does not match', () => {
initWith({
'*': ['headers.authorization'],
'n8n-nodes-base.formTrigger': ['body.password'],
});
const items = [
item({
headers: { authorization: 'a' },
body: { password: 'p' },
}),
];
service.strip(items, 'n8n-nodes-base.webhook');
expect(items[0].json).toEqual({
headers: { authorization: undefined },
body: { password: 'p' },
});
});
it('applies rules independently to every item in the batch', () => {
initWith({ '*': ['headers.authorization'] });
const items = [
item({ headers: { authorization: '1' } }),
item({ headers: { authorization: '2' } }),
item({ headers: { authorization: '3' } }),
];
service.strip(items, 'n8n-nodes-base.webhook');
for (const i of items) {
expect(i.json).toEqual({ headers: { authorization: undefined } });
}
});
it('applies multiple paths from the same rule', () => {
initWith({ '*': ['headers.authorization', 'headers.cookie'] });
const items = [item({ headers: { authorization: 'a', cookie: 'c', other: 'k' } })];
service.strip(items, 'n8n-nodes-base.webhook');
expect(items[0].json).toEqual({
headers: { authorization: undefined, cookie: undefined, other: 'k' },
});
});
it('returns the same array reference and mutates items in place', () => {
initWith({ '*': ['headers.authorization'] });
const items = [item({ headers: { authorization: 'x' } })];
const json = items[0].json;
const result = service.strip(items, 'n8n-nodes-base.webhook');
expect(result).toBe(items);
expect(result[0].json).toBe(json);
});
it('silently leaves items untouched when no path in the rule set matches', () => {
initWith({ '*': ['headers.authorization'] });
const items = [item({ body: { foo: 'bar' } })];
service.strip(items, 'n8n-nodes-base.webhook');
expect(items[0].json).toEqual({ body: { foo: 'bar' } });
});
});
});
@@ -0,0 +1,156 @@
import type { IDataObject } from 'n8n-workflow';
import { extractAndClear } from '../path-traversal';
type Case = {
name: string;
input: IDataObject;
path: string;
expectedReturn: IDataObject[string] | undefined;
expectedAfter: IDataObject;
};
const cases: Case[] = [
// ── Simple dot path ──
{
name: 'single segment extracts top-level leaf',
input: { a: 1, b: 2 },
path: 'a',
expectedReturn: 1,
expectedAfter: { a: undefined, b: 2 },
},
{
name: 'two-segment path extracts nested leaf',
input: { a: { b: 'x' }, c: 9 },
path: 'a.b',
expectedReturn: 'x',
expectedAfter: { a: { b: undefined }, c: 9 },
},
// ── Array wildcard [*] ──
{
name: '[*] last segment over multi-element array returns array of values',
input: { tags: ['x', 'y', 'z'] },
path: 'tags[*]',
expectedReturn: ['x', 'y', 'z'],
expectedAfter: { tags: [undefined, undefined, undefined] },
},
{
name: '[*] mid-path collects leaf from each element',
input: { items: [{ v: 1 }, { v: 2 }, { v: 3 }] },
path: 'items[*].v',
expectedReturn: [1, 2, 3],
expectedAfter: { items: [{ v: undefined }, { v: undefined }, { v: undefined }] },
},
{
name: '[*] over single-element array returns bare value (unwrapped)',
input: { items: [{ v: 42 }] },
path: 'items[*].v',
expectedReturn: 42,
expectedAfter: { items: [{ v: undefined }] },
},
{
name: '[*] over empty array returns undefined (zero matches)',
input: { items: [] },
path: 'items[*].v',
expectedReturn: undefined,
expectedAfter: { items: [] },
},
{
name: 'nested [*] segments collect all leaves depth-first',
input: { groups: [{ items: [{ v: 1 }, { v: 2 }] }, { items: [{ v: 3 }] }] },
path: 'groups[*].items[*].v',
expectedReturn: [1, 2, 3],
expectedAfter: {
groups: [{ items: [{ v: undefined }, { v: undefined }] }, { items: [{ v: undefined }] }],
},
},
{
name: 'partial match in [*] branch — only present leaves collected',
input: { items: [{ v: 1 }, { other: 2 }, { v: 3 }] },
path: 'items[*].v',
expectedReturn: [1, 3],
expectedAfter: { items: [{ v: undefined }, { other: 2 }, { v: undefined }] },
},
// ── No-match / silent failures ──
{
name: 'missing intermediate key is silent no-op',
input: { a: { b: 1 } },
path: 'a.x.y',
expectedReturn: undefined,
expectedAfter: { a: { b: 1 } },
},
{
name: '[*] on non-array value is silent no-op',
input: { tags: 'not-an-array' },
path: 'tags[*]',
expectedReturn: undefined,
expectedAfter: { tags: 'not-an-array' },
},
{
name: 'descending through a primitive is silent no-op',
input: { a: 5 },
path: 'a.b',
expectedReturn: undefined,
expectedAfter: { a: 5 },
},
{
name: 'descending through null is silent no-op',
input: { a: null },
path: 'a.b',
expectedReturn: undefined,
expectedAfter: { a: null },
},
// ── Mutation correctness / leaf shapes ──
{
name: 'sibling keys untouched after extraction',
input: { a: { b: 1, keep: 'me' }, other: [1, 2] },
path: 'a.b',
expectedReturn: 1,
expectedAfter: { a: { b: undefined, keep: 'me' }, other: [1, 2] },
},
{
name: 'leaf value that is null is returned as null',
input: { a: null },
path: 'a',
expectedReturn: null,
expectedAfter: { a: undefined },
},
{
name: 'leaf value that is an object is returned by reference',
input: { a: { nested: { deep: 1 } } },
path: 'a',
expectedReturn: { nested: { deep: 1 } },
expectedAfter: { a: undefined },
},
{
name: 'leaf value that is an array is returned as-is (not iterated)',
input: { a: [1, 2, 3] },
path: 'a',
expectedReturn: [1, 2, 3],
expectedAfter: { a: undefined },
},
];
describe('extractAndClear', () => {
it.each(cases)('$name', ({ input, path, expectedReturn, expectedAfter }) => {
const result = extractAndClear(input, path);
expect(result).toEqual(expectedReturn);
expect(input).toEqual(expectedAfter);
});
it('preserves the matched key (assignment, not delete)', () => {
const obj: IDataObject = { a: 'gone' };
extractAndClear(obj, 'a');
expect('a' in obj).toBe(true);
expect(obj.a).toBeUndefined();
});
it('preserves array length when clearing elements via [*]', () => {
const obj: IDataObject = { tags: ['x', 'y', 'z'] };
extractAndClear(obj, 'tags[*]');
expect((obj.tags as unknown[]).length).toBe(3);
});
});
@@ -0,0 +1,33 @@
import {
ContextEstablishmentHook,
ContextEstablishmentOptions,
ContextEstablishmentResult,
HookDescription,
IContextEstablishmentHook,
} from '@n8n/decorators';
import { InboundSecretsService } from './inbound-secrets.service';
@ContextEstablishmentHook({
alwaysExecute: true,
})
export class InboundSecretContextHook implements IContextEstablishmentHook {
constructor(private readonly inboundSecretsService: InboundSecretsService) {}
hookDescription: HookDescription = {
name: 'InboundSecretContextHook',
};
isApplicableToTriggerNode(_nodeType: string): boolean {
// This hook is never visible in the UI.
return false;
}
async execute(options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
const items = options.triggerItems ?? [];
const clearedItems = this.inboundSecretsService.strip(items, options.triggerNode.type);
return {
triggerItems: clearedItems,
};
}
}
@@ -1,4 +1,7 @@
import { Config } from '@n8n/config';
import { Config, Env } from '@n8n/config';
@Config
export class InboundSecretsConfig {}
export class InboundSecretsConfig {
@Env('N8N_SECURITY_SENSITIVE_FIELD_RULES')
sensitiveFieldRules: string = '{}';
}
@@ -1,5 +1,6 @@
import type { ModuleInterface } from '@n8n/decorators';
import { BackendModule } from '@n8n/decorators';
import { Container } from '@n8n/di';
function isFeatureFlagEnabled(): boolean {
return process.env.N8N_ENV_FEAT_INBOUND_SECRETS === 'true';
@@ -10,6 +11,9 @@ export class InboundSecretsModule implements ModuleInterface {
async init() {
if (!isFeatureFlagEnabled()) return;
await import('./inbound-secrets.config');
const { InboundSecretsService } = await import('./inbound-secrets.service');
Container.get(InboundSecretsService).init();
await import('./inbound-secrets-context-hook');
}
}
@@ -0,0 +1,19 @@
import { z } from 'zod';
/**
* Schema for `N8N_SECURITY_SENSITIVE_FIELD_RULES` (or its `_FILE` variant).
*
* Flat object keyed by node-type identifier. The `*` key applies to every
* trigger type and is unioned with any type-specific entry. Each value is a
* list of dot-paths into the trigger item's `.json` that must be stripped
* before any node consumes the trigger output.
*
* @example
* {
* "*": ["headers.authorization", "headers.cookie"],
* "n8n-nodes-base.formTrigger": ["body.password"]
* }
*/
export const sensitiveFieldRulesSchema = z.record(z.string().min(1), z.array(z.string().min(1)));
export type SensitiveFieldRules = z.infer<typeof sensitiveFieldRulesSchema>;
@@ -0,0 +1,52 @@
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { INodeExecutionData, jsonParse } from 'n8n-workflow';
import { InboundSecretsConfig } from './inbound-secrets.config';
import { SensitiveFieldRules, sensitiveFieldRulesSchema } from './inbound-secrets.schemas';
import { extractAndClear } from './path-traversal';
@Service()
export class InboundSecretsService {
private sensitiveFieldRules: SensitiveFieldRules = {};
constructor(
private readonly logger: Logger,
private readonly config: InboundSecretsConfig,
) {}
init() {
const parsedSetting = jsonParse(this.config.sensitiveFieldRules, {
errorMessage: "Failed to json parse configuration rules 'N8N_SECURITY_SENSITIVE_FIELD_RULES'",
});
const parsedRules = sensitiveFieldRulesSchema.safeParse(parsedSetting);
if (!parsedRules.success) {
this.logger.error(
"Failed to validate configuration rules 'N8N_SECURITY_SENSITIVE_FIELD_RULES'",
{
error: parsedRules.error,
},
);
throw new Error(
"Failed to validate configuration rules 'N8N_SECURITY_SENSITIVE_FIELD_RULES'",
);
}
this.sensitiveFieldRules = parsedRules.data;
}
strip(items: INodeExecutionData[], triggerNodeType: string): INodeExecutionData[] {
const wildcardPaths = this.sensitiveFieldRules['*'] ?? [];
const typePaths = this.sensitiveFieldRules[triggerNodeType] ?? [];
const paths = [...wildcardPaths, ...typePaths];
if (paths.length === 0) return items;
for (const item of items) {
for (const path of paths) {
extractAndClear(item.json, path);
}
}
return items;
}
}
@@ -0,0 +1,66 @@
import type { IDataObject } from 'n8n-workflow';
type DataValue = IDataObject[string];
/**
* Walk `path` through `obj`, replace each matched leaf with `undefined`,
* and return the collected leaf value(s).
*
* Path syntax:
* - Dot notation for object navigation: `a.b.c`
* - `[*]` suffix on a key iterates over each array element: `tags[*]`, `headers.auth[*].value`
*
* Return shape:
* - 0 matches → undefined
* - 1 match → the bare value (unwrapped)
* - 2+ matches → array in encounter order
*
* Mutation:
* - Each matched leaf is replaced with `undefined` in `obj` (key preserved).
* - Missing intermediates / type mismatches are silent: no mutation, no value collected for that branch.
*/
export function extractAndClear(obj: IDataObject, path: string): DataValue | undefined {
const segments = path.split('.');
const collected: DataValue[] = [];
walk(obj, segments, 0, collected);
if (collected.length === 0) return undefined;
if (collected.length === 1) return collected[0];
return collected;
}
function isIDataObject(v: DataValue): v is IDataObject {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
function walk(current: DataValue, segments: string[], index: number, collected: DataValue[]): void {
if (!isIDataObject(current)) return;
const segment = segments[index];
const isWildcard = segment.endsWith('[*]');
const key = isWildcard ? segment.slice(0, -3) : segment;
const isLast = index === segments.length - 1;
if (isWildcard) {
const arr = current[key];
if (!Array.isArray(arr)) return;
// Cast once: Array.isArray narrows to any[], so subsequent reads/writes need a typed view.
// `undefined` writes are legal against IDataObject[] because GenericValue includes undefined.
const typedArr = arr as DataValue[];
if (isLast) {
for (let i = 0; i < typedArr.length; i++) {
collected.push(typedArr[i]);
typedArr[i] = undefined;
}
} else {
for (const element of typedArr) walk(element, segments, index + 1, collected);
}
return;
}
if (!(key in current)) return;
if (isLast) {
collected.push(current[key]);
current[key] = undefined;
} else {
walk(current[key], segments, index + 1, collected);
}
}
@@ -418,4 +418,49 @@ describe('ExecutionContextHookRegistry', () => {
expect(hooks).toEqual([]);
});
});
describe('global hooks', () => {
it('exposes hooks decorated with alwaysExecute: true via getGlobalHooks()', async () => {
@ContextEstablishmentHook({ alwaysExecute: true })
class GlobalHook implements IContextEstablishmentHook {
hookDescription = { name: 'test.global' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return false;
}
}
await registry.init();
const globals = registry.getGlobalHooks();
expect(globals).toHaveLength(1);
expect(globals[0]).toBeInstanceOf(GlobalHook);
});
it('does not expose non-global hooks via getGlobalHooks(), but they remain retrievable by name', async () => {
@ContextEstablishmentHook()
class PerNodeHook implements IContextEstablishmentHook {
hookDescription = { name: 'test.per-node' };
async execute(_options: ContextEstablishmentOptions): Promise<ContextEstablishmentResult> {
return {};
}
isApplicableToTriggerNode(_nodeType: string): boolean {
return true;
}
}
await registry.init();
expect(registry.getGlobalHooks()).toEqual([]);
expect(registry.getHookByName('test.per-node')).toBeInstanceOf(PerNodeHook);
});
it('returns an empty array when no hooks are registered at all', async () => {
await registry.init();
expect(registry.getGlobalHooks()).toEqual([]);
});
});
});
@@ -66,6 +66,7 @@ describe('ExecutionContextService', () => {
mockRegistry = {
getHookByName: jest.fn(),
getGlobalHooks: jest.fn().mockReturnValue([]),
} as unknown as jest.Mocked<ExecutionContextHookRegistry>;
mockCipher = {
@@ -803,5 +804,113 @@ describe('ExecutionContextService', () => {
// Verify encryptV2 was called for return
expect(mockCipher.encryptV2).toHaveBeenCalled();
});
describe('global hooks', () => {
const baseContext = (): IExecutionContext => ({
version: 1,
establishedAt: Date.now(),
source: 'manual',
});
it('runs global hooks even when no per-node hook parameters are configured', async () => {
const startItem = createMockStartItem();
const context = baseContext();
const mockGlobalHook = mock<IContextEstablishmentHook>();
mockGlobalHook.execute.mockResolvedValue({});
mockRegistry.getGlobalHooks.mockReturnValue([mockGlobalHook]);
toExecutionContextEstablishmentHookParameter.mockReturnValue(null);
await service.augmentExecutionContextWithHooks(mockWorkflow, startItem, context);
expect(mockGlobalHook.execute).toHaveBeenCalledWith({
triggerNode: startItem.node,
workflow: mockWorkflow,
triggerItems: startItem.data.main[0],
context: expect.objectContaining({ version: 1, source: 'manual' }),
options: {},
});
});
it('propagates global hook triggerItems mutation to the returned result', async () => {
const startItem = createMockStartItem();
const stripped: INodeExecutionData[] = [{ json: { stripped: true } }];
const mockGlobalHook = mock<IContextEstablishmentHook>();
mockGlobalHook.execute.mockResolvedValue({ triggerItems: stripped });
mockRegistry.getGlobalHooks.mockReturnValue([mockGlobalHook]);
toExecutionContextEstablishmentHookParameter.mockReturnValue(null);
const result = await service.augmentExecutionContextWithHooks(
mockWorkflow,
startItem,
baseContext(),
);
expect(result.triggerItems).toEqual(stripped);
});
it('merges global hook contextUpdate into the returned context', async () => {
const startItem = createMockStartItem();
const mockGlobalHook = mock<IContextEstablishmentHook>();
mockGlobalHook.execute.mockResolvedValue({ contextUpdate: { source: 'webhook' } });
mockRegistry.getGlobalHooks.mockReturnValue([mockGlobalHook]);
toExecutionContextEstablishmentHookParameter.mockReturnValue(null);
const result = await service.augmentExecutionContextWithHooks(
mockWorkflow,
startItem,
baseContext(),
);
expect(result.context).toEqual(expect.objectContaining({ source: 'webhook' }));
});
it('chains multiple global hooks: second hook receives first hook output', async () => {
const startItem = createMockStartItem();
const fromA: INodeExecutionData[] = [{ json: { fromA: true } }];
const hookA = mock<IContextEstablishmentHook>();
hookA.execute.mockResolvedValue({ triggerItems: fromA });
const hookB = mock<IContextEstablishmentHook>();
hookB.execute.mockResolvedValue({});
mockRegistry.getGlobalHooks.mockReturnValue([hookA, hookB]);
toExecutionContextEstablishmentHookParameter.mockReturnValue(null);
await service.augmentExecutionContextWithHooks(mockWorkflow, startItem, baseContext());
expect(hookB.execute).toHaveBeenCalledWith(
expect.objectContaining({ triggerItems: fromA }),
);
});
it('leaves trigger items unchanged when no globals and no per-node hooks are registered', async () => {
const startItem = createMockStartItem();
toExecutionContextEstablishmentHookParameter.mockReturnValue(null);
const result = await service.augmentExecutionContextWithHooks(
mockWorkflow,
startItem,
baseContext(),
);
expect(result.triggerItems).toEqual(startItem.data.main[0]);
});
it('propagates global hook exceptions without swallowing', async () => {
const startItem = createMockStartItem();
const mockGlobalHook = mock<IContextEstablishmentHook>();
mockGlobalHook.execute.mockRejectedValue(new Error('boom'));
mockRegistry.getGlobalHooks.mockReturnValue([mockGlobalHook]);
toExecutionContextEstablishmentHookParameter.mockReturnValue(null);
await expect(
service.augmentExecutionContextWithHooks(mockWorkflow, startItem, baseContext()),
).rejects.toThrow('boom');
});
});
});
});
@@ -12,6 +12,7 @@ import { Container, Service } from '@n8n/di';
@Service()
export class ExecutionContextHookRegistry {
private hookMap: Map<string, IContextEstablishmentHook> = new Map();
private globalHook: Set<IContextEstablishmentHook> = new Set();
constructor(
private readonly executionContextHookMetadata: ContextEstablishmentHookMetadata,
@@ -31,17 +32,19 @@ export class ExecutionContextHookRegistry {
*/
async init() {
this.hookMap.clear();
this.globalHook.clear();
const hookClasses = this.executionContextHookMetadata.getClasses();
const globalHookClasses = this.executionContextHookMetadata.getGlobalClasses();
this.logger.debug(`Registering ${hookClasses.length} execution context hooks.`);
for (const HookClass of hookClasses) {
for (const hookClass of hookClasses) {
let hook: IContextEstablishmentHook;
try {
hook = Container.get(HookClass);
hook = Container.get(hookClass);
} catch (error) {
this.logger.error(
`Failed to instantiate execution context hook class "${HookClass.name}": ${(error as Error).message}`,
`Failed to instantiate execution context hook class "${hookClass.name}": ${(error as Error).message}`,
{ error },
);
continue;
@@ -49,7 +52,7 @@ export class ExecutionContextHookRegistry {
if (this.hookMap.has(hook.hookDescription.name)) {
this.logger.warn(
`Execution context hook with name "${hook.hookDescription.name}" is already registered. Conflicting classes are "${this.hookMap.get(hook.hookDescription.name)?.constructor.name}" and "${HookClass.name}". Skipping the latter.`,
`Execution context hook with name "${hook.hookDescription.name}" is already registered. Conflicting classes are "${this.hookMap.get(hook.hookDescription.name)?.constructor.name}" and "${hookClass.name}". Skipping the latter.`,
);
continue;
}
@@ -65,6 +68,9 @@ export class ExecutionContextHookRegistry {
}
}
this.hookMap.set(hook.hookDescription.name, hook);
if (globalHookClasses.includes(hookClass)) {
this.globalHook.add(hook);
}
}
}
@@ -101,4 +107,8 @@ export class ExecutionContextHookRegistry {
return hook.isApplicableToTriggerNode(triggerType);
});
}
getGlobalHooks(): IContextEstablishmentHook[] {
return Array.from(this.globalHook);
}
}
@@ -4,7 +4,6 @@ import {
IExecuteData,
IExecutionContext,
INodeExecutionData,
ISecureArtifacts,
PlaintextExecutionContext,
toCredentialContext,
toExecutionContextEstablishmentHookParameter,
@@ -26,37 +25,29 @@ export class ExecutionContextService {
) {}
async decryptExecutionContext(context: IExecutionContext): Promise<PlaintextExecutionContext> {
let credentials = undefined;
if (context.credentials) {
const decrypted = await this.cipher.decryptV2(context.credentials);
credentials = toCredentialContext(decrypted);
const { credentials: encCredentials, secureArtifacts: encSecureArtifacts, ...rest } = context;
const result: PlaintextExecutionContext = { ...rest };
if (encCredentials) {
const decrypted = await this.cipher.decryptV2(encCredentials);
result.credentials = toCredentialContext(decrypted);
}
let secureArtifacts: ISecureArtifacts | undefined = undefined;
if (context.secureArtifacts) {
const decrypted = await this.cipher.decryptV2(context.secureArtifacts);
secureArtifacts = toSecureArtifacts(decrypted);
if (encSecureArtifacts) {
const decrypted = await this.cipher.decryptV2(encSecureArtifacts);
result.secureArtifacts = toSecureArtifacts(decrypted);
}
return {
...context,
credentials,
secureArtifacts,
};
return result;
}
async encryptExecutionContext(context: PlaintextExecutionContext): Promise<IExecutionContext> {
let credentials = undefined;
if (context.credentials) {
credentials = await this.cipher.encryptV2(context.credentials);
const { credentials, secureArtifacts, ...rest } = context;
const result: IExecutionContext = { ...rest };
if (credentials) {
result.credentials = await this.cipher.encryptV2(credentials);
}
let secureArtifacts = undefined;
if (context.secureArtifacts) {
secureArtifacts = await this.cipher.encryptV2(context.secureArtifacts);
if (secureArtifacts) {
result.secureArtifacts = await this.cipher.encryptV2(secureArtifacts);
}
return {
...context,
credentials,
secureArtifacts,
};
return result;
}
mergeExecutionContexts(
@@ -86,6 +77,31 @@ export class ExecutionContextService {
...startItem.node.parameters,
};
// decrypt the context to work with plaintext data
let context = await this.decryptExecutionContext(contextToAugment);
// Run global hooks!
for (const globalHook of this.executionContextHookRegistry.getGlobalHooks()) {
// call the hook to let it modify the context and/or the main input data
const result = await globalHook.execute({
triggerNode: startItem.node,
workflow,
triggerItems: currentTriggerItems,
context,
options: {},
});
if (result.triggerItems !== undefined) {
// Update trigger items in case they were modified by the hook
currentTriggerItems = result.triggerItems;
}
if (result.contextUpdate) {
// Merge any returned context fields into the execution context
context = this.mergeExecutionContexts(context, result.contextUpdate);
}
}
const startNodeParametersResult = toExecutionContextEstablishmentHookParameter(
contextEstablishmentHookParameters,
);
@@ -96,9 +112,9 @@ export class ExecutionContextService {
`Failed to parse execution context establishment hook parameters for node ${startItem.node.name}: ${startNodeParametersResult.error.message}`,
);
}
// no execution establishment hooks found, we just return the original context
// no node specific execution establishment hooks found, we return early
return {
context: contextToAugment,
context: await this.encryptExecutionContext(context),
triggerItems: currentTriggerItems,
};
}
@@ -108,9 +124,6 @@ export class ExecutionContextService {
// for example to extract the bearer token from the start node data.
const startNodeParameters = startNodeParametersResult.data;
// decrypt the context to work with plaintext data
let context = await this.decryptExecutionContext(contextToAugment);
// based on startNodeParameters, startNodeType and currentTriggerItems we can now
// iterate over the different hooks to extract specific data for the runtime context
for (const hookParameters of startNodeParameters.contextEstablishmentHooks.hooks) {