From 8a70151aa1c18f856d87cf95f9e1223ed11caa21 Mon Sep 17 00:00:00 2001 From: Andreas Fitzek Date: Wed, 10 Dec 2025 14:08:40 +0100 Subject: [PATCH] chore(core): Write path for dynamic credentials (#23026) --- .../src/__tests__/credentials-helper.test.ts | 23 +- packages/cli/src/credentials-helper.ts | 43 +-- .../dynamic-credentials-proxy.test.ts | 200 ++++++++++++ ...redential-resolution-provider.interface.ts | 0 .../dynamic-credential-storage.interface.ts | 44 +++ .../credentials/dynamic-credentials-proxy.ts | 82 +++++ .../dynamic-credentials.module.ts | 16 +- .../errors/credential-storage.error.ts | 8 + ...dynamic-credential-storage.service.test.ts | 304 ++++++++++++++++++ .../dynamic-credential.service.test.ts | 2 +- .../dynamic-credential-storage.service.ts | 122 +++++++ .../services/dynamic-credential.service.ts | 2 +- .../dynamic-credentials.ee/services/index.ts | 1 + 13 files changed, 805 insertions(+), 42 deletions(-) create mode 100644 packages/cli/src/credentials/__tests__/dynamic-credentials-proxy.test.ts rename packages/cli/src/{ => credentials}/credential-resolution-provider.interface.ts (100%) create mode 100644 packages/cli/src/credentials/dynamic-credential-storage.interface.ts create mode 100644 packages/cli/src/credentials/dynamic-credentials-proxy.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/errors/credential-storage.error.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential-storage.service.test.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-storage.service.ts diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index d6cf1656ea6..05924775f61 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -19,11 +19,15 @@ import { CredentialTypes } from '@/credential-types'; import { CredentialsHelper } from '@/credentials-helper'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy'; describe('CredentialsHelper', () => { const nodeTypes = mock(); const mockNodesAndCredentials = mock(); const credentialsRepository = mock(); + const mockLogger = mock(); + // Use a real instance of DynamicCredentialsProxy so setResolverProvider works + const dynamicCredentialProxy = new DynamicCredentialsProxy(mockLogger); // Setup cipher for testing const cipher = new Cipher(mock({ encryptionKey: 'test_key_for_testing' })); @@ -35,6 +39,7 @@ describe('CredentialsHelper', () => { credentialsRepository, mock(), mock(), + dynamicCredentialProxy, ); describe('getCredentials', () => { @@ -417,10 +422,12 @@ describe('CredentialsHelper', () => { beforeEach(() => { jest.clearAllMocks(); credentialsRepository.findOneByOrFail.mockResolvedValue(mockCredentialEntity); + // Clear the provider between tests to ensure clean state + dynamicCredentialProxy.setResolverProvider(undefined as any); }); test('should call resolveIfNeeded when credentialResolutionProvider is set', async () => { - credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider); + dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); const resolvedData = { apiKey: 'dynamic-key' }; mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue(resolvedData); @@ -448,7 +455,7 @@ describe('CredentialsHelper', () => { }); test('should pass executionContext from additionalData to resolver', async () => { - credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider); + dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' }); await credentialsHelper.getDecrypted( @@ -465,7 +472,7 @@ describe('CredentialsHelper', () => { }); test('should pass workflowSettings from additionalData to resolver', async () => { - credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider); + dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' }); await credentialsHelper.getDecrypted( @@ -482,13 +489,15 @@ describe('CredentialsHelper', () => { }); test('should skip resolution when credentialResolutionProvider is not set', async () => { - // Create a new instance without provider + // Create a new proxy instance without provider + const proxyWithoutProvider = new DynamicCredentialsProxy(mockLogger); const helperWithoutProvider = new CredentialsHelper( new CredentialTypes(mockNodesAndCredentials), mock(), credentialsRepository, mock(), mock(), + proxyWithoutProvider, ); const result = await helperWithoutProvider.getDecrypted( @@ -505,7 +514,7 @@ describe('CredentialsHelper', () => { }); test('should use resolved data instead of static data when resolution succeeds', async () => { - credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider); + dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); const dynamicData = { apiKey: 'dynamic-key', extraField: 'extra-value' }; mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue(dynamicData); @@ -524,7 +533,7 @@ describe('CredentialsHelper', () => { }); test('should handle missing executionContext gracefully', async () => { - credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider); + dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' }); const additionalDataWithoutContext = { @@ -546,7 +555,7 @@ describe('CredentialsHelper', () => { }); test('should handle missing workflowSettings gracefully', async () => { - credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider); + dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' }); const additionalDataWithoutSettings = { diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index 4a4e577fbef..3e409af2d5f 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -46,9 +46,9 @@ import { RESPONSE_ERROR_MESSAGES } from './constants'; import { CredentialNotFoundError } from './errors/credential-not-found.error'; import { CacheService } from './services/cache/cache.service'; -import type { ICredentialResolutionProvider } from '@/credential-resolution-provider.interface'; import { CredentialTypes } from '@/credential-types'; import { CredentialsOverwrites } from '@/credentials-overwrites'; +import { DynamicCredentialsProxy } from './credentials/dynamic-credentials-proxy'; const mockNode = { name: '', @@ -86,26 +86,17 @@ const mockNodeTypes: INodeTypes = { @Service() export class CredentialsHelper extends ICredentialsHelper { - private credentialResolutionProvider?: ICredentialResolutionProvider; - constructor( private readonly credentialTypes: CredentialTypes, private readonly credentialsOverwrites: CredentialsOverwrites, private readonly credentialsRepository: CredentialsRepository, private readonly sharedCredentialsRepository: SharedCredentialsRepository, private readonly cacheService: CacheService, + private readonly dynamicCredentialsProxy: DynamicCredentialsProxy, ) { super(); } - /** - * Registers a credential resolution provider (EE feature). - * Called by the dynamic credentials module during initialization. - */ - setCredentialResolutionProvider(provider: ICredentialResolutionProvider): void { - this.credentialResolutionProvider = provider; - } - /** * Add the required authentication information to the request */ @@ -368,22 +359,20 @@ export class CredentialsHelper extends ICredentialsHelper { let decryptedDataOriginal = credentials.getData(); // Resolve dynamic credentials if configured (EE feature) - if (this.credentialResolutionProvider) { - decryptedDataOriginal = await this.credentialResolutionProvider.resolveIfNeeded( - { - id: credentialsEntity.id, - name: credentialsEntity.name, - isResolvable: false, - // TODO: use the actual values from the entity once they are added - // isResolvable: credentialsEntity.isResolvable, - // resolverId: (credentialsEntity as any).resolverId, - // resolvableAllowFallback: (credentialsEntity as any).resolvableAllowFallback, - }, - decryptedDataOriginal, - additionalData.executionContext, - additionalData.workflowSettings, - ); - } + decryptedDataOriginal = await this.dynamicCredentialsProxy.resolveIfNeeded( + { + id: credentialsEntity.id, + name: credentialsEntity.name, + isResolvable: false, + // TODO: use the actual values from the entity once they are added + // isResolvable: credentialsEntity.isResolvable, + // resolverId: (credentialsEntity as any).resolverId, + // resolvableAllowFallback: (credentialsEntity as any).resolvableAllowFallback, + }, + decryptedDataOriginal, + additionalData.executionContext, + additionalData.workflowSettings, + ); if (raw === true) { return decryptedDataOriginal; diff --git a/packages/cli/src/credentials/__tests__/dynamic-credentials-proxy.test.ts b/packages/cli/src/credentials/__tests__/dynamic-credentials-proxy.test.ts new file mode 100644 index 00000000000..4abff1502d6 --- /dev/null +++ b/packages/cli/src/credentials/__tests__/dynamic-credentials-proxy.test.ts @@ -0,0 +1,200 @@ +import type { Logger } from '@n8n/backend-common'; +import type { + ICredentialContext, + ICredentialDataDecryptedObject, + IExecutionContext, + IWorkflowSettings, +} from 'n8n-workflow'; + +import type { + CredentialResolveMetadata, + ICredentialResolutionProvider, +} from '../credential-resolution-provider.interface'; +import type { + CredentialStoreMetadata, + IDynamicCredentialStorageProvider, +} from '../dynamic-credential-storage.interface'; +import { DynamicCredentialsProxy } from '../dynamic-credentials-proxy'; + +describe('DynamicCredentialsProxy', () => { + let proxy: DynamicCredentialsProxy; + let mockLogger: jest.Mocked; + let mockResolverProvider: jest.Mocked; + let mockStorageProvider: jest.Mocked; + + beforeEach(() => { + mockLogger = { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), + } as unknown as jest.Mocked; + + mockResolverProvider = { + resolveIfNeeded: jest.fn(), + }; + + mockStorageProvider = { + storeIfNeeded: jest.fn(), + }; + + proxy = new DynamicCredentialsProxy(mockLogger); + }); + + describe('resolveIfNeeded', () => { + const staticData: ICredentialDataDecryptedObject = { token: 'static-token' }; + const credentialMetadata: CredentialResolveMetadata = { + id: 'cred-123', + name: 'Test Credential', + isResolvable: false, + }; + + it('should return static data when no provider is set and credential is not resolvable', async () => { + const result = await proxy.resolveIfNeeded(credentialMetadata, staticData); + + expect(result).toBe(staticData); + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should throw error when no provider is set and credential is resolvable', async () => { + const resolvableMetadata = { ...credentialMetadata, isResolvable: true }; + + await expect(proxy.resolveIfNeeded(resolvableMetadata, staticData)).rejects.toThrow( + 'No dynamic credential resolving provider set', + ); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('No dynamic credential resolving provider set'), + ); + }); + + it('should delegate to provider when set', async () => { + const dynamicData = { token: 'dynamic-token' }; + mockResolverProvider.resolveIfNeeded.mockResolvedValue(dynamicData); + + proxy.setResolverProvider(mockResolverProvider); + + const result = await proxy.resolveIfNeeded(credentialMetadata, staticData); + + expect(result).toBe(dynamicData); + expect(mockResolverProvider.resolveIfNeeded).toHaveBeenCalledWith( + credentialMetadata, + staticData, + undefined, + undefined, + ); + }); + + it('should pass through all parameters to provider', async () => { + const executionContext: IExecutionContext = { + version: 1, + establishedAt: Date.now(), + source: 'manual', + }; + const workflowSettings: IWorkflowSettings = { executionTimeout: 300 }; + + mockResolverProvider.resolveIfNeeded.mockResolvedValue(staticData); + proxy.setResolverProvider(mockResolverProvider); + + await proxy.resolveIfNeeded( + credentialMetadata, + staticData, + executionContext, + workflowSettings, + ); + + expect(mockResolverProvider.resolveIfNeeded).toHaveBeenCalledWith( + credentialMetadata, + staticData, + executionContext, + workflowSettings, + ); + }); + }); + + describe('storeIfNeeded', () => { + const dynamicData: ICredentialDataDecryptedObject = { token: 'new-token' }; + const credentialContext: ICredentialContext = { + version: 1, + identity: 'user-123', + metadata: {}, + }; + const credentialMetadata: CredentialStoreMetadata = { + id: 'cred-123', + name: 'Test Credential', + type: 'oAuth2Api', + isResolvable: false, + }; + + it('should return early when no provider is set and credential is not resolvable', async () => { + await proxy.storeIfNeeded(credentialMetadata, dynamicData, credentialContext); + + expect(mockLogger.warn).not.toHaveBeenCalled(); + }); + + it('should throw error when no provider is set and credential is resolvable', async () => { + const resolvableMetadata = { ...credentialMetadata, isResolvable: true }; + + await expect( + proxy.storeIfNeeded(resolvableMetadata, dynamicData, credentialContext), + ).rejects.toThrow('No dynamic credential storage provider set'); + + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('No dynamic credential storage provider set'), + ); + }); + + it('should delegate to provider when set', async () => { + proxy.setStorageProvider(mockStorageProvider); + + await proxy.storeIfNeeded(credentialMetadata, dynamicData, credentialContext); + + expect(mockStorageProvider.storeIfNeeded).toHaveBeenCalledWith( + credentialMetadata, + dynamicData, + credentialContext, + undefined, + undefined, + ); + }); + + it('should pass through all parameters to provider', async () => { + const staticData = { clientId: 'static-client-id' }; + const workflowSettings: IWorkflowSettings = { executionTimeout: 300 }; + + proxy.setStorageProvider(mockStorageProvider); + + await proxy.storeIfNeeded( + credentialMetadata, + dynamicData, + credentialContext, + staticData, + workflowSettings, + ); + + expect(mockStorageProvider.storeIfNeeded).toHaveBeenCalledWith( + credentialMetadata, + dynamicData, + credentialContext, + staticData, + workflowSettings, + ); + }); + }); + + describe('provider registration', () => { + it('should allow setting resolver provider', () => { + proxy.setResolverProvider(mockResolverProvider); + + // Verify by checking it doesn't throw when resolving resolvable credential + expect(() => proxy.setResolverProvider(mockResolverProvider)).not.toThrow(); + }); + + it('should allow setting storage provider', () => { + proxy.setStorageProvider(mockStorageProvider); + + // Verify by checking it doesn't throw when storing resolvable credential + expect(() => proxy.setStorageProvider(mockStorageProvider)).not.toThrow(); + }); + }); +}); diff --git a/packages/cli/src/credential-resolution-provider.interface.ts b/packages/cli/src/credentials/credential-resolution-provider.interface.ts similarity index 100% rename from packages/cli/src/credential-resolution-provider.interface.ts rename to packages/cli/src/credentials/credential-resolution-provider.interface.ts diff --git a/packages/cli/src/credentials/dynamic-credential-storage.interface.ts b/packages/cli/src/credentials/dynamic-credential-storage.interface.ts new file mode 100644 index 00000000000..db80aab29c8 --- /dev/null +++ b/packages/cli/src/credentials/dynamic-credential-storage.interface.ts @@ -0,0 +1,44 @@ +import type { + ICredentialContext, + ICredentialDataDecryptedObject, + IWorkflowSettings, +} from 'n8n-workflow'; + +/** + * Metadata for storing credentials to external systems. + */ +export type CredentialStoreMetadata = { + /** Credential ID */ + id: string; + /** Credential name */ + name: string; + /** Credential type (e.g., 'oAuth2Api') */ + type: string; + /** Resolver ID to use for storage */ + resolverId?: string; + /** Whether credential supports dynamic storage */ + isResolvable: boolean; +}; + +/** + * Provider for storing credentials to external secret management systems. + * Handles the write path for dynamic credentials (e.g., OAuth token refresh). + */ +export interface IDynamicCredentialStorageProvider { + /** + * Stores credential data to external system if credential is configured for dynamic storage. + * + * @param credentialStoreMetadata Credential metadata + * @param dynamicData Credential data to store (e.g., tokens) + * @param credentialContext Identity and metadata for scoping storage + * @param staticData Static credential data from database (e.g., clientId, clientSecret) + * @param workflowSettings Workflow settings with fallback resolver ID + */ + storeIfNeeded( + credentialStoreMetadata: CredentialStoreMetadata, + dynamicData: ICredentialDataDecryptedObject, + credentialContext: ICredentialContext, + staticData?: ICredentialDataDecryptedObject, + workflowSettings?: IWorkflowSettings, + ): Promise; +} diff --git a/packages/cli/src/credentials/dynamic-credentials-proxy.ts b/packages/cli/src/credentials/dynamic-credentials-proxy.ts new file mode 100644 index 00000000000..c5edc3cc315 --- /dev/null +++ b/packages/cli/src/credentials/dynamic-credentials-proxy.ts @@ -0,0 +1,82 @@ +import type { Logger } from '@n8n/backend-common'; +import type { + CredentialStoreMetadata, + IDynamicCredentialStorageProvider, +} from './dynamic-credential-storage.interface'; +import type { + ICredentialContext, + ICredentialDataDecryptedObject, + IExecutionContext, + IWorkflowSettings, +} from 'n8n-workflow'; +import type { + CredentialResolveMetadata, + ICredentialResolutionProvider, +} from './credential-resolution-provider.interface'; +import { Service } from '@n8n/di'; + +@Service() +export class DynamicCredentialsProxy + implements IDynamicCredentialStorageProvider, ICredentialResolutionProvider +{ + private storageProvider: IDynamicCredentialStorageProvider; + private resolvingProvider: ICredentialResolutionProvider; + + constructor(private readonly logger: Logger) {} + + setStorageProvider(provider: IDynamicCredentialStorageProvider) { + this.storageProvider = provider; + } + + setResolverProvider(provider: ICredentialResolutionProvider) { + this.resolvingProvider = provider; + } + + async resolveIfNeeded( + credentialsResolveMetadata: CredentialResolveMetadata, + staticData: ICredentialDataDecryptedObject, + executionContext?: IExecutionContext, + workflowSettings?: IWorkflowSettings, + ): Promise { + if (!this.resolvingProvider) { + if (credentialsResolveMetadata.isResolvable) { + this.logger.warn( + `No dynamic credential resolving provider set, but trying to resolve resolvable credential "${credentialsResolveMetadata.name}"`, + ); + throw new Error('No dynamic credential resolving provider set'); + } + return staticData; + } + return await this.resolvingProvider.resolveIfNeeded( + credentialsResolveMetadata, + staticData, + executionContext, + workflowSettings, + ); + } + + async storeIfNeeded( + credentialStoreMetadata: CredentialStoreMetadata, + dynamicData: ICredentialDataDecryptedObject, + credentialContext: ICredentialContext, + staticData?: ICredentialDataDecryptedObject, + workflowSettings?: IWorkflowSettings, + ): Promise { + if (!this.storageProvider) { + if (credentialStoreMetadata.isResolvable) { + this.logger.warn( + `No dynamic credential storage provider set, but trying to store resolvable credential "${credentialStoreMetadata.name}"`, + ); + throw new Error('No dynamic credential storage provider set'); + } + return; + } + return await this.storageProvider.storeIfNeeded( + credentialStoreMetadata, + dynamicData, + credentialContext, + staticData, + workflowSettings, + ); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts index 50d784b0f74..048bd9109fe 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts @@ -17,17 +17,21 @@ export class DynamicCredentialsModule implements ModuleInterface { await import('./credential-resolvers.controller'); await import('./context-establishment-hooks'); await import('./credential-resolvers'); - const { DynamicCredentialResolverRegistry, DynamicCredentialService } = await import( - './services' - ); + const { + DynamicCredentialResolverRegistry, + DynamicCredentialStorageService, + DynamicCredentialService, + } = await import('./services'); await Container.get(DynamicCredentialResolverRegistry).init(); // Register the credential resolution provider with CredentialsHelper - const { CredentialsHelper } = await import('@/credentials-helper'); - const credentialsHelper = Container.get(CredentialsHelper); + const { DynamicCredentialsProxy } = await import('../../credentials/dynamic-credentials-proxy'); + const credentialsProxy = Container.get(DynamicCredentialsProxy); const dynamicCredentialService = Container.get(DynamicCredentialService); - credentialsHelper.setCredentialResolutionProvider(dynamicCredentialService); + const dynamicCredentialStorageService = Container.get(DynamicCredentialStorageService); + credentialsProxy.setResolverProvider(dynamicCredentialService); + credentialsProxy.setStorageProvider(dynamicCredentialStorageService); } async entities() { diff --git a/packages/cli/src/modules/dynamic-credentials.ee/errors/credential-storage.error.ts b/packages/cli/src/modules/dynamic-credentials.ee/errors/credential-storage.error.ts new file mode 100644 index 00000000000..a448ff05922 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/errors/credential-storage.error.ts @@ -0,0 +1,8 @@ +import { UserError } from 'n8n-workflow'; + +export class CredentialStorageError extends UserError { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = 'CredentialStorageError'; + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential-storage.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential-storage.service.test.ts new file mode 100644 index 00000000000..3a554bb2863 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential-storage.service.test.ts @@ -0,0 +1,304 @@ +import type { Logger } from '@n8n/backend-common'; +import type { ICredentialResolver } from '@n8n/decorators'; +import type { Cipher } from 'n8n-core'; +import type { + ICredentialContext, + ICredentialDataDecryptedObject, + IWorkflowSettings, +} from 'n8n-workflow'; + +import type { CredentialStoreMetadata } from '@/credentials/dynamic-credential-storage.interface'; +import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; + +import type { DynamicCredentialResolver } from '../../database/entities/credential-resolver'; +import type { DynamicCredentialResolverRepository } from '../../database/repositories/credential-resolver.repository'; +import { CredentialStorageError } from '../../errors/credential-storage.error'; +import type { DynamicCredentialResolverRegistry } from '../credential-resolver-registry.service'; +import { DynamicCredentialStorageService } from '../dynamic-credential-storage.service'; + +describe('DynamicCredentialStorageService', () => { + let service: DynamicCredentialStorageService; + let mockResolverRegistry: jest.Mocked; + let mockResolverRepository: jest.Mocked; + let mockLoadNodesAndCredentials: jest.Mocked; + let mockCipher: jest.Mocked; + let mockLogger: jest.Mocked; + + const createMockCredentialMetadata = ( + overrides: Partial = {}, + ): CredentialStoreMetadata => ({ + id: 'cred-123', + name: 'Test Credential', + type: 'oAuth2Api', + isResolvable: true, + resolverId: 'resolver-456', + ...overrides, + }); + + const createMockResolverEntity = ( + overrides: Partial = {}, + ): DynamicCredentialResolver => + ({ + id: 'resolver-456', + name: 'test-resolver', + type: 'stub-resolver-1.0', + config: 'encrypted-config', + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) as DynamicCredentialResolver; + + const createMockResolver = (): jest.Mocked => ({ + metadata: { + name: 'stub-resolver-1.0', + description: 'Test resolver', + }, + getSecret: jest.fn(), + setSecret: jest.fn(), + validateOptions: jest.fn(), + }); + + const createMockCredentialContext = (): ICredentialContext => ({ + version: 1, + identity: 'user-123', + metadata: {}, + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + } as unknown as jest.Mocked; + + mockResolverRegistry = { + getResolverByName: jest.fn(), + } as unknown as jest.Mocked; + + mockResolverRepository = { + findOneBy: jest.fn(), + } as unknown as jest.Mocked; + + mockLoadNodesAndCredentials = { + getCredential: jest.fn(), + } as unknown as jest.Mocked; + + mockCipher = { + decrypt: jest.fn(), + } as unknown as jest.Mocked; + + service = new DynamicCredentialStorageService( + mockResolverRegistry, + mockResolverRepository, + mockLoadNodesAndCredentials, + mockCipher, + mockLogger, + ); + }); + + describe('storeIfNeeded', () => { + const dynamicData: ICredentialDataDecryptedObject = { + accessToken: 'new-token', + refreshToken: 'new-refresh', + }; + const staticData: ICredentialDataDecryptedObject = { + clientId: 'client-123', + clientSecret: 'secret-456', + }; + const credentialContext = createMockCredentialContext(); + + describe('should return early without storing when', () => { + it('credential is not resolvable', async () => { + const metadata = createMockCredentialMetadata({ isResolvable: false }); + + await service.storeIfNeeded(metadata, dynamicData, credentialContext); + + expect(mockResolverRepository.findOneBy).not.toHaveBeenCalled(); + }); + + it('credential has no resolver ID and no workflow fallback', async () => { + const metadata = createMockCredentialMetadata({ resolverId: undefined }); + + await expect( + service.storeIfNeeded(metadata, dynamicData, credentialContext), + ).rejects.toThrow(CredentialStorageError); + await expect( + service.storeIfNeeded(metadata, dynamicData, credentialContext), + ).rejects.toThrow('Failed to store dynamic credentials data'); + }); + }); + + describe('should throw CredentialStorageError when', () => { + it('resolver entity is not found', async () => { + const metadata = createMockCredentialMetadata(); + mockResolverRepository.findOneBy.mockResolvedValue(null); + + await expect( + service.storeIfNeeded(metadata, dynamicData, credentialContext), + ).rejects.toThrow(CredentialStorageError); + await expect( + service.storeIfNeeded(metadata, dynamicData, credentialContext), + ).rejects.toThrow('Failed to store dynamic credentials data'); + }); + + it('resolver instance is not found in registry', async () => { + const metadata = createMockCredentialMetadata(); + const resolverEntity = createMockResolverEntity(); + + mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity); + mockResolverRegistry.getResolverByName.mockReturnValue(undefined); + + await expect( + service.storeIfNeeded(metadata, dynamicData, credentialContext), + ).rejects.toThrow(CredentialStorageError); + }); + + it('resolver.setSecret fails', async () => { + const metadata = createMockCredentialMetadata(); + const resolverEntity = createMockResolverEntity(); + const mockResolver = createMockResolver(); + + mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity); + mockResolverRegistry.getResolverByName.mockReturnValue(mockResolver); + mockCipher.decrypt.mockReturnValue(JSON.stringify({ prefix: 'test' })); + mockResolver.setSecret.mockRejectedValue(new Error('Storage failed')); + + await expect( + service.storeIfNeeded(metadata, dynamicData, credentialContext), + ).rejects.toThrow(CredentialStorageError); + await expect( + service.storeIfNeeded(metadata, dynamicData, credentialContext), + ).rejects.toThrow('Failed to store dynamic credentials data'); + }); + }); + + describe('should successfully store when', () => { + it('all conditions are met', async () => { + const metadata = createMockCredentialMetadata(); + const resolverEntity = createMockResolverEntity(); + const mockResolver = createMockResolver(); + + mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity); + mockResolverRegistry.getResolverByName.mockReturnValue(mockResolver); + mockCipher.decrypt.mockReturnValue(JSON.stringify({ prefix: 'test' })); + + await service.storeIfNeeded(metadata, dynamicData, credentialContext, staticData); + + expect(mockResolver.setSecret).toHaveBeenCalledWith( + 'cred-123', + credentialContext, + expect.objectContaining({ + accessToken: 'new-token', + refreshToken: 'new-refresh', + }), + expect.objectContaining({ + configuration: { prefix: 'test' }, + resolverName: 'test-resolver', + resolverId: 'resolver-456', + }), + ); + + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Successfully stored dynamic credentials', + expect.objectContaining({ + credentialId: 'cred-123', + resolverId: 'resolver-456', + identity: 'user-123', + }), + ); + }); + + it('shared fields are removed from stored data', async () => { + const metadata = createMockCredentialMetadata(); + const resolverEntity = createMockResolverEntity(); + const mockResolver = createMockResolver(); + + const dataWithSharedFields: ICredentialDataDecryptedObject = { + clientId: 'should-be-removed', + clientSecret: 'should-be-removed', + scopes: 'should-be-removed', + accessToken: 'keep-this', + refreshToken: 'keep-this', + }; + + mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity); + mockResolverRegistry.getResolverByName.mockReturnValue(mockResolver); + mockCipher.decrypt.mockReturnValue(JSON.stringify({})); + + await service.storeIfNeeded(metadata, dataWithSharedFields, credentialContext); + + const storedData = mockResolver.setSecret.mock.calls[0][2]; + expect(storedData).not.toHaveProperty('clientId'); + expect(storedData).not.toHaveProperty('clientSecret'); + expect(storedData).not.toHaveProperty('scopes'); + expect(storedData).toHaveProperty('accessToken', 'keep-this'); + expect(storedData).toHaveProperty('refreshToken', 'keep-this'); + }); + + it('uses workflow resolver when credential has no resolver', async () => { + const metadata = createMockCredentialMetadata({ resolverId: undefined }); + const workflowSettings: IWorkflowSettings = { + credentialResolverId: 'workflow-resolver-789', + }; + const resolverEntity = createMockResolverEntity({ id: 'workflow-resolver-789' }); + const mockResolver = createMockResolver(); + + mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity); + mockResolverRegistry.getResolverByName.mockReturnValue(mockResolver); + mockCipher.decrypt.mockReturnValue(JSON.stringify({})); + + await service.storeIfNeeded( + metadata, + dynamicData, + credentialContext, + undefined, + workflowSettings, + ); + + expect(mockResolverRepository.findOneBy).toHaveBeenCalledWith({ + id: 'workflow-resolver-789', + }); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Successfully stored dynamic credentials', + expect.objectContaining({ + resolverSource: 'workflow', + }), + ); + }); + + it('prefers credential resolver over workflow settings', async () => { + const metadata = createMockCredentialMetadata({ resolverId: 'cred-resolver-456' }); + const workflowSettings: IWorkflowSettings = { + credentialResolverId: 'workflow-resolver-789', + }; + const resolverEntity = createMockResolverEntity({ id: 'cred-resolver-456' }); + const mockResolver = createMockResolver(); + + mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity); + mockResolverRegistry.getResolverByName.mockReturnValue(mockResolver); + mockCipher.decrypt.mockReturnValue(JSON.stringify({})); + + await service.storeIfNeeded( + metadata, + dynamicData, + credentialContext, + undefined, + workflowSettings, + ); + + expect(mockResolverRepository.findOneBy).toHaveBeenCalledWith({ + id: 'cred-resolver-456', + }); + expect(mockLogger.debug).toHaveBeenCalledWith( + 'Successfully stored dynamic credentials', + expect.objectContaining({ + resolverSource: 'credential', + }), + ); + }); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential.service.test.ts index 9ae6b2073c0..0cdaab1bc48 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential.service.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential.service.test.ts @@ -7,7 +7,7 @@ import type { IExecutionContext, } from 'n8n-workflow'; -import type { CredentialResolveMetadata } from '@/credential-resolution-provider.interface'; +import type { CredentialResolveMetadata } from '@/credentials/credential-resolution-provider.interface'; import type { DynamicCredentialResolver } from '../../database/entities/credential-resolver'; import type { DynamicCredentialResolverRepository } from '../../database/repositories/credential-resolver.repository'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-storage.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-storage.service.ts new file mode 100644 index 00000000000..3dd3d265cd8 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential-storage.service.ts @@ -0,0 +1,122 @@ +import type { + CredentialStoreMetadata, + IDynamicCredentialStorageProvider, +} from '@/credentials/dynamic-credential-storage.interface'; +import { + type ICredentialContext, + type ICredentialDataDecryptedObject, + type IWorkflowSettings, + jsonParse, +} from 'n8n-workflow'; +import type { DynamicCredentialResolverRegistry } from './credential-resolver-registry.service'; +import type { DynamicCredentialResolverRepository } from '../database/repositories/credential-resolver.repository'; +import type { Cipher } from 'n8n-core'; +import { CredentialStorageError } from '../errors/credential-storage.error'; +import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import type { Logger } from '@n8n/backend-common'; +import { Service } from '@n8n/di'; + +@Service() +export class DynamicCredentialStorageService implements IDynamicCredentialStorageProvider { + constructor( + private readonly resolverRegistry: DynamicCredentialResolverRegistry, + private readonly resolverRepository: DynamicCredentialResolverRepository, + private readonly loadNodesAndCredentials: LoadNodesAndCredentials, + private readonly cipher: Cipher, + private readonly logger: Logger, + ) {} + + async storeIfNeeded( + credentialStoreMetadata: CredentialStoreMetadata, + dynamicData: ICredentialDataDecryptedObject, + credentialContext: ICredentialContext, + staticData?: ICredentialDataDecryptedObject, + workflowSettings?: IWorkflowSettings, + ): Promise { + try { + if (!credentialStoreMetadata.isResolvable) { + // Not resolvable - nothing to store + return; + } + + // Determine which resolver ID to use: credential's own resolver or workflow's fallback + const resolverId = + credentialStoreMetadata.resolverId ?? workflowSettings?.credentialResolverId; + + // Not resolvable - return static credentials + if (!resolverId) { + return this.handleNoResolver(credentialStoreMetadata); + } + + // Load resolver configuration + const resolverEntity = await this.resolverRepository.findOneBy({ + id: resolverId, + }); + + if (!resolverEntity) { + return this.handleMissingResolver(credentialStoreMetadata, resolverId); + } + + // Get resolver instance from registry + const resolver = this.resolverRegistry.getResolverByName(resolverEntity.type); + + if (!resolver) { + return this.handleMissingResolver(credentialStoreMetadata, resolverId); + } + + const decryptedConfig = this.cipher.decrypt(resolverEntity.config); + const resolverConfig = jsonParse>(decryptedConfig); + + // @ts-ignore -- to be fixed in future PR + const _credential = this.loadNodesAndCredentials.getCredential(credentialStoreMetadata.type); + + // TODO: Get shared fields based on credential type + // We will have to solve this in https://linear.app/n8n/issue/PAY-4301/solve-shared-fields-for-write-path-for-dynamic-credentials + const sharedFields: string[] = ['clientId', 'clientSecret', 'scopes']; + + const mergedDynamicData = { + ...(staticData ?? {}), + ...dynamicData, + }; + + for (const field of sharedFields) { + if (field in mergedDynamicData) { + delete mergedDynamicData[field]; + } + } + + await resolver.setSecret(credentialStoreMetadata.id, credentialContext, mergedDynamicData, { + configuration: resolverConfig, + resolverName: resolverEntity.name, + resolverId: resolverEntity.id, + }); + + this.logger.debug('Successfully stored dynamic credentials', { + credentialId: credentialStoreMetadata.id, + resolverId, + resolverSource: credentialStoreMetadata.resolverId ? 'credential' : 'workflow', + identity: credentialContext.identity, + }); + } catch (error) { + throw new CredentialStorageError( + `Failed to store dynamic credentials data for "${credentialStoreMetadata.name}"`, + { cause: error }, + ); + } + } + + private handleNoResolver(credentialStoreMetadata: CredentialStoreMetadata) { + throw new CredentialStorageError( + `No resolver found for credential "${credentialStoreMetadata.name}"`, + ); + } + + private handleMissingResolver( + credentialStoreMetadata: CredentialStoreMetadata, + resolverId: string, + ) { + throw new CredentialStorageError( + `Resolver "${resolverId}" not found for credential "${credentialStoreMetadata.name}"`, + ); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential.service.ts index 1def372d185..a3e927b5882 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential.service.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential.service.ts @@ -13,7 +13,7 @@ import { DynamicCredentialResolverRegistry } from './credential-resolver-registr import type { CredentialResolveMetadata, ICredentialResolutionProvider, -} from '../../../credential-resolution-provider.interface'; +} from '../../../credentials/credential-resolution-provider.interface'; import { DynamicCredentialResolverRepository } from '../database/repositories/credential-resolver.repository'; import { CredentialResolutionError } from '../errors/credential-resolution.error'; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/index.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/index.ts index 762498cac0d..7bf0bfd6742 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/index.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/index.ts @@ -1,3 +1,4 @@ export * from './credential-resolver-registry.service'; export * from './credential-resolver.service'; export * from './dynamic-credential.service'; +export * from './dynamic-credential-storage.service';