Skip to content

Commit ce26bee

Browse files
feat(core): Add dynamic credential resolution with credential resolver id and workflow settings resolver id
1 parent 8908516 commit ce26bee

15 files changed

+1175
-14
lines changed

packages/cli/src/__tests__/credentials-helper.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,4 +381,189 @@ describe('CredentialsHelper', () => {
381381
expect(parsedUpdatedData.oauthTokenData.token_type).toBe('Bearer');
382382
});
383383
});
384+
385+
describe('getDecrypted - credential resolution integration', () => {
386+
const mockCredentialResolutionProvider = {
387+
resolveIfNeeded: jest.fn(),
388+
};
389+
390+
const mockAdditionalData = {
391+
executionContext: {
392+
version: 1,
393+
establishedAt: Date.now(),
394+
source: 'manual' as const,
395+
credentials: 'encrypted-credential-context',
396+
},
397+
workflowSettings: {
398+
executionTimeout: 300,
399+
credentialResolverId: 'workflow-resolver-123',
400+
},
401+
} as any;
402+
403+
const nodeCredentials: INodeCredentialsDetails = {
404+
id: 'cred-456',
405+
name: 'Test Credentials',
406+
};
407+
408+
const credentialType = 'testApi';
409+
410+
const mockCredentialEntity = {
411+
id: 'cred-456',
412+
name: 'Test Credentials',
413+
type: credentialType,
414+
data: cipher.encrypt({ apiKey: 'static-key' }),
415+
isResolvable: true,
416+
resolverId: 'credential-resolver-789',
417+
resolvableAllowFallback: false,
418+
} as CredentialsEntity;
419+
420+
beforeEach(() => {
421+
jest.clearAllMocks();
422+
credentialsRepository.findOneByOrFail.mockResolvedValue(mockCredentialEntity);
423+
});
424+
425+
test('should call resolveIfNeeded when credentialResolutionProvider is set', async () => {
426+
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
427+
428+
const resolvedData = { apiKey: 'dynamic-key' };
429+
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue(resolvedData);
430+
431+
const result = await credentialsHelper.getDecrypted(
432+
mockAdditionalData,
433+
nodeCredentials,
434+
credentialType,
435+
'manual',
436+
undefined, // executeData
437+
true, // raw = true to get the resolved data directly
438+
);
439+
440+
expect(mockCredentialResolutionProvider.resolveIfNeeded).toHaveBeenCalledWith(
441+
mockCredentialEntity,
442+
{ apiKey: 'static-key' },
443+
mockAdditionalData.executionContext,
444+
mockAdditionalData.workflowSettings,
445+
);
446+
expect(result).toEqual(resolvedData);
447+
});
448+
449+
test('should pass executionContext from additionalData to resolver', async () => {
450+
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
451+
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' });
452+
453+
await credentialsHelper.getDecrypted(
454+
mockAdditionalData,
455+
nodeCredentials,
456+
credentialType,
457+
'manual',
458+
undefined,
459+
true,
460+
);
461+
462+
const call = mockCredentialResolutionProvider.resolveIfNeeded.mock.calls[0];
463+
expect(call[2]).toBe(mockAdditionalData.executionContext);
464+
});
465+
466+
test('should pass workflowSettings from additionalData to resolver', async () => {
467+
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
468+
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' });
469+
470+
await credentialsHelper.getDecrypted(
471+
mockAdditionalData,
472+
nodeCredentials,
473+
credentialType,
474+
'manual',
475+
undefined,
476+
true,
477+
);
478+
479+
const call = mockCredentialResolutionProvider.resolveIfNeeded.mock.calls[0];
480+
expect(call[3]).toBe(mockAdditionalData.workflowSettings);
481+
});
482+
483+
test('should skip resolution when credentialResolutionProvider is not set', async () => {
484+
// Create a new instance without provider
485+
const helperWithoutProvider = new CredentialsHelper(
486+
new CredentialTypes(mockNodesAndCredentials),
487+
mock(),
488+
credentialsRepository,
489+
mock(),
490+
mock(),
491+
);
492+
493+
const result = await helperWithoutProvider.getDecrypted(
494+
mockAdditionalData,
495+
nodeCredentials,
496+
credentialType,
497+
'manual',
498+
undefined,
499+
true,
500+
);
501+
502+
// Should return static decrypted data
503+
expect(result).toEqual({ apiKey: 'static-key' });
504+
});
505+
506+
test('should use resolved data instead of static data when resolution succeeds', async () => {
507+
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
508+
509+
const dynamicData = { apiKey: 'dynamic-key', extraField: 'extra-value' };
510+
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue(dynamicData);
511+
512+
const result = await credentialsHelper.getDecrypted(
513+
mockAdditionalData,
514+
nodeCredentials,
515+
credentialType,
516+
'manual',
517+
undefined,
518+
true,
519+
);
520+
521+
expect(result).toEqual(dynamicData);
522+
expect(result).not.toEqual({ apiKey: 'static-key' });
523+
});
524+
525+
test('should handle missing executionContext gracefully', async () => {
526+
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
527+
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' });
528+
529+
const additionalDataWithoutContext = {
530+
...mockAdditionalData,
531+
executionContext: undefined,
532+
};
533+
534+
await credentialsHelper.getDecrypted(
535+
additionalDataWithoutContext,
536+
nodeCredentials,
537+
credentialType,
538+
'manual',
539+
undefined,
540+
true,
541+
);
542+
543+
const call = mockCredentialResolutionProvider.resolveIfNeeded.mock.calls[0];
544+
expect(call[2]).toBeUndefined();
545+
});
546+
547+
test('should handle missing workflowSettings gracefully', async () => {
548+
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
549+
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' });
550+
551+
const additionalDataWithoutSettings = {
552+
...mockAdditionalData,
553+
workflowSettings: undefined,
554+
};
555+
556+
await credentialsHelper.getDecrypted(
557+
additionalDataWithoutSettings,
558+
nodeCredentials,
559+
credentialType,
560+
'manual',
561+
undefined,
562+
true,
563+
);
564+
565+
const call = mockCredentialResolutionProvider.resolveIfNeeded.mock.calls[0];
566+
expect(call[3]).toBeUndefined();
567+
});
568+
});
384569
});

packages/cli/src/__tests__/workflow-execute-additional-data.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,5 +550,17 @@ describe('WorkflowExecuteAdditionalData', () => {
550550

551551
expect(additionalData.executionTimeoutTimestamp).toBe(executionTimeoutTimestamp);
552552
});
553+
554+
it('should include workflowSettings when provided', async () => {
555+
const workflowSettings = {
556+
executionTimeout: 300,
557+
credentialResolverId: 'test-resolver-123',
558+
};
559+
const additionalData = await getBase({
560+
workflowSettings,
561+
});
562+
563+
expect(additionalData.workflowSettings).toBe(workflowSettings);
564+
});
553565
});
554566
});

packages/cli/src/active-workflow-manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,10 @@ export class ActiveWorkflowManager {
263263

264264
const mode = 'internal';
265265

266-
const additionalData = await WorkflowExecuteAdditionalData.getBase({ workflowId: workflow.id });
266+
const additionalData = await WorkflowExecuteAdditionalData.getBase({
267+
workflowId: workflow.id,
268+
workflowSettings: workflowData.settings,
269+
});
267270

268271
const webhooks = WebhookHelpers.getWorkflowWebhooks(workflow, additionalData, undefined, true);
269272

@@ -615,6 +618,7 @@ export class ActiveWorkflowManager {
615618

616619
const additionalData = await WorkflowExecuteAdditionalData.getBase({
617620
workflowId: workflow.id,
621+
workflowSettings: dbWorkflow.settings,
618622
});
619623

620624
if (shouldAddWebhooks) {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { CredentialsEntity } from '@n8n/db';
2+
import type {
3+
ICredentialDataDecryptedObject,
4+
IExecutionContext,
5+
IWorkflowSettings,
6+
} from 'n8n-workflow';
7+
8+
/**
9+
* Interface for credential resolution providers.
10+
* Implementations can provide dynamic credential resolution logic.
11+
* This allows EE modules to hook into credential resolution without tight coupling.
12+
*/
13+
export interface ICredentialResolutionProvider {
14+
/**
15+
* Resolves credentials dynamically if configured, otherwise returns static data.
16+
*
17+
* @param credentialsEntity The credential entity from database
18+
* @param staticData The decrypted static credential data
19+
* @param executionContext Optional execution context containing credential context
20+
* @param workflowSettings Optional workflow settings containing resolver ID fallback
21+
* @returns Resolved credential data (either dynamic or static)
22+
*/
23+
resolveIfNeeded(
24+
credentialsEntity: CredentialsEntity,
25+
staticData: ICredentialDataDecryptedObject,
26+
executionContext?: IExecutionContext,
27+
workflowSettings?: IWorkflowSettings,
28+
): Promise<ICredentialDataDecryptedObject>;
29+
}

packages/cli/src/credentials-helper.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,14 @@ import {
4242
isExpression,
4343
} from 'n8n-workflow';
4444

45-
import { CredentialTypes } from '@/credential-types';
46-
import { CredentialsOverwrites } from '@/credentials-overwrites';
47-
4845
import { RESPONSE_ERROR_MESSAGES } from './constants';
4946
import { CredentialNotFoundError } from './errors/credential-not-found.error';
5047
import { CacheService } from './services/cache/cache.service';
5148

49+
import type { ICredentialResolutionProvider } from '@/credential-resolution-provider.interface';
50+
import { CredentialTypes } from '@/credential-types';
51+
import { CredentialsOverwrites } from '@/credentials-overwrites';
52+
5253
const mockNode = {
5354
name: '',
5455
typeVersion: 1,
@@ -85,6 +86,8 @@ const mockNodeTypes: INodeTypes = {
8586

8687
@Service()
8788
export class CredentialsHelper extends ICredentialsHelper {
89+
private credentialResolutionProvider?: ICredentialResolutionProvider;
90+
8891
constructor(
8992
private readonly credentialTypes: CredentialTypes,
9093
private readonly credentialsOverwrites: CredentialsOverwrites,
@@ -95,6 +98,14 @@ export class CredentialsHelper extends ICredentialsHelper {
9598
super();
9699
}
97100

101+
/**
102+
* Registers a credential resolution provider (EE feature).
103+
* Called by the dynamic credentials module during initialization.
104+
*/
105+
setCredentialResolutionProvider(provider: ICredentialResolutionProvider): void {
106+
this.credentialResolutionProvider = provider;
107+
}
108+
98109
/**
99110
* Add the required authentication information to the request
100111
*/
@@ -254,6 +265,22 @@ export class CredentialsHelper extends ICredentialsHelper {
254265
nodeCredential: INodeCredentialsDetails,
255266
type: string,
256267
): Promise<Credentials> {
268+
const credential = await this.getCredentialsEntity(nodeCredential, type);
269+
270+
return new Credentials(
271+
{ id: credential.id, name: credential.name },
272+
credential.type,
273+
credential.data,
274+
);
275+
}
276+
277+
/**
278+
* Loads the credentials entity from the database
279+
*/
280+
private async getCredentialsEntity(
281+
nodeCredential: INodeCredentialsDetails,
282+
type: string,
283+
): Promise<CredentialsEntity> {
257284
if (!nodeCredential.id) {
258285
throw new UnexpectedError('Found credential with no ID.', {
259286
extra: { credentialName: nodeCredential.name },
@@ -276,11 +303,7 @@ export class CredentialsHelper extends ICredentialsHelper {
276303
throw error;
277304
}
278305

279-
return new Credentials(
280-
{ id: credential.id, name: credential.name },
281-
credential.type,
282-
credential.data,
283-
);
306+
return credential;
284307
}
285308

286309
/**
@@ -336,8 +359,23 @@ export class CredentialsHelper extends ICredentialsHelper {
336359
raw?: boolean,
337360
expressionResolveValues?: ICredentialsExpressionResolveValues,
338361
): Promise<ICredentialDataDecryptedObject> {
339-
const credentials = await this.getCredentials(nodeCredentials, type);
340-
const decryptedDataOriginal = credentials.getData();
362+
const credentialsEntity = await this.getCredentialsEntity(nodeCredentials, type);
363+
const credentials = new Credentials(
364+
{ id: credentialsEntity.id, name: credentialsEntity.name },
365+
credentialsEntity.type,
366+
credentialsEntity.data,
367+
);
368+
let decryptedDataOriginal = credentials.getData();
369+
370+
// Resolve dynamic credentials if configured (EE feature)
371+
if (this.credentialResolutionProvider) {
372+
decryptedDataOriginal = await this.credentialResolutionProvider.resolveIfNeeded(
373+
credentialsEntity,
374+
decryptedDataOriginal,
375+
additionalData.executionContext,
376+
additionalData.workflowSettings,
377+
);
378+
}
341379

342380
if (raw === true) {
343381
return decryptedDataOriginal;

packages/cli/src/modules/dynamic-credentials.ee/dynamic-credentials.module.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,17 @@ export class DynamicCredentialsModule implements ModuleInterface {
77
async init() {
88
await import('./context-establishment-hooks');
99
await import('./credential-resolvers');
10-
const { DynamicCredentialResolverRegistry } = await import('./services');
10+
const { DynamicCredentialResolverRegistry, DynamicCredentialService } = await import(
11+
'./services'
12+
);
1113

1214
await Container.get(DynamicCredentialResolverRegistry).init();
15+
16+
// Register the credential resolution provider with CredentialsHelper
17+
const { CredentialsHelper } = await import('@/credentials-helper');
18+
const credentialsHelper = Container.get(CredentialsHelper);
19+
const dynamicCredentialService = Container.get(DynamicCredentialService);
20+
credentialsHelper.setCredentialResolutionProvider(dynamicCredentialService);
1321
}
1422

1523
async entities() {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { OperationalError } from 'n8n-workflow';
2+
3+
export class CredentialResolutionError extends OperationalError {
4+
constructor(message: string, options?: { cause?: unknown }) {
5+
super(message, options);
6+
this.name = 'CredentialResolutionError';
7+
}
8+
}

0 commit comments

Comments
 (0)