feat(core): Add credential resolver service for CRUD operations (#22653)

This commit is contained in:
Guillaume Jacquart
2025-12-04 20:20:24 +01:00
committed by GitHub
parent 803ab42164
commit b8d045b050
6 changed files with 492 additions and 1 deletions
@@ -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];
@@ -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;
}
@@ -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.`);
}
}
@@ -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<Logger>;
let mockRepository: jest.Mocked<DynamicCredentialResolverRepository>;
let mockRegistry: jest.Mocked<DynamicCredentialResolverRegistry>;
let mockCipher: jest.Mocked<Cipher>;
const mockResolverImplementation: jest.Mocked<ICredentialResolver> = {
metadata: {
name: 'test.resolver',
description: 'A test resolver',
},
getSecret: jest.fn(),
setSecret: jest.fn(),
validateOptions: jest.fn(),
};
const createMockEntity = (
overrides: Partial<DynamicCredentialResolver> = {},
): 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<Logger>;
mockRepository = {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
findOneBy: jest.fn(),
remove: jest.fn(),
} as unknown as jest.Mocked<DynamicCredentialResolverRepository>;
mockRegistry = {
getResolverByName: jest.fn(),
} as unknown as jest.Mocked<DynamicCredentialResolverRegistry>;
mockCipher = {
encrypt: jest.fn(),
decrypt: jest.fn(),
} as unknown as jest.Mocked<Cipher>;
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();
});
});
});
@@ -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<DynamicCredentialResolver> {
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<DynamicCredentialResolver[]> {
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<DynamicCredentialResolver> {
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<DynamicCredentialResolver> {
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<void> {
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<void> {
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<CredentialResolverConfiguration>(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;
}
}
@@ -1 +1,2 @@
export * from './credential-resolver-registry.service';
export * from './credential-resolver.service';