From b8d045b05084b401cf20d094b9d426cb41bcc8c6 Mon Sep 17 00:00:00 2001 From: Guillaume Jacquart Date: Thu, 4 Dec 2025 20:20:24 +0100 Subject: [PATCH] feat(core): Add credential resolver service for CRUD operations (#22653) --- .../@n8n/config/src/configs/logging.config.ts | 1 + .../database/entities/credential-resolver.ts | 6 +- .../credential-resolver-not-found.error.ts | 7 + .../credential-resolver.service.test.ts | 308 ++++++++++++++++++ .../services/credential-resolver.service.ts | 170 ++++++++++ .../dynamic-credentials.ee/services/index.ts | 1 + 6 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/errors/credential-resolver-not-found.error.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver.service.test.ts create mode 100644 packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts diff --git a/packages/@n8n/config/src/configs/logging.config.ts b/packages/@n8n/config/src/configs/logging.config.ts index 8a899329d32..fbeaddfb0d0 100644 --- a/packages/@n8n/config/src/configs/logging.config.ts +++ b/packages/@n8n/config/src/configs/logging.config.ts @@ -28,6 +28,7 @@ export const LOG_SCOPES = [ 'chat-hub', 'breaking-changes', 'circuit-breaker', + 'dynamic-credentials', ] as const; export type LogScope = (typeof LOG_SCOPES)[number]; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/database/entities/credential-resolver.ts b/packages/cli/src/modules/dynamic-credentials.ee/database/entities/credential-resolver.ts index 7c011a6bd08..a4c31322d91 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/database/entities/credential-resolver.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/database/entities/credential-resolver.ts @@ -1,5 +1,6 @@ -import { Column, Entity } from '@n8n/typeorm'; import { WithTimestampsAndStringId } from '@n8n/db'; +import type { CredentialResolverConfiguration } from '@n8n/decorators'; +import { Column, Entity } from '@n8n/typeorm'; @Entity() export class DynamicCredentialResolver extends WithTimestampsAndStringId { @@ -11,4 +12,7 @@ export class DynamicCredentialResolver extends WithTimestampsAndStringId { @Column({ type: 'text' }) config: string; + + /** Decrypted config, not persisted to the database */ + decryptedConfig?: CredentialResolverConfiguration; } diff --git a/packages/cli/src/modules/dynamic-credentials.ee/errors/credential-resolver-not-found.error.ts b/packages/cli/src/modules/dynamic-credentials.ee/errors/credential-resolver-not-found.error.ts new file mode 100644 index 00000000000..59c78ee3b66 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/errors/credential-resolver-not-found.error.ts @@ -0,0 +1,7 @@ +import { UserError } from 'n8n-workflow'; + +export class DynamicCredentialResolverNotFoundError extends UserError { + constructor(resolverId: string) { + super(`Credential resolver with ID "${resolverId}" does not exist.`); + } +} diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver.service.test.ts new file mode 100644 index 00000000000..2879d02b6e2 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/credential-resolver.service.test.ts @@ -0,0 +1,308 @@ +import type { Logger } from '@n8n/backend-common'; +import { + CredentialResolverValidationError, + type CredentialResolverConfiguration, + type ICredentialResolver, +} from '@n8n/decorators'; +import type { Cipher } from 'n8n-core'; +import { UnexpectedError } from 'n8n-workflow'; + +import { DynamicCredentialResolver } from '../../database/entities/credential-resolver'; +import type { DynamicCredentialResolverRepository } from '../../database/repositories/credential-resolver.repository'; +import { DynamicCredentialResolverNotFoundError } from '../../errors/credential-resolver-not-found.error'; +import type { DynamicCredentialResolverRegistry } from '../credential-resolver-registry.service'; +import { DynamicCredentialResolverService } from '../credential-resolver.service'; + +describe('DynamicCredentialResolverService', () => { + let service: DynamicCredentialResolverService; + let mockLogger: jest.Mocked; + let mockRepository: jest.Mocked; + let mockRegistry: jest.Mocked; + let mockCipher: jest.Mocked; + + const mockResolverImplementation: jest.Mocked = { + metadata: { + name: 'test.resolver', + description: 'A test resolver', + }, + getSecret: jest.fn(), + setSecret: jest.fn(), + validateOptions: jest.fn(), + }; + + const createMockEntity = ( + overrides: Partial = {}, + ): DynamicCredentialResolver => { + const entity = new DynamicCredentialResolver(); + entity.id = 'resolver-id-123'; + entity.name = 'Test Resolver'; + entity.type = 'test.resolver'; + entity.config = 'encrypted-config-data'; + entity.createdAt = new Date('2024-01-01'); + entity.updatedAt = new Date('2024-01-01'); + Object.assign(entity, overrides); + return entity; + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockLogger = { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + scoped: jest.fn().mockReturnThis(), + } as unknown as jest.Mocked; + + mockRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOneBy: jest.fn(), + remove: jest.fn(), + } as unknown as jest.Mocked; + + mockRegistry = { + getResolverByName: jest.fn(), + } as unknown as jest.Mocked; + + mockCipher = { + encrypt: jest.fn(), + decrypt: jest.fn(), + } as unknown as jest.Mocked; + + service = new DynamicCredentialResolverService( + mockLogger, + mockRepository, + mockRegistry, + mockCipher, + ); + }); + + describe('create', () => { + it('should create a resolver with encrypted config', async () => { + const config: CredentialResolverConfiguration = { prefix: 'test-prefix' }; + const savedEntity = createMockEntity(); + + mockRegistry.getResolverByName.mockReturnValue(mockResolverImplementation); + mockResolverImplementation.validateOptions.mockResolvedValue(undefined); + mockCipher.encrypt.mockReturnValue('encrypted-config-data'); + mockRepository.create.mockReturnValue(savedEntity); + mockRepository.save.mockResolvedValue(savedEntity); + mockCipher.decrypt.mockReturnValue(JSON.stringify(config)); + + const result = await service.create({ + name: 'Test Resolver', + type: 'test.resolver', + config, + }); + + expect(mockRegistry.getResolverByName).toHaveBeenCalledWith('test.resolver'); + expect(mockResolverImplementation.validateOptions).toHaveBeenCalledWith(config); + expect(mockCipher.encrypt).toHaveBeenCalledWith(config); + expect(mockRepository.create).toHaveBeenCalledWith({ + name: 'Test Resolver', + type: 'test.resolver', + config: 'encrypted-config-data', + }); + expect(mockRepository.save).toHaveBeenCalledWith(savedEntity); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Created credential resolver'), + ); + expect(result).toBeDefined(); + }); + + it('should throw CredentialResolverValidationError for unknown resolver type', async () => { + mockRegistry.getResolverByName.mockReturnValue(undefined); + + await expect( + service.create({ + name: 'Test Resolver', + type: 'unknown.resolver', + config: {}, + }), + ).rejects.toThrow(CredentialResolverValidationError); + + expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw CredentialResolverValidationError when config validation fails', async () => { + const config: CredentialResolverConfiguration = { invalidOption: 'value' }; + + mockRegistry.getResolverByName.mockReturnValue(mockResolverImplementation); + mockResolverImplementation.validateOptions.mockRejectedValue( + new CredentialResolverValidationError('Invalid option'), + ); + + await expect( + service.create({ + name: 'Test Resolver', + type: 'test.resolver', + config, + }), + ).rejects.toThrow(CredentialResolverValidationError); + + expect(mockRepository.create).not.toHaveBeenCalled(); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('findAll', () => { + it('should return all resolvers with decryptedConfig populated', async () => { + const entities = [ + createMockEntity({ id: 'id-1', name: 'Resolver 1' }), + createMockEntity({ id: 'id-2', name: 'Resolver 2' }), + ]; + const decryptedConfig = { prefix: 'test' }; + + mockRepository.find.mockResolvedValue(entities); + mockCipher.decrypt.mockReturnValue(JSON.stringify(decryptedConfig)); + + const result = await service.findAll(); + + expect(mockRepository.find).toHaveBeenCalled(); + expect(mockCipher.decrypt).toHaveBeenCalledTimes(2); + expect(result).toHaveLength(2); + expect(result[0].decryptedConfig).toEqual(decryptedConfig); + expect(result[1].decryptedConfig).toEqual(decryptedConfig); + }); + + it('should return empty array when no resolvers exist', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await service.findAll(); + + expect(result).toEqual([]); + }); + }); + + describe('findById', () => { + it('should return resolver with decryptedConfig populated', async () => { + const entity = createMockEntity(); + const decryptedConfig = { prefix: 'test' }; + + mockRepository.findOneBy.mockResolvedValue(entity); + mockCipher.decrypt.mockReturnValue(JSON.stringify(decryptedConfig)); + + const result = await service.findById('resolver-id-123'); + + expect(mockRepository.findOneBy).toHaveBeenCalledWith({ id: 'resolver-id-123' }); + expect(mockCipher.decrypt).toHaveBeenCalledWith('encrypted-config-data'); + expect(result.decryptedConfig).toEqual(decryptedConfig); + }); + + it('should throw DynamicCredentialResolverNotFoundError when resolver not found', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + await expect(service.findById('non-existent-id')).rejects.toThrow( + DynamicCredentialResolverNotFoundError, + ); + }); + + it('should throw UnexpectedError when decryption fails', async () => { + const entity = createMockEntity(); + + mockRepository.findOneBy.mockResolvedValue(entity); + mockCipher.decrypt.mockReturnValue('invalid-json{'); + + await expect(service.findById('resolver-id-123')).rejects.toThrow(UnexpectedError); + }); + }); + + describe('update', () => { + it('should update resolver name', async () => { + const entity = createMockEntity(); + const updatedEntity = createMockEntity({ name: 'Updated Name' }); + const decryptedConfig = { prefix: 'test' }; + + mockRepository.findOneBy.mockResolvedValue(entity); + mockRepository.save.mockResolvedValue(updatedEntity); + mockCipher.decrypt.mockReturnValue(JSON.stringify(decryptedConfig)); + + const result = await service.update('resolver-id-123', { name: 'Updated Name' }); + + expect(mockRepository.findOneBy).toHaveBeenCalledWith({ id: 'resolver-id-123' }); + expect(mockRepository.save).toHaveBeenCalled(); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Updated credential resolver'), + ); + expect(result.name).toBe('Updated Name'); + }); + + it('should update resolver config with encryption and validation', async () => { + const entity = createMockEntity(); + const newConfig: CredentialResolverConfiguration = { prefix: 'new-prefix' }; + const updatedEntity = createMockEntity({ config: 'new-encrypted-config' }); + + mockRepository.findOneBy.mockResolvedValue(entity); + mockRegistry.getResolverByName.mockReturnValue(mockResolverImplementation); + mockResolverImplementation.validateOptions.mockResolvedValue(undefined); + mockCipher.encrypt.mockReturnValue('new-encrypted-config'); + mockRepository.save.mockResolvedValue(updatedEntity); + mockCipher.decrypt.mockReturnValue(JSON.stringify(newConfig)); + + await service.update('resolver-id-123', { config: newConfig }); + + expect(mockRegistry.getResolverByName).toHaveBeenCalledWith('test.resolver'); + expect(mockResolverImplementation.validateOptions).toHaveBeenCalledWith(newConfig); + expect(mockCipher.encrypt).toHaveBeenCalledWith(newConfig); + expect(mockRepository.save).toHaveBeenCalled(); + }); + + it('should throw DynamicCredentialResolverNotFoundError when resolver not found', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + await expect(service.update('non-existent-id', { name: 'New Name' })).rejects.toThrow( + DynamicCredentialResolverNotFoundError, + ); + + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it('should throw CredentialResolverValidationError when config validation fails on update', async () => { + const entity = createMockEntity(); + const invalidConfig: CredentialResolverConfiguration = { badOption: 'value' }; + + mockRepository.findOneBy.mockResolvedValue(entity); + mockRegistry.getResolverByName.mockReturnValue(mockResolverImplementation); + mockResolverImplementation.validateOptions.mockRejectedValue( + new CredentialResolverValidationError('Invalid config'), + ); + + await expect(service.update('resolver-id-123', { config: invalidConfig })).rejects.toThrow( + CredentialResolverValidationError, + ); + + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should delete an existing resolver', async () => { + const entity = createMockEntity(); + + mockRepository.findOneBy.mockResolvedValue(entity); + mockRepository.remove.mockResolvedValue(entity); + + await service.delete('resolver-id-123'); + + expect(mockRepository.findOneBy).toHaveBeenCalledWith({ id: 'resolver-id-123' }); + expect(mockRepository.remove).toHaveBeenCalledWith(entity); + expect(mockLogger.debug).toHaveBeenCalledWith( + expect.stringContaining('Deleted credential resolver'), + ); + }); + + it('should throw DynamicCredentialResolverNotFoundError when resolver not found', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + await expect(service.delete('non-existent-id')).rejects.toThrow( + DynamicCredentialResolverNotFoundError, + ); + + expect(mockRepository.remove).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts new file mode 100644 index 00000000000..a72305b7965 --- /dev/null +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/credential-resolver.service.ts @@ -0,0 +1,170 @@ +import { Logger } from '@n8n/backend-common'; +import { + CredentialResolverConfiguration, + CredentialResolverValidationError, +} from '@n8n/decorators'; +import { Service } from '@n8n/di'; +import { Cipher } from 'n8n-core'; +import { jsonParse, UnexpectedError } from 'n8n-workflow'; + +import { DynamicCredentialResolverRegistry } from './credential-resolver-registry.service'; +import { DynamicCredentialResolver } from '../database/entities/credential-resolver'; +import { DynamicCredentialResolverRepository } from '../database/repositories/credential-resolver.repository'; +import { DynamicCredentialResolverNotFoundError } from '../errors/credential-resolver-not-found.error'; + +export interface CreateResolverParams { + name: string; + type: string; + config: CredentialResolverConfiguration; +} + +export interface UpdateResolverParams { + name?: string; + config?: CredentialResolverConfiguration; +} + +/** + * Service layer for managing DynamicCredentialResolver entities. + * Provides CRUD operations with: + * - Config encryption at rest + * - Validation against resolver type's config schema + */ +@Service() +export class DynamicCredentialResolverService { + constructor( + private readonly logger: Logger, + private readonly repository: DynamicCredentialResolverRepository, + private readonly registry: DynamicCredentialResolverRegistry, + private readonly cipher: Cipher, + ) { + this.logger = this.logger.scoped('dynamic-credentials'); + } + + /** + * Creates a new credential resolver. + * @throws {CredentialResolverValidationError} When the resolver type is unknown or config is invalid + */ + async create(params: CreateResolverParams): Promise { + await this.validateConfig(params.type, params.config); + + const encryptedConfig = this.encryptConfig(params.config); + + const resolver = this.repository.create({ + name: params.name, + type: params.type, + config: encryptedConfig, + }); + + const saved = await this.repository.save(resolver); + this.logger.debug(`Created credential resolver "${saved.name}" (${saved.id})`); + + return saved; + } + + /** + * Retrieves all credential resolvers. + * Config is returned decrypted. + */ + async findAll(): Promise { + const resolvers = await this.repository.find(); + return resolvers.map((resolver) => this.withDecryptedConfig(resolver)); + } + + /** + * Retrieves a credential resolver by ID. + * Config is returned decrypted. + * @throws {DynamicCredentialResolverNotFoundError} When resolver is not found + */ + async findById(id: string): Promise { + const resolver = await this.repository.findOneBy({ id }); + if (!resolver) { + throw new DynamicCredentialResolverNotFoundError(id); + } + return this.withDecryptedConfig(resolver); + } + + /** + * Updates an existing credential resolver. + * @throws {DynamicCredentialResolverNotFoundError} When resolver is not found + * @throws {CredentialResolverValidationError} When the config is invalid for the resolver type + */ + async update(id: string, params: UpdateResolverParams): Promise { + const existing = await this.repository.findOneBy({ id }); + if (!existing) { + throw new DynamicCredentialResolverNotFoundError(id); + } + + if (params.config !== undefined) { + await this.validateConfig(existing.type, params.config); + existing.config = this.encryptConfig(params.config); + } + + if (params.name !== undefined) { + existing.name = params.name; + } + + const saved = await this.repository.save(existing); + this.logger.debug(`Updated credential resolver "${saved.name}" (${saved.id})`); + + return this.withDecryptedConfig(saved); + } + + /** + * Deletes a credential resolver by ID. + * @throws {DynamicCredentialResolverNotFoundError} When resolver is not found + */ + async delete(id: string): Promise { + const existing = await this.repository.findOneBy({ id }); + if (!existing) { + throw new DynamicCredentialResolverNotFoundError(id); + } + + await this.repository.remove(existing); + this.logger.debug(`Deleted credential resolver "${existing.name}" (${id})`); + } + + /** + * Validates the config against the resolver type's schema. + * @throws {CredentialResolverValidationError} When the resolver type is unknown or config is invalid + */ + private async validateConfig( + type: string, + config: CredentialResolverConfiguration, + ): Promise { + const resolverImplementation = this.registry.getResolverByName(type); + if (!resolverImplementation) { + throw new CredentialResolverValidationError(`Unknown resolver type: ${type}`); + } + + await resolverImplementation.validateOptions(config); + } + + /** + * Encrypts the config for storage. + */ + private encryptConfig(config: CredentialResolverConfiguration): string { + return this.cipher.encrypt(config); + } + + /** + * Decrypts the config from storage. + */ + private decryptConfig(encryptedConfig: string): CredentialResolverConfiguration { + const decryptedData = this.cipher.decrypt(encryptedConfig); + try { + return jsonParse(decryptedData); + } catch { + throw new UnexpectedError( + 'Credential resolver config could not be decrypted. The likely reason is that a different "encryptionKey" was used to encrypt the data.', + ); + } + } + + /** + * Populates the decryptedConfig field on the resolver. + */ + private withDecryptedConfig(resolver: DynamicCredentialResolver): DynamicCredentialResolver { + resolver.decryptedConfig = this.decryptConfig(resolver.config); + return resolver; + } +} 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 342ab6b6b4f..7405c3ed8eb 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/index.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/index.ts @@ -1 +1,2 @@ export * from './credential-resolver-registry.service'; +export * from './credential-resolver.service';