refactor(core): Simplify ExternalSecretsProxy setup and move it to core (#16021)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2025-06-05 12:49:08 +02:00
committed by GitHub
parent 3e91f3253b
commit 2258a74518
20 changed files with 227 additions and 118 deletions

View File

@@ -4,6 +4,7 @@ import { ExecutionRepository } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { ExternalSecretsProxy } from 'n8n-core';
import type { IWorkflowBase } from 'n8n-workflow'; import type { IWorkflowBase } from 'n8n-workflow';
import type { import type {
IExecuteWorkflowInfo, IExecuteWorkflowInfo,
@@ -23,7 +24,6 @@ import {
SubworkflowPolicyChecker, SubworkflowPolicyChecker,
} from '@/executions/pre-execution-checks'; } from '@/executions/pre-execution-checks';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import { SecretsHelper } from '@/secrets-helpers.ee';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import { Telemetry } from '@/telemetry'; import { Telemetry } from '@/telemetry';
@@ -86,12 +86,12 @@ describe('WorkflowExecuteAdditionalData', () => {
const variablesService = mockInstance(VariablesService); const variablesService = mockInstance(VariablesService);
variablesService.getAllCached.mockResolvedValue([]); variablesService.getAllCached.mockResolvedValue([]);
const credentialsHelper = mockInstance(CredentialsHelper); const credentialsHelper = mockInstance(CredentialsHelper);
const secretsHelper = mockInstance(SecretsHelper); const externalSecretsProxy = mockInstance(ExternalSecretsProxy);
const eventService = mockInstance(EventService); const eventService = mockInstance(EventService);
mockInstance(ExternalHooks); mockInstance(ExternalHooks);
Container.set(VariablesService, variablesService); Container.set(VariablesService, variablesService);
Container.set(CredentialsHelper, credentialsHelper); Container.set(CredentialsHelper, credentialsHelper);
Container.set(SecretsHelper, secretsHelper); Container.set(ExternalSecretsProxy, externalSecretsProxy);
const executionRepository = mockInstance(ExecutionRepository); const executionRepository = mockInstance(ExecutionRepository);
mockInstance(Telemetry); mockInstance(Telemetry);
const workflowRepository = mockInstance(WorkflowRepository); const workflowRepository = mockInstance(WorkflowRepository);
@@ -306,7 +306,7 @@ describe('WorkflowExecuteAdditionalData', () => {
userId: undefined, userId: undefined,
setExecutionStatus: expect.any(Function), setExecutionStatus: expect.any(Function),
variables: mockVariables, variables: mockVariables,
secretsHelpers: secretsHelper, externalSecretsProxy,
startRunnerTask: expect.any(Function), startRunnerTask: expect.any(Function),
logAiEvent: expect.any(Function), logAiEvent: expect.any(Function),
}); });

View File

@@ -11,6 +11,7 @@ import {
ObjectStoreService, ObjectStoreService,
DataDeduplicationService, DataDeduplicationService,
ErrorReporter, ErrorReporter,
ExternalSecretsProxy,
} from 'n8n-core'; } from 'n8n-core';
import { ensureError, sleep, UserError } from 'n8n-workflow'; import { ensureError, sleep, UserError } from 'n8n-workflow';
@@ -278,6 +279,7 @@ export abstract class BaseCommand extends Command {
async initExternalSecrets() { async initExternalSecrets() {
const secretsManager = Container.get(ExternalSecretsManager); const secretsManager = Container.get(ExternalSecretsManager);
await secretsManager.init(); await secretsManager.init();
Container.get(ExternalSecretsProxy).setManager(secretsManager);
} }
initWorkflowHistory() { initWorkflowHistory() {

View File

@@ -6,7 +6,7 @@ import { Container } from '@n8n/di';
import Csrf from 'csrf'; import Csrf from 'csrf';
import type { Response } from 'express'; import type { Response } from 'express';
import { captor, mock } from 'jest-mock-extended'; import { captor, mock } from 'jest-mock-extended';
import { Cipher, type InstanceSettings } from 'n8n-core'; import { Cipher, type InstanceSettings, ExternalSecretsProxy } from 'n8n-core';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow'; import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import nock from 'nock'; import nock from 'nock';
@@ -19,7 +19,6 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import type { OAuthRequest } from '@/requests'; import type { OAuthRequest } from '@/requests';
import { SecretsHelper } from '@/secrets-helpers.ee';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
@@ -28,7 +27,7 @@ jest.mock('@/workflow-execute-additional-data');
describe('OAuth1CredentialController', () => { describe('OAuth1CredentialController', () => {
mockInstance(Logger); mockInstance(Logger);
mockInstance(ExternalHooks); mockInstance(ExternalHooks);
mockInstance(SecretsHelper); mockInstance(ExternalSecretsProxy);
mockInstance(VariablesService, { mockInstance(VariablesService, {
getAllCached: async () => [], getAllCached: async () => [],
}); });

View File

@@ -6,7 +6,7 @@ import { Container } from '@n8n/di';
import Csrf from 'csrf'; import Csrf from 'csrf';
import { type Response } from 'express'; import { type Response } from 'express';
import { captor, mock } from 'jest-mock-extended'; import { captor, mock } from 'jest-mock-extended';
import { Cipher, type InstanceSettings } from 'n8n-core'; import { Cipher, type InstanceSettings, ExternalSecretsProxy } from 'n8n-core';
import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow'; import type { IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import nock from 'nock'; import nock from 'nock';
@@ -19,7 +19,6 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import type { OAuthRequest } from '@/requests'; import type { OAuthRequest } from '@/requests';
import { SecretsHelper } from '@/secrets-helpers.ee';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { mockInstance } from '@test/mocking'; import { mockInstance } from '@test/mocking';
@@ -27,7 +26,7 @@ jest.mock('@/workflow-execute-additional-data');
describe('OAuth2CredentialController', () => { describe('OAuth2CredentialController', () => {
mockInstance(Logger); mockInstance(Logger);
mockInstance(SecretsHelper); mockInstance(ExternalSecretsProxy);
mockInstance(VariablesService, { mockInstance(VariablesService, {
getAllCached: async () => [], getAllCached: async () => [],
}); });

View File

@@ -327,8 +327,6 @@ export class CredentialsHelper extends ICredentialsHelper {
return decryptedDataOriginal; return decryptedDataOriginal;
} }
await additionalData?.secretsHelpers?.waitForInit();
return await this.applyDefaultsAndOverwrites( return await this.applyDefaultsAndOverwrites(
additionalData, additionalData,
decryptedDataOriginal, decryptedDataOriginal,

View File

@@ -4,6 +4,7 @@ import { Container } from '@n8n/di';
import axios from 'axios'; import axios from 'axios';
import type { AxiosRequestConfig, Method } from 'axios'; import type { AxiosRequestConfig, Method } from 'axios';
import { Agent as HTTPSAgent } from 'https'; import { Agent as HTTPSAgent } from 'https';
import { ExternalSecretsProxy } from 'n8n-core';
import { jsonParse, MessageEventBusDestinationTypeNames } from 'n8n-workflow'; import { jsonParse, MessageEventBusDestinationTypeNames } from 'n8n-workflow';
import type { import type {
MessageEventBusDestinationOptions, MessageEventBusDestinationOptions,
@@ -14,7 +15,6 @@ import type {
} from 'n8n-workflow'; } from 'n8n-workflow';
import { CredentialsHelper } from '@/credentials-helper'; import { CredentialsHelper } from '@/credentials-helper';
import { SecretsHelper } from '@/secrets-helpers.ee';
import { MessageEventBusDestination } from './message-event-bus-destination.ee'; import { MessageEventBusDestination } from './message-event-bus-destination.ee';
import { eventMessageGenericDestinationTestEvent } from '../event-message-classes/event-message-generic'; import { eventMessageGenericDestinationTestEvent } from '../event-message-classes/event-message-generic';
@@ -103,7 +103,7 @@ export class MessageEventBusDestinationWebhook
if (foundCredential) { if (foundCredential) {
const credentialsDecrypted = await this.credentialsHelper?.getDecrypted( const credentialsDecrypted = await this.credentialsHelper?.getDecrypted(
{ {
secretsHelpers: Container.get(SecretsHelper), externalSecretsProxy: Container.get(ExternalSecretsProxy),
} as unknown as IWorkflowExecuteAdditionalData, } as unknown as IWorkflowExecuteAdditionalData,
foundCredential[1], foundCredential[1],
foundCredential[0], foundCredential[0],

View File

@@ -352,10 +352,10 @@ describe('External Secrets Manager', () => {
expect(manager.getSecretNames('dummy')).toEqual(['test1', 'test2']); expect(manager.getSecretNames('dummy')).toEqual(['test1', 'test2']);
}); });
test('should return undefined when provider does not exist', async () => { test('should return an empty array when provider does not exist', async () => {
await manager.init(); await manager.init();
expect(manager.getSecretNames('nonexistent')).toBeUndefined(); expect(manager.getSecretNames('nonexistent')).toBeEmptyArray();
}); });
}); });

View File

@@ -2,7 +2,7 @@ import { Logger } from '@n8n/backend-common';
import { SettingsRepository } from '@n8n/db'; import { SettingsRepository } from '@n8n/db';
import { OnPubSubEvent, OnShutdown } from '@n8n/decorators'; import { OnPubSubEvent, OnShutdown } from '@n8n/decorators';
import { Service } from '@n8n/di'; import { Service } from '@n8n/di';
import { Cipher } from 'n8n-core'; import { Cipher, type IExternalSecretsManager } from 'n8n-core';
import { jsonParse, type IDataObject, ensureError, UnexpectedError } from 'n8n-workflow'; import { jsonParse, type IDataObject, ensureError, UnexpectedError } from 'n8n-workflow';
import { EventService } from '@/events/event.service'; import { EventService } from '@/events/event.service';
@@ -19,7 +19,7 @@ import { ExternalSecretsConfig } from './external-secrets.config';
import type { ExternalSecretsSettings, SecretsProvider, SecretsProviderSettings } from './types'; import type { ExternalSecretsSettings, SecretsProvider, SecretsProviderSettings } from './types';
@Service() @Service()
export class ExternalSecretsManager { export class ExternalSecretsManager implements IExternalSecretsManager {
private providers: Record<string, SecretsProvider> = {}; private providers: Record<string, SecretsProvider> = {};
private initializingPromise?: Promise<void>; private initializingPromise?: Promise<void>;
@@ -211,7 +211,7 @@ export class ExternalSecretsManager {
return provider in this.providers; return provider in this.providers;
} }
getProviderNames(): string[] | undefined { getProviderNames(): string[] {
return Object.keys(this.providers); return Object.keys(this.providers);
} }
@@ -223,8 +223,8 @@ export class ExternalSecretsManager {
return this.getProvider(provider)?.hasSecret(name) ?? false; return this.getProvider(provider)?.hasSecret(name) ?? false;
} }
getSecretNames(provider: string): string[] | undefined { getSecretNames(provider: string): string[] {
return this.getProvider(provider)?.getSecretNames(); return this.getProvider(provider)?.getSecretNames() ?? [];
} }
getAllSecretNames(): Record<string, string[]> { getAllSecretNames(): Record<string, string[]> {

View File

@@ -3,6 +3,7 @@ import type { IExecutionResponse } from '@n8n/db';
import type { ExecutionRepository } from '@n8n/db'; import type { ExecutionRepository } from '@n8n/db';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import type { WorkflowExecute as ActualWorkflowExecute } from 'n8n-core'; import type { WorkflowExecute as ActualWorkflowExecute } from 'n8n-core';
import { ExternalSecretsProxy } from 'n8n-core';
import { mockInstance } from 'n8n-core/test/utils'; import { mockInstance } from 'n8n-core/test/utils';
import type { IPinData, ITaskData, IWorkflowExecuteAdditionalData } from 'n8n-workflow'; import type { IPinData, ITaskData, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
import { Workflow, type IRunExecutionData, type WorkflowExecuteMode } from 'n8n-workflow'; import { Workflow, type IRunExecutionData, type WorkflowExecuteMode } from 'n8n-workflow';
@@ -11,7 +12,6 @@ import { CredentialsHelper } from '@/credentials-helper';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee'; import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import type { ManualExecutionService } from '@/manual-execution.service'; import type { ManualExecutionService } from '@/manual-execution.service';
import { SecretsHelper } from '@/secrets-helpers.ee';
import { WorkflowStatisticsService } from '@/services/workflow-statistics.service'; import { WorkflowStatisticsService } from '@/services/workflow-statistics.service';
import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data'; import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-data';
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service'; import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
@@ -23,7 +23,7 @@ mockInstance(VariablesService, {
getAllCached: jest.fn().mockResolvedValue([]), getAllCached: jest.fn().mockResolvedValue([]),
}); });
mockInstance(CredentialsHelper); mockInstance(CredentialsHelper);
mockInstance(SecretsHelper); mockInstance(ExternalSecretsProxy);
mockInstance(WorkflowStaticDataService); mockInstance(WorkflowStaticDataService);
mockInstance(WorkflowStatisticsService); mockInstance(WorkflowStatisticsService);
mockInstance(ExternalHooks); mockInstance(ExternalHooks);

View File

@@ -1,42 +0,0 @@
import { Service } from '@n8n/di';
import type { SecretsHelpersBase } from 'n8n-workflow';
import { ExternalSecretsManager } from './external-secrets.ee/external-secrets-manager.ee';
@Service()
export class SecretsHelper implements SecretsHelpersBase {
constructor(private service: ExternalSecretsManager) {}
async update() {
if (!this.service.initialized) {
await this.service.init();
}
await this.service.updateSecrets();
}
async waitForInit() {
if (!this.service.initialized) {
await this.service.init();
}
}
getSecret(provider: string, name: string) {
return this.service.getSecret(provider, name);
}
hasSecret(provider: string, name: string): boolean {
return this.service.hasSecret(provider, name);
}
hasProvider(provider: string): boolean {
return this.service.hasProvider(provider);
}
listProviders(): string[] {
return this.service.getProviderNames() ?? [];
}
listSecrets(provider: string): string[] {
return this.service.getSecretNames(provider) ?? [];
}
}

View File

@@ -7,7 +7,7 @@ import { Logger } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config'; import { GlobalConfig } from '@n8n/config';
import { ExecutionRepository, WorkflowRepository } from '@n8n/db'; import { ExecutionRepository, WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { WorkflowExecute } from 'n8n-core'; import { ExternalSecretsProxy, WorkflowExecute } from 'n8n-core';
import { UnexpectedError, Workflow } from 'n8n-workflow'; import { UnexpectedError, Workflow } from 'n8n-workflow';
import type { import type {
IDataObject, IDataObject,
@@ -46,7 +46,6 @@ import {
import type { UpdateExecutionPayload } from '@/interfaces'; import type { UpdateExecutionPayload } from '@/interfaces';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import { Push } from '@/push'; import { Push } from '@/push';
import { SecretsHelper } from '@/secrets-helpers.ee';
import { UrlService } from '@/services/url.service'; import { UrlService } from '@/services/url.service';
import { TaskRequester } from '@/task-runners/task-managers/task-requester'; import { TaskRequester } from '@/task-runners/task-managers/task-requester';
import { findSubworkflowStart } from '@/utils'; import { findSubworkflowStart } from '@/utils';
@@ -388,7 +387,7 @@ export async function getBase(
userId, userId,
setExecutionStatus, setExecutionStatus,
variables, variables,
secretsHelpers: Container.get(SecretsHelper), externalSecretsProxy: Container.get(ExternalSecretsProxy),
async startRunnerTask( async startRunnerTask(
additionalData: IWorkflowExecuteAdditionalData, additionalData: IWorkflowExecuteAdditionalData,
jobType: string, jobType: string,

View File

@@ -2,7 +2,7 @@ import type { WebhookEntity } from '@n8n/db';
import { WorkflowRepository } from '@n8n/db'; import { WorkflowRepository } from '@n8n/db';
import { Container } from '@n8n/di'; import { Container } from '@n8n/di';
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { InstanceSettings } from 'n8n-core'; import { InstanceSettings, ExternalSecretsProxy } from 'n8n-core';
import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node'; import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node';
import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node'; import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node';
import { NodeApiError, Workflow } from 'n8n-workflow'; import { NodeApiError, Workflow } from 'n8n-workflow';
@@ -19,7 +19,6 @@ import { ExecutionService } from '@/executions/execution.service';
import { ExternalHooks } from '@/external-hooks'; import { ExternalHooks } from '@/external-hooks';
import { NodeTypes } from '@/node-types'; import { NodeTypes } from '@/node-types';
import { Push } from '@/push'; import { Push } from '@/push';
import { SecretsHelper } from '@/secrets-helpers.ee';
import * as WebhookHelpers from '@/webhooks/webhook-helpers'; import * as WebhookHelpers from '@/webhooks/webhook-helpers';
import { WebhookService } from '@/webhooks/webhook.service'; import { WebhookService } from '@/webhooks/webhook.service';
import * as AdditionalData from '@/workflow-execute-additional-data'; import * as AdditionalData from '@/workflow-execute-additional-data';
@@ -33,7 +32,7 @@ import { mockInstance } from '../shared/mocking';
mockInstance(ActiveExecutions); mockInstance(ActiveExecutions);
mockInstance(Push); mockInstance(Push);
mockInstance(SecretsHelper); mockInstance(ExternalSecretsProxy);
mockInstance(ExecutionService); mockInstance(ExecutionService);
mockInstance(WorkflowService); mockInstance(WorkflowService);

View File

@@ -13,7 +13,7 @@ import type {
import type { import type {
ExecutionLifecycleHookName, ExecutionLifecycleHookName,
ExecutionLifecyleHookHandlers, ExecutionLifecycleHookHandlers,
} from '../execution-lifecycle-hooks'; } from '../execution-lifecycle-hooks';
import { ExecutionLifecycleHooks } from '../execution-lifecycle-hooks'; import { ExecutionLifecycleHooks } from '../execution-lifecycle-hooks';
@@ -46,12 +46,14 @@ describe('ExecutionLifecycleHooks', () => {
describe('addHandler()', () => { describe('addHandler()', () => {
const hooksHandlers = const hooksHandlers =
mock<{ mock<{
[K in keyof ExecutionLifecyleHookHandlers]: ExecutionLifecyleHookHandlers[K][number]; [K in keyof ExecutionLifecycleHookHandlers]: ExecutionLifecycleHookHandlers[K][number];
}>(); }>();
const testCases: Array<{ const testCases: Array<{
hook: ExecutionLifecycleHookName; hook: ExecutionLifecycleHookName;
args: Parameters<ExecutionLifecyleHookHandlers[keyof ExecutionLifecyleHookHandlers][number]>; args: Parameters<
ExecutionLifecycleHookHandlers[keyof ExecutionLifecycleHookHandlers][number]
>;
}> = [ }> = [
{ hook: 'nodeExecuteBefore', args: ['testNode', mock<ITaskStartedData>()] }, { hook: 'nodeExecuteBefore', args: ['testNode', mock<ITaskStartedData>()] },
{ {

View File

@@ -0,0 +1,121 @@
import { mock } from 'jest-mock-extended';
import { ExternalSecretsProxy, type IExternalSecretsManager } from '../external-secrets-proxy';
describe('ExternalSecretsProxy', () => {
let proxy: ExternalSecretsProxy;
const manager = mock<IExternalSecretsManager>();
beforeEach(() => {
jest.resetAllMocks();
proxy = new ExternalSecretsProxy();
});
describe('update', () => {
it('should update secrets when manager is set', async () => {
manager.updateSecrets.mockResolvedValue();
proxy.setManager(manager);
await proxy.update();
expect(manager.updateSecrets).toHaveBeenCalledTimes(1);
});
it('should not throw when updating without a manager', async () => {
await expect(proxy.update()).resolves.not.toThrow();
});
});
describe('getSecret', () => {
it('should get secret from manager', () => {
const secretValue = { key: 'value' };
manager.getSecret.mockReturnValue(secretValue);
proxy.setManager(manager);
const result = proxy.getSecret('aws', 'api-key');
expect(manager.getSecret).toHaveBeenCalledWith('aws', 'api-key');
expect(result).toBe(secretValue);
});
it('should return undefined when getting secret without a manager', () => {
const result = proxy.getSecret('aws', 'api-key');
expect(result).toBeUndefined();
});
});
describe('hasSecret', () => {
it('should check if secret exists', () => {
manager.hasSecret.mockReturnValue(true);
proxy.setManager(manager);
const result = proxy.hasSecret('aws', 'api-key');
expect(manager.hasSecret).toHaveBeenCalledWith('aws', 'api-key');
expect(result).toBe(true);
});
it('should return false when checking secret without a manager', () => {
const result = proxy.hasSecret('aws', 'api-key');
expect(result).toBe(false);
});
});
describe('hasProvider', () => {
it('should check if provider exists', () => {
manager.hasProvider.mockReturnValue(true);
proxy.setManager(manager);
const result = proxy.hasProvider('aws');
expect(manager.hasProvider).toHaveBeenCalledWith('aws');
expect(result).toBe(true);
});
it('should return false when checking provider without a manager', () => {
const result = proxy.hasProvider('aws');
expect(result).toBe(false);
});
});
describe('listProviders', () => {
it('should list providers', () => {
const providers = ['aws', 'gcp', 'azure'];
manager.getProviderNames.mockReturnValue(providers);
proxy.setManager(manager);
const result = proxy.listProviders();
expect(manager.getProviderNames).toHaveBeenCalledTimes(1);
expect(result).toEqual(providers);
});
it('should return empty array when listing providers without a manager', () => {
const result = proxy.listProviders();
expect(result).toEqual([]);
});
});
describe('listSecrets', () => {
it('should list secrets for a provider', () => {
const secrets = ['api-key', 'api-secret', 'token'];
manager.getSecretNames.mockReturnValue(secrets);
proxy.setManager(manager);
const result = proxy.listSecrets('aws');
expect(manager.getSecretNames).toHaveBeenCalledWith('aws');
expect(result).toEqual(secrets);
});
it('should return empty array when listing secrets without a manager', () => {
const result = proxy.listSecrets('aws');
expect(result).toEqual([]);
});
});
});

View File

@@ -11,7 +11,7 @@ import type {
WorkflowExecuteMode, WorkflowExecuteMode,
} from 'n8n-workflow'; } from 'n8n-workflow';
export type ExecutionLifecyleHookHandlers = { export type ExecutionLifecycleHookHandlers = {
nodeExecuteBefore: Array< nodeExecuteBefore: Array<
( (
this: ExecutionLifecycleHooks, this: ExecutionLifecycleHooks,
@@ -56,7 +56,7 @@ s */
>; >;
}; };
export type ExecutionLifecycleHookName = keyof ExecutionLifecyleHookHandlers; export type ExecutionLifecycleHookName = keyof ExecutionLifecycleHookHandlers;
/** /**
* Contains hooks that trigger at specific events in an execution's lifecycle. Every hook has an array of callbacks to run. * Contains hooks that trigger at specific events in an execution's lifecycle. Every hook has an array of callbacks to run.
@@ -77,7 +77,7 @@ export type ExecutionLifecycleHookName = keyof ExecutionLifecyleHookHandlers;
* ``` * ```
*/ */
export class ExecutionLifecycleHooks { export class ExecutionLifecycleHooks {
readonly handlers: ExecutionLifecyleHookHandlers = { readonly handlers: ExecutionLifecycleHookHandlers = {
nodeExecuteAfter: [], nodeExecuteAfter: [],
nodeExecuteBefore: [], nodeExecuteBefore: [],
nodeFetchedData: [], nodeFetchedData: [],
@@ -92,18 +92,18 @@ export class ExecutionLifecycleHooks {
readonly workflowData: IWorkflowBase, readonly workflowData: IWorkflowBase,
) {} ) {}
addHandler<Hook extends keyof ExecutionLifecyleHookHandlers>( addHandler<Hook extends keyof ExecutionLifecycleHookHandlers>(
hookName: Hook, hookName: Hook,
...handlers: Array<ExecutionLifecyleHookHandlers[Hook][number]> ...handlers: Array<ExecutionLifecycleHookHandlers[Hook][number]>
): void { ): void {
// @ts-expect-error FIX THIS // @ts-expect-error FIX THIS
this.handlers[hookName].push(...handlers); this.handlers[hookName].push(...handlers);
} }
async runHook< async runHook<
Hook extends keyof ExecutionLifecyleHookHandlers, Hook extends keyof ExecutionLifecycleHookHandlers,
Params extends unknown[] = Parameters< Params extends unknown[] = Parameters<
Exclude<ExecutionLifecyleHookHandlers[Hook], undefined>[number] Exclude<ExecutionLifecycleHookHandlers[Hook], undefined>[number]
>, >,
>(hookName: Hook, parameters: Params) { >(hookName: Hook, parameters: Params) {
const hooks = this.handlers[hookName]; const hooks = this.handlers[hookName];
@@ -116,9 +116,3 @@ export class ExecutionLifecycleHooks {
} }
} }
} }
declare module 'n8n-workflow' {
interface IWorkflowExecuteAdditionalData {
hooks?: ExecutionLifecycleHooks;
}
}

View File

@@ -0,0 +1,43 @@
import { Service } from '@n8n/di';
export interface IExternalSecretsManager {
updateSecrets(): Promise<void>;
hasSecret(provider: string, name: string): boolean;
getSecret(provider: string, name: string): unknown;
getSecretNames(provider: string): string[];
hasProvider(provider: string): boolean;
getProviderNames(): string[];
}
@Service()
export class ExternalSecretsProxy {
private manager?: IExternalSecretsManager;
setManager(manager: IExternalSecretsManager) {
this.manager = manager;
}
async update() {
await this.manager?.updateSecrets();
}
getSecret(provider: string, name: string) {
return this.manager?.getSecret(provider, name);
}
hasSecret(provider: string, name: string): boolean {
return !!this.manager && this.manager.hasSecret(provider, name);
}
hasProvider(provider: string): boolean {
return !!this.manager && this.manager.hasProvider(provider);
}
listProviders(): string[] {
return this.manager?.getProviderNames() ?? [];
}
listSecrets(provider: string): string[] {
return this.manager?.getSecretNames(provider) ?? [];
}
}

View File

@@ -1,3 +1,13 @@
import type { ExecutionLifecycleHooks } from './execution-lifecycle-hooks';
import type { ExternalSecretsProxy } from './external-secrets-proxy';
declare module 'n8n-workflow' {
interface IWorkflowExecuteAdditionalData {
hooks?: ExecutionLifecycleHooks;
externalSecretsProxy: ExternalSecretsProxy;
}
}
export * from './active-workflows'; export * from './active-workflows';
export * from './interfaces'; export * from './interfaces';
export * from './routing-node'; export * from './routing-node';
@@ -6,3 +16,4 @@ export * from './partial-execution-utils';
export * from './node-execution-context/utils/execution-metadata'; export * from './node-execution-context/utils/execution-metadata';
export * from './workflow-execute'; export * from './workflow-execute';
export { ExecutionLifecycleHooks } from './execution-lifecycle-hooks'; export { ExecutionLifecycleHooks } from './execution-lifecycle-hooks';
export { ExternalSecretsProxy, type IExternalSecretsManager } from './external-secrets-proxy';

View File

@@ -1,24 +1,20 @@
import { mock } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended';
import { LoggerProxy } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow';
import type { import type { IDataObject, IRunExecutionData, IWorkflowExecuteAdditionalData } from 'n8n-workflow';
IDataObject,
IRunExecutionData,
IWorkflowExecuteAdditionalData,
SecretsHelpersBase,
} from 'n8n-workflow';
import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants'; import { PLACEHOLDER_EMPTY_EXECUTION_ID } from '@/constants';
import type { ExternalSecretsProxy } from '@/execution-engine/external-secrets-proxy';
import { getAdditionalKeys } from '../get-additional-keys'; import { getAdditionalKeys } from '../get-additional-keys';
describe('getAdditionalKeys', () => { describe('getAdditionalKeys', () => {
const secretsHelpers = mock<SecretsHelpersBase>(); const externalSecretsProxy = mock<ExternalSecretsProxy>();
const additionalData = mock<IWorkflowExecuteAdditionalData>({ const additionalData = mock<IWorkflowExecuteAdditionalData>({
executionId: '123', executionId: '123',
webhookWaitingBaseUrl: 'https://webhook.test', webhookWaitingBaseUrl: 'https://webhook.test',
formWaitingBaseUrl: 'https://form.test', formWaitingBaseUrl: 'https://form.test',
variables: { testVar: 'value' }, variables: { testVar: 'value' },
secretsHelpers, externalSecretsProxy,
}); });
const runExecutionData = mock<IRunExecutionData>({ const runExecutionData = mock<IRunExecutionData>({
@@ -30,11 +26,11 @@ describe('getAdditionalKeys', () => {
beforeAll(() => { beforeAll(() => {
LoggerProxy.init(mock()); LoggerProxy.init(mock());
secretsHelpers.hasProvider.mockReturnValue(true); externalSecretsProxy.hasProvider.mockReturnValue(true);
secretsHelpers.hasSecret.mockReturnValue(true); externalSecretsProxy.hasSecret.mockReturnValue(true);
secretsHelpers.getSecret.mockReturnValue('secret-value'); externalSecretsProxy.getSecret.mockReturnValue('secret-value');
secretsHelpers.listSecrets.mockReturnValue(['secret1']); externalSecretsProxy.listSecrets.mockReturnValue(['secret1']);
secretsHelpers.listProviders.mockReturnValue(['provider1']); externalSecretsProxy.listProviders.mockReturnValue(['provider1']);
}); });
it('should use placeholder execution ID when none provided', () => { it('should use placeholder execution ID when none provided', () => {

View File

@@ -23,7 +23,7 @@ function buildSecretsValueProxy(value: IDataObject): unknown {
} }
export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData): IDataObject { export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData): IDataObject {
const secretsHelpers = additionalData.secretsHelpers; const { externalSecretsProxy } = additionalData;
return new Proxy( return new Proxy(
{}, {},
{ {
@@ -31,7 +31,7 @@ export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData):
if (typeof providerName !== 'string') { if (typeof providerName !== 'string') {
return {}; return {};
} }
if (secretsHelpers.hasProvider(providerName)) { if (externalSecretsProxy.hasProvider(providerName)) {
return new Proxy( return new Proxy(
{}, {},
{ {
@@ -39,13 +39,13 @@ export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData):
if (typeof secretName !== 'string') { if (typeof secretName !== 'string') {
return; return;
} }
if (!secretsHelpers.hasSecret(providerName, secretName)) { if (!externalSecretsProxy.hasSecret(providerName, secretName)) {
throw new ExpressionError('Could not load secrets', { throw new ExpressionError('Could not load secrets', {
description: description:
'The credential in use tries to use secret from an external store that could not be found', 'The credential in use tries to use secret from an external store that could not be found',
}); });
} }
const retValue = secretsHelpers.getSecret(providerName, secretName); const retValue = externalSecretsProxy.getSecret(providerName, secretName);
if (typeof retValue === 'object' && retValue !== null) { if (typeof retValue === 'object' && retValue !== null) {
return buildSecretsValueProxy(retValue as IDataObject); return buildSecretsValueProxy(retValue as IDataObject);
} }
@@ -55,7 +55,7 @@ export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData):
return false; return false;
}, },
ownKeys() { ownKeys() {
return secretsHelpers.listSecrets(providerName); return externalSecretsProxy.listSecrets(providerName);
}, },
}, },
); );
@@ -69,7 +69,7 @@ export function getSecretsProxy(additionalData: IWorkflowExecuteAdditionalData):
return false; return false;
}, },
ownKeys() { ownKeys() {
return secretsHelpers.listProviders(); return externalSecretsProxy.listProviders();
}, },
}, },
); );

View File

@@ -2374,7 +2374,6 @@ export interface IWorkflowExecuteAdditionalData {
executionTimeoutTimestamp?: number; executionTimeoutTimestamp?: number;
userId?: string; userId?: string;
variables: IDataObject; variables: IDataObject;
secretsHelpers: SecretsHelpersBase;
logAiEvent: (eventName: AiEvent, payload: AiEventPayload) => void; logAiEvent: (eventName: AiEvent, payload: AiEventPayload) => void;
parentCallbackManager?: CallbackManager; parentCallbackManager?: CallbackManager;
startRunnerTask<T, E = unknown>( startRunnerTask<T, E = unknown>(
@@ -2870,17 +2869,6 @@ export interface ICheckProcessedContextData {
export type N8nAIProviderType = 'openai' | 'unknown'; export type N8nAIProviderType = 'openai' | 'unknown';
export interface SecretsHelpersBase {
update(): Promise<void>;
waitForInit(): Promise<void>;
getSecret(provider: string, name: string): unknown;
hasSecret(provider: string, name: string): boolean;
hasProvider(provider: string): boolean;
listProviders(): string[];
listSecrets(provider: string): string[];
}
export type Functionality = 'regular' | 'configuration-node' | 'pairedItem'; export type Functionality = 'regular' | 'configuration-node' | 'pairedItem';
export type CallbackManager = CallbackManagerLC; export type CallbackManager = CallbackManagerLC;