Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions packages/@n8n/db/src/entities/credentials-entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ export class CredentialsEntity extends WithTimestampsAndStringId implements ICre
@Column({ default: false })
isGlobal: boolean;

/**
* Whether the credential can be dynamically resolved by a resolver.
*/
@Column({ default: false })
isResolvable: boolean;

/**
* Whether the credential resolver should allow falling back to static credentials
* if dynamic resolution fails.
*/
@Column({ default: false })
resolvableAllowFallback: boolean;

/**
* ID of the dynamic credential resolver associated with this credential.
*/
@Column({ type: 'varchar', nullable: true })
resolverId?: string;

toJSON() {
const { shared, ...rest } = this;
return rest;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';

const credentialsTableName = 'credentials_entity';
const resolverTableName = 'dynamic_credential_resolver';
const FOREIGN_KEY_NAME = 'credentials_entity_resolverId_foreign';

export class AddResolvableFieldsToCredentials1764689448000 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, addForeignKey, column } }: MigrationContext) {
// Add isResolvable, resolvableAllowFallback, and resolverId columns to credentials_entity
await addColumns(credentialsTableName, [
column('isResolvable').bool.notNull.default(false),
column('resolvableAllowFallback').bool.notNull.default(false),
column('resolverId').varchar(16),
]);

// Add foreign key constraint
await addForeignKey(
credentialsTableName,
'resolverId',
[resolverTableName, 'id'],
FOREIGN_KEY_NAME,
'SET NULL',
);
}

async down({ schemaBuilder: { dropColumns, dropForeignKey } }: MigrationContext) {
// Drop foreign key constraint
await dropForeignKey(
credentialsTableName,
'resolverId',
[resolverTableName, 'id'],
FOREIGN_KEY_NAME,
);

// Drop columns from credentials_entity
await dropColumns(credentialsTableName, [
'isResolvable',
'resolvableAllowFallback',
'resolverId',
]);
}
}
2 changes: 2 additions & 0 deletions packages/@n8n/db/src/migrations/mysqldb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ import { CreateWorkflowPublishHistoryTable1764167920585 } from '../common/176416
import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837-AddCreatorIdToProjectTable';
import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable';
import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable';
import { AddResolvableFieldsToCredentials1764689448000 } from '../common/1764689448000-AddResolvableFieldsToCredentials';
import type { Migration } from '../migration-types';

export const mysqlMigrations: Migration[] = [
Expand Down Expand Up @@ -249,4 +250,5 @@ export const mysqlMigrations: Migration[] = [
AddCreatorIdToProjectTable1764276827837,
CreateDynamicCredentialResolverTable1764682447000,
AddDynamicCredentialEntryTable1764689388394,
AddResolvableFieldsToCredentials1764689448000,
];
2 changes: 2 additions & 0 deletions packages/@n8n/db/src/migrations/postgresdb/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ import { CreateWorkflowPublishHistoryTable1764167920585 } from '../common/176416
import { AddCreatorIdToProjectTable1764276827837 } from '../common/1764276827837-AddCreatorIdToProjectTable';
import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable';
import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable';
import { AddResolvableFieldsToCredentials1764689448000 } from '../common/1764689448000-AddResolvableFieldsToCredentials';
import type { Migration } from '../migration-types';

export const postgresMigrations: Migration[] = [
Expand Down Expand Up @@ -249,4 +250,5 @@ export const postgresMigrations: Migration[] = [
AddCreatorIdToProjectTable1764276827837,
CreateDynamicCredentialResolverTable1764682447000,
AddDynamicCredentialEntryTable1764689388394,
AddResolvableFieldsToCredentials1764689448000,
];
2 changes: 2 additions & 0 deletions packages/@n8n/db/src/migrations/sqlite/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import { CreateBinaryDataTable1763716655000 } from '../common/1763716655000-Crea
import { CreateWorkflowPublishHistoryTable1764167920585 } from '../common/1764167920585-CreateWorkflowPublishHistoryTable';
import { CreateDynamicCredentialResolverTable1764682447000 } from '../common/1764682447000-CreateCredentialResolverTable';
import { AddDynamicCredentialEntryTable1764689388394 } from '../common/1764689388394-AddDynamicCredentialEntryTable';
import { AddResolvableFieldsToCredentials1764689448000 } from '../common/1764689448000-AddResolvableFieldsToCredentials';
import type { Migration } from '../migration-types';

const sqliteMigrations: Migration[] = [
Expand Down Expand Up @@ -241,6 +242,7 @@ const sqliteMigrations: Migration[] = [
AddCreatorIdToProjectTable1764276827837,
CreateDynamicCredentialResolverTable1764682447000,
AddDynamicCredentialEntryTable1764689388394,
AddResolvableFieldsToCredentials1764689448000,
];

export { sqliteMigrations };
185 changes: 185 additions & 0 deletions packages/cli/src/__tests__/credentials-helper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,4 +381,189 @@ describe('CredentialsHelper', () => {
expect(parsedUpdatedData.oauthTokenData.token_type).toBe('Bearer');
});
});

describe('getDecrypted - credential resolution integration', () => {
const mockCredentialResolutionProvider = {
resolveIfNeeded: jest.fn(),
};

const mockAdditionalData = {
executionContext: {
version: 1,
establishedAt: Date.now(),
source: 'manual' as const,
credentials: 'encrypted-credential-context',
},
workflowSettings: {
executionTimeout: 300,
credentialResolverId: 'workflow-resolver-123',
},
} as any;

const nodeCredentials: INodeCredentialsDetails = {
id: 'cred-456',
name: 'Test Credentials',
};

const credentialType = 'testApi';

const mockCredentialEntity = {
id: 'cred-456',
name: 'Test Credentials',
type: credentialType,
data: cipher.encrypt({ apiKey: 'static-key' }),
isResolvable: true,
resolverId: 'credential-resolver-789',
resolvableAllowFallback: false,
} as CredentialsEntity;

beforeEach(() => {
jest.clearAllMocks();
credentialsRepository.findOneByOrFail.mockResolvedValue(mockCredentialEntity);
});

test('should call resolveIfNeeded when credentialResolutionProvider is set', async () => {
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);

const resolvedData = { apiKey: 'dynamic-key' };
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue(resolvedData);

const result = await credentialsHelper.getDecrypted(
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
undefined, // executeData
true, // raw = true to get the resolved data directly
);

expect(mockCredentialResolutionProvider.resolveIfNeeded).toHaveBeenCalledWith(
mockCredentialEntity,
{ apiKey: 'static-key' },
mockAdditionalData.executionContext,
mockAdditionalData.workflowSettings,
);
expect(result).toEqual(resolvedData);
});

test('should pass executionContext from additionalData to resolver', async () => {
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' });

await credentialsHelper.getDecrypted(
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
undefined,
true,
);

const call = mockCredentialResolutionProvider.resolveIfNeeded.mock.calls[0];
expect(call[2]).toBe(mockAdditionalData.executionContext);
});

test('should pass workflowSettings from additionalData to resolver', async () => {
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' });

await credentialsHelper.getDecrypted(
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
undefined,
true,
);

const call = mockCredentialResolutionProvider.resolveIfNeeded.mock.calls[0];
expect(call[3]).toBe(mockAdditionalData.workflowSettings);
});

test('should skip resolution when credentialResolutionProvider is not set', async () => {
// Create a new instance without provider
const helperWithoutProvider = new CredentialsHelper(
new CredentialTypes(mockNodesAndCredentials),
mock(),
credentialsRepository,
mock(),
mock(),
);

const result = await helperWithoutProvider.getDecrypted(
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
undefined,
true,
);

// Should return static decrypted data
expect(result).toEqual({ apiKey: 'static-key' });
});

test('should use resolved data instead of static data when resolution succeeds', async () => {
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);

const dynamicData = { apiKey: 'dynamic-key', extraField: 'extra-value' };
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue(dynamicData);

const result = await credentialsHelper.getDecrypted(
mockAdditionalData,
nodeCredentials,
credentialType,
'manual',
undefined,
true,
);

expect(result).toEqual(dynamicData);
expect(result).not.toEqual({ apiKey: 'static-key' });
});

test('should handle missing executionContext gracefully', async () => {
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' });

const additionalDataWithoutContext = {
...mockAdditionalData,
executionContext: undefined,
};

await credentialsHelper.getDecrypted(
additionalDataWithoutContext,
nodeCredentials,
credentialType,
'manual',
undefined,
true,
);

const call = mockCredentialResolutionProvider.resolveIfNeeded.mock.calls[0];
expect(call[2]).toBeUndefined();
});

test('should handle missing workflowSettings gracefully', async () => {
credentialsHelper.setCredentialResolutionProvider(mockCredentialResolutionProvider);
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ apiKey: 'resolved' });

const additionalDataWithoutSettings = {
...mockAdditionalData,
workflowSettings: undefined,
};

await credentialsHelper.getDecrypted(
additionalDataWithoutSettings,
nodeCredentials,
credentialType,
'manual',
undefined,
true,
);

const call = mockCredentialResolutionProvider.resolveIfNeeded.mock.calls[0];
expect(call[3]).toBeUndefined();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -550,5 +550,17 @@ describe('WorkflowExecuteAdditionalData', () => {

expect(additionalData.executionTimeoutTimestamp).toBe(executionTimeoutTimestamp);
});

it('should include workflowSettings when provided', async () => {
const workflowSettings = {
executionTimeout: 300,
credentialResolverId: 'test-resolver-123',
};
const additionalData = await getBase({
workflowSettings,
});

expect(additionalData.workflowSettings).toBe(workflowSettings);
});
});
});
4 changes: 3 additions & 1 deletion packages/cli/src/__tests__/workflow-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ describe('run', () => {
const data = mock<IWorkflowExecutionDataProcess>({
triggerToStartFrom: { name: 'trigger', data: mock<ITaskData>() },

workflowData: { nodes: [], id: 'workflow-id' },
workflowData: { nodes: [], id: 'workflow-id', settings: undefined },
executionData: undefined,
startNodes: [mock<StartNodeData>()],
destinationNode: undefined,
Expand All @@ -256,6 +256,8 @@ describe('run', () => {
expect(WorkflowExecuteAdditionalData.getBase).toHaveBeenCalledWith({
userId: data.userId,
workflowId: 'workflow-id',
executionTimeoutTimestamp: undefined,
workflowSettings: {},
});
expect(ManualExecutionService.prototype.runManually).toHaveBeenCalledWith(
data,
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/active-workflow-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,10 @@ export class ActiveWorkflowManager {

const mode = 'internal';

const additionalData = await WorkflowExecuteAdditionalData.getBase({ workflowId: workflow.id });
const additionalData = await WorkflowExecuteAdditionalData.getBase({
workflowId: workflow.id,
workflowSettings: workflowData.settings,
});

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

Expand Down Expand Up @@ -615,6 +618,7 @@ export class ActiveWorkflowManager {

const additionalData = await WorkflowExecuteAdditionalData.getBase({
workflowId: workflow.id,
workflowSettings: dbWorkflow.settings,
});

if (shouldAddWebhooks) {
Expand Down
29 changes: 29 additions & 0 deletions packages/cli/src/credential-resolution-provider.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { CredentialsEntity } from '@n8n/db';
import type {
ICredentialDataDecryptedObject,
IExecutionContext,
IWorkflowSettings,
} from 'n8n-workflow';

/**
* Interface for credential resolution providers.
* Implementations can provide dynamic credential resolution logic.
* This allows EE modules to hook into credential resolution without tight coupling.
*/
export interface ICredentialResolutionProvider {
/**
* Resolves credentials dynamically if configured, otherwise returns static data.
*
* @param credentialsEntity The credential entity from database
* @param staticData The decrypted static credential data
* @param executionContext Optional execution context containing credential context
* @param workflowSettings Optional workflow settings containing resolver ID fallback
* @returns Resolved credential data (either dynamic or static)
*/
resolveIfNeeded(
credentialsEntity: CredentialsEntity,
staticData: ICredentialDataDecryptedObject,
executionContext?: IExecutionContext,
workflowSettings?: IWorkflowSettings,
): Promise<ICredentialDataDecryptedObject>;
}
Loading
Loading