mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(core): Add encrypted secureArtifacts slot to IExecutionContext (no-changelog) (#30125)
This commit is contained in:
parent
744bb92c2f
commit
d2e5db258c
|
|
@ -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()', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user