feat(core): Add encrypted secureArtifacts slot to IExecutionContext (no-changelog) (#30125)

This commit is contained in:
Andreas Fitzek 2026-05-12 11:58:45 +02:00 committed by GitHub
parent 744bb92c2f
commit d2e5db258c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 278 additions and 2 deletions

View File

@ -6,6 +6,7 @@ import type {
IExecutionContext,
INode,
INodeExecutionData,
ISecureArtifacts,
PlaintextExecutionContext,
Workflow,
} from 'n8n-workflow';
@ -19,12 +20,33 @@ import { ExecutionContextService } from '../execution-context.service';
jest.mock('n8n-workflow', () => ({
...jest.requireActual('n8n-workflow'),
toCredentialContext: jest.fn((data: string) => JSON.parse(data)),
toSecureArtifacts: jest.fn((data: string) => JSON.parse(data)),
toExecutionContextEstablishmentHookParameter: jest.fn(),
}));
const { toCredentialContext, toExecutionContextEstablishmentHookParameter } =
const { toCredentialContext, toSecureArtifacts, toExecutionContextEstablishmentHookParameter } =
jest.requireMock('n8n-workflow');
const sampleArtifacts: ISecureArtifacts = {
version: 1,
artifacts: {
Webhook: [
{
'headers.authorization': 'Bearer A',
'body.count': 42,
'body.flag': true,
'body.maybe': null,
},
{
'headers.authorization': 'Bearer B',
'body.nested': { id: 'x', tags: ['a', 'b'] },
},
],
OtherTrigger: [{ 'a.b': ['v1', 'v2'] }],
},
metadata: { source: 'stripper' },
};
describe('ExecutionContextService', () => {
let service: ExecutionContextService;
let mockLogger: jest.Mocked<Logger>;
@ -97,6 +119,79 @@ describe('ExecutionContextService', () => {
credentials: parsedCreds,
});
});
it('should leave secureArtifacts undefined when not present', async () => {
const context: IExecutionContext = {
version: 1,
establishedAt: Date.now(),
source: 'manual',
};
const result = await service.decryptExecutionContext(context);
expect(result.secureArtifacts).toBeUndefined();
expect(mockCipher.decryptV2).not.toHaveBeenCalled();
expect(toSecureArtifacts).not.toHaveBeenCalled();
});
it('should decrypt secureArtifacts when present', async () => {
const encryptedArtifacts = 'encrypted_artifacts';
const decryptedArtifacts = JSON.stringify(sampleArtifacts);
const context: IExecutionContext = {
version: 1,
establishedAt: Date.now(),
source: 'webhook',
secureArtifacts: encryptedArtifacts,
};
mockCipher.decryptV2.mockResolvedValue(decryptedArtifacts);
toSecureArtifacts.mockReturnValue(sampleArtifacts);
const result = await service.decryptExecutionContext(context);
expect(mockCipher.decryptV2).toHaveBeenCalledWith(encryptedArtifacts);
expect(toSecureArtifacts).toHaveBeenCalledWith(decryptedArtifacts);
expect(result).toEqual({
...context,
credentials: undefined,
secureArtifacts: sampleArtifacts,
});
});
it('should decrypt both credentials and secureArtifacts when both present', async () => {
const encryptedCreds = 'encrypted_creds';
const encryptedArtifacts = 'encrypted_artifacts';
const decryptedCreds = '{"version":1,"identity":"token"}';
const parsedCreds = { version: 1, identity: 'token' };
const decryptedArtifacts = JSON.stringify(sampleArtifacts);
const context: IExecutionContext = {
version: 1,
establishedAt: Date.now(),
source: 'webhook',
credentials: encryptedCreds,
secureArtifacts: encryptedArtifacts,
};
mockCipher.decryptV2.mockImplementation(async (data: string) => {
if (data === encryptedCreds) return decryptedCreds;
if (data === encryptedArtifacts) return decryptedArtifacts;
throw new Error(`Unexpected ciphertext: ${data}`);
});
toCredentialContext.mockReturnValue(parsedCreds);
toSecureArtifacts.mockReturnValue(sampleArtifacts);
const result = await service.decryptExecutionContext(context);
expect(mockCipher.decryptV2).toHaveBeenCalledWith(encryptedCreds);
expect(mockCipher.decryptV2).toHaveBeenCalledWith(encryptedArtifacts);
expect(result).toEqual({
...context,
credentials: parsedCreds,
secureArtifacts: sampleArtifacts,
});
});
});
describe('encryptExecutionContext()', () => {
@ -137,6 +232,95 @@ describe('ExecutionContextService', () => {
credentials: encryptedCreds,
});
});
it('should leave secureArtifacts undefined when not present', async () => {
const context: PlaintextExecutionContext = {
version: 1,
establishedAt: Date.now(),
source: 'manual',
};
const result = await service.encryptExecutionContext(context);
expect(result.secureArtifacts).toBeUndefined();
expect(mockCipher.encryptV2).not.toHaveBeenCalled();
});
it('should encrypt secureArtifacts when present', async () => {
const encryptedArtifacts = 'encrypted_artifacts';
const context: PlaintextExecutionContext = {
version: 1,
establishedAt: Date.now(),
source: 'webhook',
secureArtifacts: sampleArtifacts,
};
mockCipher.encryptV2.mockResolvedValue(encryptedArtifacts);
const result = await service.encryptExecutionContext(context);
expect(mockCipher.encryptV2).toHaveBeenCalledWith(sampleArtifacts);
expect(result).toEqual({
...context,
credentials: undefined,
secureArtifacts: encryptedArtifacts,
});
});
it('should encrypt both credentials and secureArtifacts when both present', async () => {
const plaintextCreds = { version: 1 as const, identity: 'token' };
const encryptedCreds = 'encrypted_creds';
const encryptedArtifacts = 'encrypted_artifacts';
const context: PlaintextExecutionContext = {
version: 1,
establishedAt: Date.now(),
source: 'webhook',
credentials: plaintextCreds,
secureArtifacts: sampleArtifacts,
};
mockCipher.encryptV2.mockImplementation(async (data: unknown) => {
if (data === plaintextCreds) return encryptedCreds;
if (data === sampleArtifacts) return encryptedArtifacts;
throw new Error('Unexpected encryption input');
});
const result = await service.encryptExecutionContext(context);
expect(result).toEqual({
...context,
credentials: encryptedCreds,
secureArtifacts: encryptedArtifacts,
});
});
});
describe('encrypt → decrypt round-trip', () => {
it('should preserve secureArtifacts through a full round-trip', async () => {
// JSON-stringify on encrypt, identity on decrypt — simulates a symmetric cipher
// on the JSON serialization that Cipher.encryptV2/decryptV2 perform around the payload.
mockCipher.encryptV2.mockImplementation(async (data: unknown) => JSON.stringify(data));
mockCipher.decryptV2.mockImplementation(async (data: string) => data);
// Use the real toSecureArtifacts so the round-trip exercises actual schema parsing.
const realToSecureArtifacts = jest.requireActual('n8n-workflow').toSecureArtifacts;
toSecureArtifacts.mockImplementation(realToSecureArtifacts);
const plaintext: PlaintextExecutionContext = {
version: 1,
establishedAt: 12345,
source: 'webhook',
secureArtifacts: sampleArtifacts,
};
const encrypted = await service.encryptExecutionContext(plaintext);
expect(typeof encrypted.secureArtifacts).toBe('string');
const decrypted = await service.decryptExecutionContext(encrypted);
expect(decrypted.secureArtifacts).toEqual(sampleArtifacts);
});
});
describe('mergeExecutionContexts()', () => {

View File

@ -4,9 +4,11 @@ import {
IExecuteData,
IExecutionContext,
INodeExecutionData,
ISecureArtifacts,
PlaintextExecutionContext,
toCredentialContext,
toExecutionContextEstablishmentHookParameter,
toSecureArtifacts,
Workflow,
} from 'n8n-workflow';
@ -29,9 +31,15 @@ export class ExecutionContextService {
const decrypted = await this.cipher.decryptV2(context.credentials);
credentials = toCredentialContext(decrypted);
}
let secureArtifacts: ISecureArtifacts | undefined = undefined;
if (context.secureArtifacts) {
const decrypted = await this.cipher.decryptV2(context.secureArtifacts);
secureArtifacts = toSecureArtifacts(decrypted);
}
return {
...context,
credentials,
secureArtifacts,
};
}
@ -40,9 +48,14 @@ export class ExecutionContextService {
if (context.credentials) {
credentials = await this.cipher.encryptV2(context.credentials);
}
let secureArtifacts = undefined;
if (context.secureArtifacts) {
secureArtifacts = await this.cipher.encryptV2(context.secureArtifacts);
}
return {
...context,
credentials,
secureArtifacts,
};
}

View File

@ -2,6 +2,64 @@ import z, { type ZodType } from 'zod/v4';
import { jsonParse } from './utils';
/**
* JSON-shaped value type what survives an encrypt JSON serialize
* decrypt JSON parse round-trip. Used as the leaf type for secure artifacts.
*/
type JsonPrimitive = string | number | boolean | null;
type JsonValue = JsonPrimitive | { [key: string]: JsonValue } | JsonValue[];
const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
z.union([
z.string(),
z.number(),
z.boolean(),
z.null(),
z.record(z.string(), JsonValueSchema),
z.array(JsonValueSchema),
]),
);
const SecureArtifactsSchemaV1 = z.object({
version: z.literal(1),
/**
* Artifacts produced by context-establishment hooks (e.g. a trigger
* stripper) and consumed by node backends later in the execution.
*
* - Outer key: source node name.
* - Inner array is parallel to the trigger items array index `i`
* corresponds to `triggerItems[i]`.
* - Each per-item map is keyed by the extraction path; values are the
* leaf data extracted from that item.
*/
artifacts: z.record(z.string(), z.array(z.record(z.string(), JsonValueSchema))),
/**
* Optional metadata produced by the hook (e.g. provenance, hook id).
*/
metadata: z.record(z.string(), z.unknown()).optional(),
});
export type ISecureArtifactsV1 = z.output<typeof SecureArtifactsSchemaV1>;
export const SecureArtifactsSchema = z
.discriminatedUnion('version', [SecureArtifactsSchemaV1])
.meta({
title: 'ISecureArtifacts',
});
/**
* Decrypted structure of the `secureArtifacts` field on the execution context.
* Carries values produced by context-establishment hooks (e.g. trigger
* stripping) for later consumption by node backends. Always encrypted as a
* single string when stored on `IExecutionContext`; only exists in this
* structured form on `PlaintextExecutionContext` during runtime.
*
* @see PlaintextExecutionContext.secureArtifacts
*/
export type ISecureArtifacts = z.output<typeof SecureArtifactsSchema>;
const CredentialContextSchemaV1 = z.object({
version: z.literal(1),
/**
@ -100,6 +158,18 @@ const ExecutionContextSchemaV1 = z.object({
'Encrypted credential context for dynamic credential resolution Always encrypted when stored, decrypted on-demand by credential resolver @see ICredentialContext for decrypted structure',
}),
/**
* Encrypted artifacts produced by context-establishment hooks
* (e.g. a trigger stripper) for later consumption by node backends.
* Always encrypted when stored, decrypted on demand by
* `ExecutionContextService`.
* @see ISecureArtifacts for the decrypted structure.
*/
secureArtifacts: z.string().optional().meta({
description:
'Encrypted artifacts produced by context-establishment hooks. Always encrypted when stored, decrypted on-demand by ExecutionContextService. @see ISecureArtifacts for decrypted structure',
}),
/**
* Redaction setting captured at execution time.
* Persisted so the correct redaction policy is applied when reading execution data,
@ -167,8 +237,12 @@ export type IExecutionContext = z.output<typeof ExecutionContextSchema>;
* };
* ```
*/
export type PlaintextExecutionContext = Omit<IExecutionContext, 'credentials'> & {
export type PlaintextExecutionContext = Omit<
IExecutionContext,
'credentials' | 'secureArtifacts'
> & {
credentials?: ICredentialContext;
secureArtifacts?: ISecureArtifacts;
};
export const safeParse = <T extends ZodType>(value: string | object, schema: T) => {
@ -210,3 +284,8 @@ export const toCredentialContext = (value: string | object): ICredentialContext
// here we could implement a mgiration policy for migrating old credential context versions to newer ones
return safeParse(value, CredentialContextSchema);
};
export const toSecureArtifacts = (value: string | object): ISecureArtifacts => {
// here we could implement a migration policy for migrating old secure artifacts versions to newer ones
return safeParse(value, SecureArtifactsSchema);
};