diff --git a/packages/cli/src/__tests__/credentials-helper.test.ts b/packages/cli/src/__tests__/credentials-helper.test.ts index b83f49f0d31..559f07e7227 100644 --- a/packages/cli/src/__tests__/credentials-helper.test.ts +++ b/packages/cli/src/__tests__/credentials-helper.test.ts @@ -52,6 +52,7 @@ import { CredentialsHelper } from '@/credentials-helper'; import type { CredentialsOverwrites } from '@/credentials-overwrites'; import { CredentialNotFoundError } from '@/errors/credential-not-found.error'; import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { MissingExecutionContextError } from '@/modules/dynamic-credentials.ee/errors/missing-execution-context.error'; import type { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config'; import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service'; import type { AiGatewayService } from '@/services/ai-gateway.service'; @@ -1615,6 +1616,30 @@ describe('CredentialsHelper', () => { }; const credentialType = 'testApi'; + const triggerExecuteData = { + data: {}, + node: { + id: 'gmail-trigger', + name: 'Gmail Trigger', + type: 'n8n-nodes-base.gmailTrigger', + typeVersion: 1, + parameters: {}, + position: [0, 0], + }, + source: null, + } satisfies IExecuteData; + const actionExecuteData = { + data: {}, + node: { + id: 'gmail-node', + name: 'Gmail', + type: 'n8n-nodes-base.gmail', + typeVersion: 1, + parameters: {}, + position: [0, 0], + }, + source: null, + } satisfies IExecuteData; const mockCredentialEntity = { id: 'cred-456', @@ -1757,8 +1782,123 @@ describe('CredentialsHelper', () => { expect(result).not.toEqual({ apiKey: 'static-key' }); }); - test('should skip resolution when executionContext is missing (manual mode)', async () => { + test.each([ + { + name: 'system resolver', + credentialResolverId: undefined, + expectedMessage: + "End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed.", + }, + { + name: 'custom resolver', + credentialResolverId: 'custom-resolver', + expectedMessage: + 'End-user credentials with this resolver need a trigger that extracts an identity. Configure an identity extractor on the trigger, or switch this credential to Fixed.', + }, + ])( + 'should explain unsupported manual triggers using the $name', + async ({ credentialResolverId, expectedMessage }) => { + dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); + mockCredentialResolutionProvider.getSystemResolverId.mockReturnValue('system-n8n'); + + credentialsRepository.findOneByOrFail.mockResolvedValue({ + ...mockCredentialEntity, + isResolvable: true, + } as CredentialsEntity); + + await expect( + credentialsHelper.getDecrypted( + { + ...mockAdditionalData, + executionContext: undefined, + workflowSettings: { + ...mockAdditionalData.workflowSettings, + credentialResolverId, + }, + }, + nodeCredentials, + credentialType, + 'manual', + triggerExecuteData, + true, + undefined, + { credentialUsage: 'trigger' }, + ), + ).rejects.toThrow(expectedMessage); + + expect(mockCredentialResolutionProvider.resolveIfNeeded).not.toHaveBeenCalled(); + }, + ); + + test('should surface resolver configuration errors instead of an unsupported-trigger error', async () => { dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); + mockCredentialResolutionProvider.getSystemResolverId.mockReturnValue(null); + mockCredentialResolutionProvider.resolveIfNeeded.mockRejectedValue( + new Error('Credential resolver is not configured'), + ); + credentialsRepository.findOneByOrFail.mockResolvedValue({ + ...mockCredentialEntity, + isResolvable: true, + } as CredentialsEntity); + + await expect( + credentialsHelper.getDecrypted( + { + ...mockAdditionalData, + executionContext: undefined, + workflowSettings: { + ...mockAdditionalData.workflowSettings, + credentialResolverId: undefined, + }, + }, + nodeCredentials, + credentialType, + 'manual', + triggerExecuteData, + true, + undefined, + { credentialUsage: 'trigger' }, + ), + ).rejects.toThrow('Credential resolver is not configured'); + + expect(mockCredentialResolutionProvider.resolveIfNeeded).toHaveBeenCalledOnce(); + }); + + test('should defer to the resolver when trigger execution data is unavailable', async () => { + dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); + mockCredentialResolutionProvider.resolveIfNeeded.mockRejectedValue( + new MissingExecutionContextError(), + ); + credentialsRepository.findOneByOrFail.mockResolvedValue({ + ...mockCredentialEntity, + isResolvable: true, + } as CredentialsEntity); + + await expect( + credentialsHelper.getDecrypted( + { + ...mockAdditionalData, + executionContext: undefined, + }, + nodeCredentials, + credentialType, + 'manual', + undefined, + true, + undefined, + { credentialUsage: 'trigger' }, + ), + ).rejects.toThrow(MissingExecutionContextError); + + expect(mockCredentialResolutionProvider.resolveIfNeeded).toHaveBeenCalledOnce(); + }); + + test('should preserve static credentials for manual action-node tests without context', async () => { + dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); + credentialsRepository.findOneByOrFail.mockResolvedValue({ + ...mockCredentialEntity, + isResolvable: true, + } as CredentialsEntity); mockCredentialResolutionProvider.resolveIfNeeded.mockResolvedValue({ data: { apiKey: 'resolved' }, isDynamic: false, @@ -1774,7 +1914,7 @@ describe('CredentialsHelper', () => { nodeCredentials, credentialType, 'manual', - undefined, + actionExecuteData, true, ); @@ -1782,22 +1922,27 @@ 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 () => { + test('should resolve manual trigger credentials when execution context is present', async () => { dynamicCredentialProxy.setResolverProvider(mockCredentialResolutionProvider); + credentialsRepository.findOneByOrFail.mockResolvedValue({ + ...mockCredentialEntity, + isResolvable: true, + } as CredentialsEntity); 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, + triggerExecuteData, true, + undefined, + { credentialUsage: 'trigger' }, ); expect(mockCredentialResolutionProvider.resolveIfNeeded).toHaveBeenCalled(); diff --git a/packages/cli/src/credentials-helper.ts b/packages/cli/src/credentials-helper.ts index 4cfa2be2d21..b6a5add43ac 100644 --- a/packages/cli/src/credentials-helper.ts +++ b/packages/cli/src/credentials-helper.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { SYSTEM_RESOLVER_ID } from '@n8n/api-types'; import { LicenseState } from '@n8n/backend-common'; import type { CredentialsEntity, ICredentialsDb } from '@n8n/db'; import { CredentialsRepository, SecretsProviderConnectionRepository } from '@n8n/db'; @@ -23,6 +24,7 @@ import type { IHttpRequestHelper, IWorkflowExecuteAdditionalData, IExecuteData, + IGetDecryptedCredentialsOptions, IDataObject, } from 'n8n-workflow'; import { @@ -31,6 +33,7 @@ import { Workflow, UnexpectedError, UserError, + classifyTriggerIdentity, getCredentialOwnRequestAllowedDomains, isExpression, jsonParse, @@ -50,6 +53,7 @@ import { DynamicCredentialsProxy } from './credentials/dynamic-credentials-proxy import { createMockNodeTypes } from './credentials/mock-node-types'; import { CredentialMissingIdError } from './errors/credential-missing-id.error'; import { CredentialNotFoundError } from './errors/credential-not-found.error'; +import { UnsupportedEndUserCredentialTriggerError } from './errors/unsupported-end-user-credential-trigger.error'; /** * Applies the credential's allowlist to the requests its `preAuthentication` hook issues. @@ -520,6 +524,30 @@ export class CredentialsHelper extends ICredentialsHelper { } } + private assertTriggerSupportsEndUserCredential( + credentialsEntity: CredentialsEntity, + additionalData: IWorkflowExecuteAdditionalData, + executeData: IExecuteData | undefined, + ): void { + if (!executeData) return; + + const resolverId = + credentialsEntity.resolverId ?? + this.dynamicCredentialsProxy.getEffectiveResolverId(additionalData.workflowSettings); + if (!resolverId) return; + + const isSystemResolver = resolverId === SYSTEM_RESOLVER_ID; + const { providesN8nIdentity, providesExternalIdentity } = classifyTriggerIdentity( + executeData.node.type, + executeData.node.parameters, + ); + const supportsResolver = isSystemResolver ? providesN8nIdentity : providesExternalIdentity; + + if (!supportsResolver) { + throw new UnsupportedEndUserCredentialTriggerError(isSystemResolver ? 'system' : 'custom'); + } + } + /** * Returns the decrypted credential data with applied overwrites */ @@ -531,6 +559,7 @@ export class CredentialsHelper extends ICredentialsHelper { executeData?: IExecuteData, raw?: boolean, expressionResolveValues?: ICredentialsExpressionResolveValues, + options?: IGetDecryptedCredentialsOptions, ): Promise { // Sub-nodes, such as a chat model connected to a chain or agent, inherit executeData.node // from their parent. Prefer expressionResolveValues.node when present: it is always @@ -568,23 +597,41 @@ export class CredentialsHelper extends ICredentialsHelper { // 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. + // present or a trigger explicitly requests the credential. Canvas action-node tests have + // no incoming request, so they fall back to static data for easier developer testing. + // Trigger contexts opt in so a missing identity produces an actionable error instead. + // 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) { - // Mark that this execution attempted to run with a private credential before - // resolution is attempted, so the flag survives even when resolution throws - // (e.g. the running user has not connected the credential). Telemetry-only; - // the redaction layer relies on `currentNodeUsedDynamicCredentials` instead. - if (credentialsEntity.isResolvable) { - additionalData.currentNodeAttemptedDynamicCredentials = true; - } + const isManualTriggerCredentialRequest = + effectiveMode === 'manual' && + options?.credentialUsage === 'trigger' && + credentialsEntity.isResolvable; + const shouldAttemptDynamicResolution = + additionalData.executionContext?.credentials !== undefined || + isManualTriggerCredentialRequest || + !skipDynamicResolution; + + // Mark that this execution attempted to run with a private credential before + // resolution is attempted, so the flag survives even when resolution throws + // (e.g. the running user has not connected the credential). Telemetry-only; + // the redaction layer relies on `currentNodeUsedDynamicCredentials` instead. + if (shouldAttemptDynamicResolution && credentialsEntity.isResolvable) { + additionalData.currentNodeAttemptedDynamicCredentials = true; + } + + if ( + isManualTriggerCredentialRequest && + additionalData.executionContext?.credentials === undefined + ) { + this.assertTriggerSupportsEndUserCredential(credentialsEntity, additionalData, executeData); + } + + if (shouldAttemptDynamicResolution) { // Resolve dynamic credentials if configured (EE feature) const resolveResult = await this.dynamicCredentialsProxy.resolveIfNeeded( { diff --git a/packages/cli/src/errors/unsupported-end-user-credential-trigger.error.ts b/packages/cli/src/errors/unsupported-end-user-credential-trigger.error.ts new file mode 100644 index 00000000000..5852a63046a --- /dev/null +++ b/packages/cli/src/errors/unsupported-end-user-credential-trigger.error.ts @@ -0,0 +1,13 @@ +import { UserError } from 'n8n-workflow'; + +const SYSTEM_RESOLVER_MESSAGE = + "End-user credentials aren't supported by this workflow's trigger. Supported triggers: Manual, Sub-workflow, Chat available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, Form, or Webhook with n8n user authentication. To use another trigger, switch this credential to Fixed."; + +const CUSTOM_RESOLVER_MESSAGE = + 'End-user credentials with this resolver need a trigger that extracts an identity. Configure an identity extractor on the trigger, or switch this credential to Fixed.'; + +export class UnsupportedEndUserCredentialTriggerError extends UserError { + constructor(resolverType: 'system' | 'custom') { + super(resolverType === 'system' ? SYSTEM_RESOLVER_MESSAGE : CUSTOM_RESOLVER_MESSAGE); + } +} diff --git a/packages/cli/src/modules/instance-ai/eval/__tests__/eval-mocked-credentials-helper.test.ts b/packages/cli/src/modules/instance-ai/eval/__tests__/eval-mocked-credentials-helper.test.ts index 61fc39ad1ed..332331ba7b4 100644 --- a/packages/cli/src/modules/instance-ai/eval/__tests__/eval-mocked-credentials-helper.test.ts +++ b/packages/cli/src/modules/instance-ai/eval/__tests__/eval-mocked-credentials-helper.test.ts @@ -728,12 +728,13 @@ describe('EvalMockedCredentialsHelper', () => { ]); }); - it('still delegates (and surfaces the throw) when an id IS present', async () => { + it('forwards trigger credential usage when an id is present', async () => { // A present id whose lookup fails with a non-CredentialNotFoundError must still propagate. const inner = makeInner({ getDecrypted: vi.fn().mockRejectedValue(new Error('database is down')), }); const helper = new EvalMockedCredentialsHelper(inner, undefined, mockLogger); + const options = { credentialUsage: 'trigger' } as const; await expect( helper.getDecrypted( @@ -741,9 +742,22 @@ describe('EvalMockedCredentialsHelper', () => { { id: 'real-id', name: 'Telegram cred' }, 'telegramApi', 'manual', + undefined, + undefined, + undefined, + options, ), ).rejects.toThrow('database is down'); - expect(inner.getDecrypted).toHaveBeenCalled(); + expect(inner.getDecrypted).toHaveBeenCalledWith( + fakeAdditionalData, + { id: 'real-id', name: 'Telegram cred' }, + 'telegramApi', + 'manual', + undefined, + undefined, + undefined, + options, + ); }); }); diff --git a/packages/cli/src/modules/instance-ai/eval/eval-mocked-credentials-helper.ts b/packages/cli/src/modules/instance-ai/eval/eval-mocked-credentials-helper.ts index 04e031537af..d923f6fb2ac 100644 --- a/packages/cli/src/modules/instance-ai/eval/eval-mocked-credentials-helper.ts +++ b/packages/cli/src/modules/instance-ai/eval/eval-mocked-credentials-helper.ts @@ -9,6 +9,7 @@ import type { ICredentials, ICredentialsExpressionResolveValues, IExecuteData, + IGetDecryptedCredentialsOptions, IHttpRequestHelper, IHttpRequestOptions, INode, @@ -107,6 +108,7 @@ export class EvalMockedCredentialsHelper extends ICredentialsHelper { executeData?: IExecuteData, raw?: boolean, expressionResolveValues?: ICredentialsExpressionResolveValues, + options?: IGetDecryptedCredentialsOptions, ): Promise { // Id-less refs make the inner helper throw UnexpectedError (not CredentialNotFoundError), // which the catch below won't handle — synthesize a mock here instead of delegating. @@ -133,6 +135,7 @@ export class EvalMockedCredentialsHelper extends ICredentialsHelper { executeData, raw, expressionResolveValues, + options, ); } catch (error) { if (!(error instanceof CredentialNotFoundError)) throw error; diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/hook-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/hook-context.test.ts index 211582ea0ad..c03273aef88 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/hook-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/hook-context.test.ts @@ -115,6 +115,7 @@ describe('HookContext', () => { expect.objectContaining({ node }), false, undefined, + undefined, ); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/load-options-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/load-options-context.test.ts index 575e7ff4785..99c01bcf3d4 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/load-options-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/load-options-context.test.ts @@ -98,6 +98,7 @@ describe('LoadOptionsContext', () => { expect.objectContaining({ node }), false, undefined, + undefined, ); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts index e06ecc260eb..0866331b9a7 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/node-execution-context.test.ts @@ -256,6 +256,7 @@ describe('NodeExecutionContext', () => { undefined, false, undefined, + undefined, ); }); @@ -308,6 +309,9 @@ describe('NodeExecutionContext', () => { 'slackApi', 'internal', undefined, + undefined, + undefined, + undefined, ); }); @@ -393,6 +397,7 @@ describe('NodeExecutionContext', () => { { data: {}, node: runlessNode, source: null }, false, undefined, + undefined, ); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/poll-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/poll-context.test.ts index 6953f81ed7c..68c5cdc58d6 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/poll-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/poll-context.test.ts @@ -1,6 +1,7 @@ import type { ICredentialDataDecryptedObject, ICredentialsHelper, + IExecutionContext, IDataObject, INode, INodeType, @@ -48,7 +49,10 @@ describe('PollContext', () => { testParameter: 'testValue', }; const credentialsHelper = mock(); - const additionalData = mock({ credentialsHelper }); + const additionalData = mock({ + credentialsHelper, + executionContext: undefined, + }); const mode: WorkflowExecuteMode = 'manual'; const activation: WorkflowActivateMode = 'init'; @@ -100,7 +104,7 @@ describe('PollContext', () => { expect(credentials).toEqual({ secret: 'token' }); }); - it('should surface the node to the credentials helper', async () => { + it('should identify credentials requested by a polling trigger', async () => { nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' }); credentialsHelper.isCredentialUsableByNode.mockReturnValue(true); @@ -115,6 +119,35 @@ describe('PollContext', () => { expect.objectContaining({ node }), false, undefined, + { credentialUsage: 'trigger' }, + ); + }); + + it("should preserve the entry point's execution context", async () => { + const executionContext: IExecutionContext = { + version: 1, + establishedAt: 1, + source: 'manual', + credentials: 'sealed-credential-context', + }; + const additionalDataWithContext = mock({ credentialsHelper }); + additionalDataWithContext.executionContext = executionContext; + const context = new PollContext(workflow, node, additionalDataWithContext, mode, activation); + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' }); + credentialsHelper.isCredentialUsableByNode.mockReturnValue(true); + + await context.getCredentials(testCredentialType); + + expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith( + expect.objectContaining({ executionContext }), + expect.anything(), + testCredentialType, + mode, + expect.objectContaining({ node }), + false, + undefined, + { credentialUsage: 'trigger' }, ); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/trigger-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/trigger-context.test.ts index 5a70bdcf62f..a413541be17 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/trigger-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/trigger-context.test.ts @@ -3,6 +3,7 @@ import type { CronExpression, ICredentialDataDecryptedObject, ICredentialsHelper, + IExecutionContext, INode, INodeType, INodeTypes, @@ -48,7 +49,10 @@ describe('TriggerContext', () => { testParameter: 'testValue', }; const credentialsHelper = mock(); - const additionalData = mock({ credentialsHelper }); + const additionalData = mock({ + credentialsHelper, + executionContext: undefined, + }); const mode: WorkflowExecuteMode = 'manual'; const activation: WorkflowActivateMode = 'init'; @@ -77,7 +81,7 @@ describe('TriggerContext', () => { expect(credentials).toEqual({ secret: 'token' }); }); - it('should surface the node to the credentials helper', async () => { + it('should identify credentials requested by a trigger', async () => { nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' }); credentialsHelper.isCredentialUsableByNode.mockReturnValue(true); @@ -92,6 +96,41 @@ describe('TriggerContext', () => { expect.objectContaining({ node }), false, undefined, + { credentialUsage: 'trigger' }, + ); + }); + + it("should preserve the entry point's execution context", async () => { + const executionContext: IExecutionContext = { + version: 1, + establishedAt: 1, + source: 'manual', + credentials: 'sealed-credential-context', + }; + const additionalDataWithContext = mock({ credentialsHelper }); + additionalDataWithContext.executionContext = executionContext; + const context = new TriggerContext( + workflow, + node, + additionalDataWithContext, + mode, + activation, + ); + nodeTypes.getByNameAndVersion.mockReturnValue(nodeType); + credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' }); + credentialsHelper.isCredentialUsableByNode.mockReturnValue(true); + + await context.getCredentials(testCredentialType); + + expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith( + expect.objectContaining({ executionContext }), + expect.anything(), + testCredentialType, + mode, + expect.objectContaining({ node }), + false, + undefined, + { credentialUsage: 'trigger' }, ); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts b/packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts index 677f6a2e907..c794f6290c6 100644 --- a/packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/__tests__/webhook-context.test.ts @@ -156,6 +156,7 @@ describe('WebhookContext', () => { expect.objectContaining({ node }), false, undefined, + undefined, ); }); }); diff --git a/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts b/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts index e41c3664d51..a000e4a7eb7 100644 --- a/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/node-execution-context.ts @@ -7,6 +7,7 @@ import type { ICredentialDataDecryptedObject, ICredentialsExpressionResolveValues, IExecuteData, + IGetDecryptedCredentialsOptions, IGetNodeParameterOptions, INode, INodeCredentialDescription, @@ -326,6 +327,7 @@ export abstract class NodeExecutionContext implements Omit { const { workflow, node, additionalData, mode, runExecutionData, runIndex } = this; @@ -344,6 +346,9 @@ export abstract class NodeExecutionContext implements Omit( type: string, + options?: IGetDecryptedCredentialsOptions, ) { - return await this._getCredentials(type, { data: {}, node: this.node, source: null }); + return await this._getCredentials( + type, + { data: {}, node: this.node, source: null }, + undefined, + undefined, + options, + ); } @Memoized diff --git a/packages/core/src/execution-engine/node-execution-context/poll-context.ts b/packages/core/src/execution-engine/node-execution-context/poll-context.ts index 74dc0c0dc82..162d3922dfe 100644 --- a/packages/core/src/execution-engine/node-execution-context/poll-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/poll-context.ts @@ -71,7 +71,12 @@ export class PollContext extends NodeExecutionContext implements IPollFunctions return this.activation; } + override getExecutionContext() { + // Poll contexts have no run data, so preserve context established by the entry point. + return super.getExecutionContext() ?? this.additionalData.executionContext; + } + async getCredentials(type: string) { - return await this._getRunlessCredentials(type); + return await this._getRunlessCredentials(type, { credentialUsage: 'trigger' }); } } diff --git a/packages/core/src/execution-engine/node-execution-context/trigger-context.ts b/packages/core/src/execution-engine/node-execution-context/trigger-context.ts index 22c98a79508..f5ee29e01b4 100644 --- a/packages/core/src/execution-engine/node-execution-context/trigger-context.ts +++ b/packages/core/src/execution-engine/node-execution-context/trigger-context.ts @@ -64,7 +64,12 @@ export class TriggerContext extends NodeExecutionContext implements ITriggerFunc return this.activation; } + override getExecutionContext() { + // Trigger contexts have no run data, so preserve context established by the entry point. + return super.getExecutionContext() ?? this.additionalData.executionContext; + } + async getCredentials(type: string) { - return await this._getRunlessCredentials(type); + return await this._getRunlessCredentials(type, { credentialUsage: 'trigger' }); } } diff --git a/packages/workflow/src/interfaces.ts b/packages/workflow/src/interfaces.ts index b5cd84adac2..f001c79f133 100644 --- a/packages/workflow/src/interfaces.ts +++ b/packages/workflow/src/interfaces.ts @@ -255,6 +255,11 @@ export interface IRequestOptionsSimplifiedAuth { export interface IHttpRequestHelper { helpers: { httpRequest: IAllExecuteFunctions['helpers']['httpRequest'] }; } + +export interface IGetDecryptedCredentialsOptions { + credentialUsage?: 'trigger'; +} + export abstract class ICredentialsHelper { abstract getParentTypes(name: string): string[]; @@ -303,6 +308,7 @@ export abstract class ICredentialsHelper { executeData?: IExecuteData, raw?: boolean, expressionResolveValues?: ICredentialsExpressionResolveValues, + options?: IGetDecryptedCredentialsOptions, ): Promise; abstract updateCredentials(