chore(core): Write path for dynamic credentials (#23026)

This commit is contained in:
Andreas Fitzek
2025-12-10 14:08:40 +01:00
committed by GitHub
parent c3190fcfec
commit 8a70151aa1
13 changed files with 805 additions and 42 deletions
@@ -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<INodeTypes>();
const mockNodesAndCredentials = mock<LoadNodesAndCredentials>();
const credentialsRepository = mock<CredentialsRepository>();
const mockLogger = mock<any>();
// Use a real instance of DynamicCredentialsProxy so setResolverProvider works
const dynamicCredentialProxy = new DynamicCredentialsProxy(mockLogger);
// Setup cipher for testing
const cipher = new Cipher(mock<InstanceSettings>({ 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 = {
+16 -27
View File
@@ -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;
@@ -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<Logger>;
let mockResolverProvider: jest.Mocked<ICredentialResolutionProvider>;
let mockStorageProvider: jest.Mocked<IDynamicCredentialStorageProvider>;
beforeEach(() => {
mockLogger = {
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
info: jest.fn(),
} as unknown as jest.Mocked<Logger>;
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();
});
});
});
@@ -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<void>;
}
@@ -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<ICredentialDataDecryptedObject> {
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<void> {
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,
);
}
}
@@ -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() {
@@ -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';
}
}
@@ -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<DynamicCredentialResolverRegistry>;
let mockResolverRepository: jest.Mocked<DynamicCredentialResolverRepository>;
let mockLoadNodesAndCredentials: jest.Mocked<LoadNodesAndCredentials>;
let mockCipher: jest.Mocked<Cipher>;
let mockLogger: jest.Mocked<Logger>;
const createMockCredentialMetadata = (
overrides: Partial<CredentialStoreMetadata> = {},
): CredentialStoreMetadata => ({
id: 'cred-123',
name: 'Test Credential',
type: 'oAuth2Api',
isResolvable: true,
resolverId: 'resolver-456',
...overrides,
});
const createMockResolverEntity = (
overrides: Partial<DynamicCredentialResolver> = {},
): 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<ICredentialResolver> => ({
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<Logger>;
mockResolverRegistry = {
getResolverByName: jest.fn(),
} as unknown as jest.Mocked<DynamicCredentialResolverRegistry>;
mockResolverRepository = {
findOneBy: jest.fn(),
} as unknown as jest.Mocked<DynamicCredentialResolverRepository>;
mockLoadNodesAndCredentials = {
getCredential: jest.fn(),
} as unknown as jest.Mocked<LoadNodesAndCredentials>;
mockCipher = {
decrypt: jest.fn(),
} as unknown as jest.Mocked<Cipher>;
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',
}),
);
});
});
});
});
@@ -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';
@@ -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<void> {
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<Record<string, unknown>>(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}"`,
);
}
}
@@ -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';
@@ -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';