mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-24 23:48:42 +08:00
chore(core): Write path for dynamic credentials (#23026)
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
+304
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+122
@@ -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}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user