mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-01 15:47:41 +08:00
feat(core): Add globally-registered execution context hooks and inbound-secrets stripper (no-changelog) (#30119)
This commit is contained in:
+17
-1
@@ -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
|
||||
|
||||
+44
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user