diff --git a/packages/@n8n/db/src/repositories/credentials.repository.ts b/packages/@n8n/db/src/repositories/credentials.repository.ts index 972cc294139..20a00e15e52 100644 --- a/packages/@n8n/db/src/repositories/credentials.repository.ts +++ b/packages/@n8n/db/src/repositories/credentials.repository.ts @@ -78,7 +78,6 @@ export class CredentialsRepository extends Repository { 'isGlobal', 'isResolvable', 'resolverId', - 'resolvableAllowFallback', ]; if (!listQueryOptions) { @@ -329,7 +328,6 @@ export class CredentialsRepository extends Repository { 'isGlobal', 'isResolvable', 'resolverId', - 'resolvableAllowFallback', ]; if (options.select) { diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index 63288c1e4ec..2bcacdc5ac2 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -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, ); diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index bdaa7a33b9d..c077be29070 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -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, diff --git a/packages/cli/src/credentials/credential-resolution-provider.interface.ts b/packages/cli/src/credentials/credential-resolution-provider.interface.ts index 7ca829a3e5b..d71268df659 100644 --- a/packages/cli/src/credentials/credential-resolution-provider.interface.ts +++ b/packages/cli/src/credentials/credential-resolution-provider.interface.ts @@ -10,7 +10,6 @@ export type CredentialResolveMetadata = { /** Credential type (e.g., 'oAuth2Api') */ type: string; resolverId?: string; - resolvableAllowFallback?: boolean; isResolvable: boolean; }; diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential.service.test.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential.service.test.ts index bc6312c8da8..d3511cd2ec5 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential.service.test.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/__tests__/dynamic-credential.service.test.ts @@ -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', diff --git a/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential.service.ts b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential.service.ts index 976efcc51c8..0d9144b4e32 100644 --- a/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential.service.ts +++ b/packages/cli/src/modules/dynamic-credentials.ee/services/dynamic-credential.service.ts @@ -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}"`, diff --git a/packages/cli/src/workflow-execute-additional-data.ts b/packages/cli/src/workflow-execute-additional-data.ts index ff3c89267dd..0c6d77588aa 100644 --- a/packages/cli/src/workflow-execute-additional-data.ts +++ b/packages/cli/src/workflow-execute-additional-data.ts @@ -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; } diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index 35391125950..a98bb747e69 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -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( additionalData: IWorkflowExecuteAdditionalData, jobType: string,