mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-29 19:10:43 +08:00
feat(core): Static fallback on dynamic credentials for manual executions (#26406)
This commit is contained in:
@@ -78,7 +78,6 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
|
||||
'isGlobal',
|
||||
'isResolvable',
|
||||
'resolverId',
|
||||
'resolvableAllowFallback',
|
||||
];
|
||||
|
||||
if (!listQueryOptions) {
|
||||
@@ -329,7 +328,6 @@ export class CredentialsRepository extends Repository<CredentialsEntity> {
|
||||
'isGlobal',
|
||||
'isResolvable',
|
||||
'resolverId',
|
||||
'resolvableAllowFallback',
|
||||
];
|
||||
|
||||
if (options.select) {
|
||||
|
||||
@@ -605,7 +605,6 @@ describe('CredentialsHelper', () => {
|
||||
type: credentialType,
|
||||
data: cipher.encrypt({ apiKey: 'static-key' }),
|
||||
isResolvable: false,
|
||||
resolvableAllowFallback: false,
|
||||
} as CredentialsEntity;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -629,7 +628,7 @@ describe('CredentialsHelper', () => {
|
||||
mockAdditionalData,
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
'manual',
|
||||
'trigger',
|
||||
undefined, // executeData
|
||||
true, // raw = true to get the resolved data directly
|
||||
);
|
||||
@@ -641,7 +640,6 @@ describe('CredentialsHelper', () => {
|
||||
isResolvable: false,
|
||||
type: 'testApi',
|
||||
resolverId: undefined,
|
||||
resolvableAllowFallback: false,
|
||||
},
|
||||
{ apiKey: 'static-key' },
|
||||
mockAdditionalData.executionContext,
|
||||
@@ -662,7 +660,7 @@ describe('CredentialsHelper', () => {
|
||||
mockAdditionalData,
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
'manual',
|
||||
'trigger',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
@@ -683,7 +681,7 @@ describe('CredentialsHelper', () => {
|
||||
mockAdditionalData,
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
'manual',
|
||||
'trigger',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
@@ -731,7 +729,7 @@ describe('CredentialsHelper', () => {
|
||||
mockAdditionalData,
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
'manual',
|
||||
'trigger',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
@@ -765,6 +763,28 @@ describe('CredentialsHelper', () => {
|
||||
expect(result).toEqual({ apiKey: 'static-key' });
|
||||
});
|
||||
|
||||
test('should resolve in manual mode when credentials context is present (test webhook with identity extractor)', async () => {
|
||||
dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider);
|
||||
const dynamicData = { apiKey: 'dynamic-key' };
|
||||
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({
|
||||
data: dynamicData,
|
||||
isDynamic: true,
|
||||
});
|
||||
|
||||
// mockAdditionalData has credentials context set — simulates a test webhook run
|
||||
const result = await credentialsHelper.getDecrypted(
|
||||
mockAdditionalData,
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
'manual',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(mockCredentialResolutionProvider.resolveIfNeeded).toHaveBeenCalled();
|
||||
expect(result).toEqual(dynamicData);
|
||||
});
|
||||
|
||||
test('should skip resolution when credentials context is missing', async () => {
|
||||
dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider);
|
||||
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({
|
||||
@@ -797,6 +817,33 @@ describe('CredentialsHelper', () => {
|
||||
expect(result).toEqual({ apiKey: 'static-key' });
|
||||
});
|
||||
|
||||
test('should skip resolution in a subworkflow when rootExecutionMode is manual', async () => {
|
||||
dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider);
|
||||
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({
|
||||
data: { apiKey: 'dynamic-key' },
|
||||
isDynamic: true,
|
||||
});
|
||||
|
||||
// Simulates a subworkflow: local mode is 'integrated' but root was 'manual'
|
||||
const subworkflowAdditionalData = {
|
||||
...mockAdditionalData,
|
||||
executionContext: undefined,
|
||||
rootExecutionMode: 'manual' as const,
|
||||
};
|
||||
|
||||
const result = await credentialsHelper.getDecrypted(
|
||||
subworkflowAdditionalData,
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
'integrated',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(mockCredentialResolutionProvider.resolveIfNeeded).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ apiKey: 'static-key' });
|
||||
});
|
||||
|
||||
test('should handle missing workflowSettings gracefully', async () => {
|
||||
dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider);
|
||||
mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({
|
||||
@@ -813,7 +860,7 @@ describe('CredentialsHelper', () => {
|
||||
additionalDataWithoutSettings,
|
||||
nodeCredentials,
|
||||
credentialType,
|
||||
'manual',
|
||||
'trigger',
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -361,11 +361,18 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||
// Check if credential can use external secrets for expression resolution
|
||||
const canUseExternalSecrets = await this.credentialCanUseExternalSecrets(nodeCredentials);
|
||||
|
||||
/**
|
||||
* We skip dynamic credentials resolution when no credentials context is present.
|
||||
* This helps workflow developers to run workflows with static credentials.
|
||||
*/
|
||||
if (additionalData.executionContext?.credentials !== undefined) {
|
||||
// In manual or internal mode (or when the root execution is manual, e.g. a subworkflow
|
||||
// called from a manual parent), skip dynamic resolution unless a credentials context is
|
||||
// present (set by webhook triggers with an identity extractor). Canvas node tests
|
||||
// have no incoming request, so we fall back to static data for easier developer
|
||||
// testing. Internal mode is used by OAuth authorize/revoke flows which are not
|
||||
// actual workflow executions and should not trigger dynamic resolution.
|
||||
// For all other modes (especially production), always attempt resolution —
|
||||
// missing credentials will surface an error rather than silently falling back to
|
||||
// static data.
|
||||
const effectiveMode = additionalData.rootExecutionMode ?? mode;
|
||||
const skipDynamicResolution = effectiveMode === 'manual' || effectiveMode === 'internal';
|
||||
if (additionalData.executionContext?.credentials !== undefined || !skipDynamicResolution) {
|
||||
// Resolve dynamic credentials if configured (EE feature)
|
||||
const resolveResult = await this.dynamicCredentialsProxy.resolveIfNeeded(
|
||||
{
|
||||
@@ -374,7 +381,6 @@ export class CredentialsHelper extends ICredentialsHelper {
|
||||
type: credentialsEntity.type,
|
||||
isResolvable: credentialsEntity.isResolvable,
|
||||
resolverId: credentialsEntity.resolverId ?? undefined,
|
||||
resolvableAllowFallback: credentialsEntity.resolvableAllowFallback,
|
||||
},
|
||||
decryptedDataOriginal,
|
||||
additionalData.executionContext,
|
||||
|
||||
@@ -10,7 +10,6 @@ export type CredentialResolveMetadata = {
|
||||
/** Credential type (e.g., 'oAuth2Api') */
|
||||
type: string;
|
||||
resolverId?: string;
|
||||
resolvableAllowFallback?: boolean;
|
||||
isResolvable: boolean;
|
||||
};
|
||||
|
||||
|
||||
+93
-202
@@ -45,7 +45,6 @@ describe('DynamicCredentialService', () => {
|
||||
id: 'cred-123',
|
||||
name: 'Test Credential',
|
||||
isResolvable: true,
|
||||
resolvableAllowFallback: false,
|
||||
resolverId: 'resolver-456',
|
||||
...overrides,
|
||||
}) as CredentialResolveMetadata;
|
||||
@@ -234,163 +233,11 @@ describe('DynamicCredentialService', () => {
|
||||
expectStaticResult(result);
|
||||
expect(mockResolverRepository.findOneBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolver entity is not found and fallback is allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: true,
|
||||
});
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
const result = await service.resolveIfNeeded(credentialsEntity, staticData, undefined);
|
||||
|
||||
expectStaticResult(result);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Resolver not found, falling back to static credentials',
|
||||
expect.objectContaining({
|
||||
credentialId: 'cred-123',
|
||||
resolverId: 'resolver-456',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolver instance is not found in registry and fallback is allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: true,
|
||||
});
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
|
||||
mockResolverRegistry.getResolverByTypename.mockReturnValue(undefined);
|
||||
|
||||
const result = await service.resolveIfNeeded(credentialsEntity, staticData, undefined);
|
||||
|
||||
expectStaticResult(result);
|
||||
expect(mockLogger.debug).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('execution context is missing and fallback is allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: true,
|
||||
});
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver();
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
|
||||
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||
|
||||
const result = await service.resolveIfNeeded(credentialsEntity, staticData, undefined);
|
||||
|
||||
expectStaticResult(result);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'No execution context available, falling back to static credentials',
|
||||
expect.objectContaining({
|
||||
credentialId: 'cred-123',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('credential context decryption fails and fallback is allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: true,
|
||||
});
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver();
|
||||
const executionContext = createMockExecutionContext('encrypted-credentials');
|
||||
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
|
||||
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||
mockCipher.decrypt.mockImplementation(() => {
|
||||
throw new Error('Decryption failed'); // Fails on first call (execution context)
|
||||
});
|
||||
|
||||
const result = await service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expectStaticResult(result);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Failed to decrypt credential context from execution context',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolver getSecret fails and fallback is allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: true,
|
||||
});
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver(false); // Will fail
|
||||
const executionContext = createMockExecutionContext('encrypted-credentials');
|
||||
const credentialContext = createMockCredentialContext();
|
||||
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
|
||||
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||
mockCipher.decrypt
|
||||
.mockReturnValueOnce(JSON.stringify(credentialContext)) // First call: decrypt credential context
|
||||
.mockReturnValueOnce(JSON.stringify({ prefix: 'test' })); // Second call: decrypt resolver config
|
||||
|
||||
const result = await service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expectStaticResult(result);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Dynamic credential resolution failed, falling back to static',
|
||||
expect.objectContaining({
|
||||
credentialId: 'cred-123',
|
||||
error: 'Resolution failed',
|
||||
isDataNotFound: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolver throws CredentialResolverDataNotFoundError and fallback is allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: true,
|
||||
});
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver(false, true); // Throws DataNotFoundError
|
||||
const executionContext = createMockExecutionContext('encrypted-credentials');
|
||||
const credentialContext = createMockCredentialContext();
|
||||
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
|
||||
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||
mockCipher.decrypt
|
||||
.mockReturnValueOnce(JSON.stringify(credentialContext)) // First call: decrypt execution context
|
||||
.mockReturnValueOnce(JSON.stringify({ prefix: 'test' })); // Second call: decrypt resolver config
|
||||
|
||||
const result = await service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expectStaticResult(result);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Dynamic credential resolution failed, falling back to static',
|
||||
expect.objectContaining({
|
||||
isDataNotFound: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should throw error when', () => {
|
||||
it('resolver entity is not found and fallback is not allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: false,
|
||||
});
|
||||
it('resolver entity is not found', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata();
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
@@ -434,10 +281,8 @@ describe('DynamicCredentialService', () => {
|
||||
).rejects.toThrow(CredentialResolutionError);
|
||||
});
|
||||
|
||||
it('resolver instance is not found in registry and fallback is not allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: false,
|
||||
});
|
||||
it('resolver instance is not found in registry', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata();
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
|
||||
@@ -448,10 +293,8 @@ describe('DynamicCredentialService', () => {
|
||||
).rejects.toThrow(CredentialResolutionError);
|
||||
});
|
||||
|
||||
it('execution context is missing and fallback is not allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: false,
|
||||
});
|
||||
it('execution context is missing', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata();
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver();
|
||||
|
||||
@@ -469,10 +312,8 @@ describe('DynamicCredentialService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('resolver getSecret fails and fallback is not allowed', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: false,
|
||||
});
|
||||
it('resolver getSecret fails', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata();
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver(false); // Will fail
|
||||
const executionContext = createMockExecutionContext('encrypted-credentials');
|
||||
@@ -506,10 +347,64 @@ describe('DynamicCredentialService', () => {
|
||||
).rejects.toThrow('Failed to resolve dynamic credentials for "Test Credential"');
|
||||
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Dynamic credential resolution failed without fallback',
|
||||
'Dynamic credential resolution failed',
|
||||
expect.objectContaining({
|
||||
credentialId: 'cred-123',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('credential context decryption fails', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata();
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver();
|
||||
const executionContext = createMockExecutionContext('encrypted-credentials');
|
||||
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
|
||||
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||
mockCipher.decrypt.mockImplementation(() => {
|
||||
throw new Error('Decryption failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toThrow(CredentialResolutionError);
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'Failed to decrypt credential context from execution context',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('resolver throws CredentialResolverDataNotFoundError', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata();
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver(false, true); // Throws DataNotFoundError
|
||||
const executionContext = createMockExecutionContext('encrypted-credentials');
|
||||
const credentialContext = createMockCredentialContext();
|
||||
const additionalData = createMockAdditionalData('exec-123', {}, executionContext);
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
|
||||
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||
mockCipher.decrypt
|
||||
.mockReturnValueOnce(JSON.stringify(credentialContext))
|
||||
.mockReturnValueOnce(JSON.stringify({ prefix: 'test' }));
|
||||
|
||||
await expect(
|
||||
service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toThrow(CredentialResolutionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('should successfully resolve dynamic credentials when', () => {
|
||||
@@ -654,10 +549,8 @@ describe('DynamicCredentialService', () => {
|
||||
});
|
||||
|
||||
describe('should handle edge cases', () => {
|
||||
it('execution context without credentials field', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: true,
|
||||
});
|
||||
it('execution context without credentials field throws', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata();
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver();
|
||||
const executionContext = createMockExecutionContext(undefined);
|
||||
@@ -666,21 +559,20 @@ describe('DynamicCredentialService', () => {
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(resolverEntity);
|
||||
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||
|
||||
const result = await service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
undefined,
|
||||
);
|
||||
await expect(
|
||||
service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toThrow(CredentialResolutionError);
|
||||
|
||||
expectStaticResult(result);
|
||||
expect(mockCipher.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalid JSON in encrypted credentials', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolvableAllowFallback: true,
|
||||
});
|
||||
it('invalid JSON in encrypted credentials throws', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata();
|
||||
const resolverEntity = createMockResolverEntity();
|
||||
const mockResolver = createMockResolver();
|
||||
const executionContext = createMockExecutionContext('encrypted-credentials');
|
||||
@@ -690,15 +582,14 @@ describe('DynamicCredentialService', () => {
|
||||
mockResolverRegistry.getResolverByTypename.mockReturnValue(mockResolver);
|
||||
mockCipher.decrypt.mockReturnValue('not-valid-json');
|
||||
|
||||
const result = await service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
undefined,
|
||||
);
|
||||
|
||||
expectStaticResult(result);
|
||||
expect(mockLogger.debug).toHaveBeenCalled();
|
||||
await expect(
|
||||
service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
undefined,
|
||||
),
|
||||
).rejects.toThrow(CredentialResolutionError);
|
||||
});
|
||||
|
||||
it('empty resolver config', async () => {
|
||||
@@ -821,10 +712,9 @@ describe('DynamicCredentialService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should fall back to static when workflow resolver not found and fallback allowed', async () => {
|
||||
it('should throw when workflow resolver not found', async () => {
|
||||
const credentialsEntity = createMockCredentialsMetadata({
|
||||
resolverId: undefined,
|
||||
resolvableAllowFallback: true,
|
||||
});
|
||||
const executionContext = createMockExecutionContext('encrypted-credentials');
|
||||
const additionalData = {
|
||||
@@ -836,16 +726,17 @@ describe('DynamicCredentialService', () => {
|
||||
|
||||
mockResolverRepository.findOneBy.mockResolvedValue(null);
|
||||
|
||||
const result = await service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
additionalData.workflowSettings,
|
||||
);
|
||||
await expect(
|
||||
service.resolveIfNeeded(
|
||||
credentialsEntity,
|
||||
staticData,
|
||||
additionalData.executionContext,
|
||||
additionalData.workflowSettings,
|
||||
),
|
||||
).rejects.toThrow(CredentialResolutionError);
|
||||
|
||||
expectStaticResult(result);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Resolver not found, falling back to static credentials',
|
||||
'Resolver not found for dynamic credential',
|
||||
expect.objectContaining({
|
||||
resolverId: 'workflow-resolver-789',
|
||||
resolverSource: 'workflow',
|
||||
|
||||
+26
-49
@@ -1,5 +1,4 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { CredentialResolverDataNotFoundError } from '@n8n/decorators';
|
||||
import { Service } from '@n8n/di';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { Cipher } from 'n8n-core';
|
||||
@@ -70,7 +69,7 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
|
||||
}
|
||||
|
||||
if (!resolverId) {
|
||||
return this.handleMissingResolver(credentialsResolveMetadata, staticData, resolverId);
|
||||
return this.handleMissingResolver(credentialsResolveMetadata, resolverId);
|
||||
}
|
||||
|
||||
// Load resolver configuration
|
||||
@@ -79,21 +78,21 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
|
||||
});
|
||||
|
||||
if (!resolverEntity) {
|
||||
return this.handleMissingResolver(credentialsResolveMetadata, staticData, resolverId);
|
||||
return this.handleMissingResolver(credentialsResolveMetadata, resolverId);
|
||||
}
|
||||
|
||||
// Get resolver instance from registry
|
||||
const resolver = this.resolverRegistry.getResolverByTypename(resolverEntity.type);
|
||||
|
||||
if (!resolver) {
|
||||
return this.handleMissingResolver(credentialsResolveMetadata, staticData, resolverId);
|
||||
return this.handleMissingResolver(credentialsResolveMetadata, resolverId);
|
||||
}
|
||||
|
||||
// Build credential context from execution context
|
||||
const credentialContext = this.buildCredentialContext(executionContext);
|
||||
|
||||
if (!credentialContext) {
|
||||
return this.handleMissingContext(credentialsResolveMetadata, staticData);
|
||||
return this.handleMissingContext(credentialsResolveMetadata);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -141,7 +140,7 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
|
||||
// Adds and override static data with dynamically resolved data
|
||||
return { data: { ...staticData, ...dynamicData }, isDynamic: true };
|
||||
} catch (error) {
|
||||
return this.handleResolutionError(credentialsResolveMetadata, staticData, error, resolverId);
|
||||
return this.handleResolutionError(credentialsResolveMetadata, error, resolverId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,34 +165,20 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles resolution errors with fallback logic
|
||||
* Handles resolution errors by always throwing.
|
||||
* Dynamic credentials must resolve successfully — no silent fallback to static data.
|
||||
*/
|
||||
private handleResolutionError(
|
||||
credentialsResolveMetadata: CredentialResolveMetadata,
|
||||
staticData: ICredentialDataDecryptedObject,
|
||||
error: unknown,
|
||||
resolverId: string,
|
||||
): CredentialResolutionResult {
|
||||
const isDataNotFound = error instanceof CredentialResolverDataNotFoundError;
|
||||
|
||||
if (credentialsResolveMetadata.resolvableAllowFallback) {
|
||||
this.logger.debug('Dynamic credential resolution failed, falling back to static', {
|
||||
credentialId: credentialsResolveMetadata.id,
|
||||
credentialName: credentialsResolveMetadata.name,
|
||||
resolverId,
|
||||
resolverSource: credentialsResolveMetadata.resolverId ? 'credential' : 'workflow',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
isDataNotFound,
|
||||
});
|
||||
return { data: staticData, isDynamic: false };
|
||||
}
|
||||
|
||||
this.logger.debug('Dynamic credential resolution failed without fallback', {
|
||||
): never {
|
||||
this.logger.debug('Dynamic credential resolution failed', {
|
||||
credentialId: credentialsResolveMetadata.id,
|
||||
credentialName: credentialsResolveMetadata.name,
|
||||
resolverId,
|
||||
resolverSource: credentialsResolveMetadata.resolverId ? 'credential' : 'workflow',
|
||||
error,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
throw new CredentialResolutionError(
|
||||
@@ -203,22 +188,19 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles missing resolver with fallback logic
|
||||
* Handles missing resolver by always throwing.
|
||||
* Dynamic credentials must have a valid resolver — no silent fallback to static data.
|
||||
*/
|
||||
private handleMissingResolver(
|
||||
credentialsResolveMetadata: CredentialResolveMetadata,
|
||||
staticData: ICredentialDataDecryptedObject,
|
||||
resolverId?: string,
|
||||
): CredentialResolutionResult {
|
||||
if (credentialsResolveMetadata.resolvableAllowFallback) {
|
||||
this.logger.debug('Resolver not found, falling back to static credentials', {
|
||||
credentialId: credentialsResolveMetadata.id,
|
||||
credentialName: credentialsResolveMetadata.name,
|
||||
resolverId,
|
||||
resolverSource: credentialsResolveMetadata.resolverId ? 'credential' : 'workflow',
|
||||
});
|
||||
return { data: staticData, isDynamic: false };
|
||||
}
|
||||
): never {
|
||||
this.logger.debug('Resolver not found for dynamic credential', {
|
||||
credentialId: credentialsResolveMetadata.id,
|
||||
credentialName: credentialsResolveMetadata.name,
|
||||
resolverId,
|
||||
resolverSource: credentialsResolveMetadata.resolverId ? 'credential' : 'workflow',
|
||||
});
|
||||
|
||||
throw new CredentialResolutionError(
|
||||
`Resolver "${resolverId ?? 'unknown'}" not found for credential "${credentialsResolveMetadata.name}"`,
|
||||
@@ -226,19 +208,14 @@ export class DynamicCredentialService implements ICredentialResolutionProvider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles missing execution context with fallback logic
|
||||
* Handles missing execution context by always throwing.
|
||||
* Dynamic credentials require an execution context — no silent fallback to static data.
|
||||
*/
|
||||
private handleMissingContext(
|
||||
credentialsResolveMetadata: CredentialResolveMetadata,
|
||||
staticData: ICredentialDataDecryptedObject,
|
||||
): CredentialResolutionResult {
|
||||
if (credentialsResolveMetadata.resolvableAllowFallback) {
|
||||
this.logger.debug('No execution context available, falling back to static credentials', {
|
||||
credentialId: credentialsResolveMetadata.id,
|
||||
credentialName: credentialsResolveMetadata.name,
|
||||
});
|
||||
return { data: staticData, isDynamic: false };
|
||||
}
|
||||
private handleMissingContext(credentialsResolveMetadata: CredentialResolveMetadata): never {
|
||||
this.logger.debug('No execution context available for dynamic credential', {
|
||||
credentialId: credentialsResolveMetadata.id,
|
||||
credentialName: credentialsResolveMetadata.name,
|
||||
});
|
||||
|
||||
throw new CredentialResolutionError(
|
||||
`Cannot resolve dynamic credentials without execution context for "${credentialsResolveMetadata.name}"`,
|
||||
|
||||
@@ -310,6 +310,10 @@ async function startExecution(
|
||||
// This one already contains changes to talk to parent process
|
||||
// and get executionID from `activeExecutions` running on main process
|
||||
additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow;
|
||||
// Propagate the root execution mode so nested subworkflows retain the original
|
||||
// mode (e.g. 'manual') even though their own WorkflowExecute runs as 'integrated'
|
||||
additionalDataIntegrated.rootExecutionMode =
|
||||
additionalData.rootExecutionMode ?? options.executionMode;
|
||||
if (additionalData.httpResponse) {
|
||||
additionalDataIntegrated.httpResponse = additionalData.httpResponse;
|
||||
}
|
||||
|
||||
@@ -2919,6 +2919,12 @@ export interface IWorkflowExecuteAdditionalData {
|
||||
variables: IDataObject;
|
||||
logAiEvent: (eventName: AiEvent, payload: AiEventPayload) => void;
|
||||
parentCallbackManager?: CallbackManager;
|
||||
/**
|
||||
* The execution mode of the root (top-level) workflow. Used to propagate manual
|
||||
* mode context into subworkflows so credential resolution can fall back to static
|
||||
* data consistently across the entire execution tree.
|
||||
*/
|
||||
rootExecutionMode?: WorkflowExecuteMode;
|
||||
startRunnerTask<T, E = unknown>(
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
jobType: string,
|
||||
|
||||
Reference in New Issue
Block a user