mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
feat(core): Add credential resolver service for CRUD operations (#22653)
This commit is contained in:
@@ -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];
|
||||
|
||||
+5
-1
@@ -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;
|
||||
}
|
||||
|
||||
+7
@@ -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.`);
|
||||
}
|
||||
}
|
||||
+308
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
+170
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user