mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-16 21:19:43 +02:00
fix(core): Show an explicit error for unsupported end-user credential triggers (#37332)
This commit is contained in:
parent
e009b9aaa0
commit
c5935b9cb5
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<ICredentialDataDecryptedObject> {
|
||||
// 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(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ICredentialDataDecryptedObject> {
|
||||
// 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;
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ describe('HookContext', () => {
|
|||
expect.objectContaining({ node }),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ describe('LoadOptionsContext', () => {
|
|||
expect.objectContaining({ node }),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
IExecutionContext,
|
||||
IDataObject,
|
||||
INode,
|
||||
INodeType,
|
||||
|
|
@ -48,7 +49,10 @@ describe('PollContext', () => {
|
|||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
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<IWorkflowExecuteAdditionalData>({ 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<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ executionContext }),
|
||||
expect.anything(),
|
||||
testCredentialType,
|
||||
mode,
|
||||
expect.objectContaining({ node }),
|
||||
false,
|
||||
undefined,
|
||||
{ credentialUsage: 'trigger' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
CronExpression,
|
||||
ICredentialDataDecryptedObject,
|
||||
ICredentialsHelper,
|
||||
IExecutionContext,
|
||||
INode,
|
||||
INodeType,
|
||||
INodeTypes,
|
||||
|
|
@ -48,7 +49,10 @@ describe('TriggerContext', () => {
|
|||
testParameter: 'testValue',
|
||||
};
|
||||
const credentialsHelper = mock<ICredentialsHelper>();
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({ credentialsHelper });
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>({
|
||||
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<IWorkflowExecuteAdditionalData>({ 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<ICredentialDataDecryptedObject>(testCredentialType);
|
||||
|
||||
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ executionContext }),
|
||||
expect.anything(),
|
||||
testCredentialType,
|
||||
mode,
|
||||
expect.objectContaining({ node }),
|
||||
false,
|
||||
undefined,
|
||||
{ credentialUsage: 'trigger' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -156,6 +156,7 @@ describe('WebhookContext', () => {
|
|||
expect.objectContaining({ node }),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type {
|
|||
ICredentialDataDecryptedObject,
|
||||
ICredentialsExpressionResolveValues,
|
||||
IExecuteData,
|
||||
IGetDecryptedCredentialsOptions,
|
||||
IGetNodeParameterOptions,
|
||||
INode,
|
||||
INodeCredentialDescription,
|
||||
|
|
@ -326,6 +327,7 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
|
|||
executeData?: IExecuteData,
|
||||
connectionInputData?: INodeExecutionData[],
|
||||
itemIndex?: number,
|
||||
options?: IGetDecryptedCredentialsOptions,
|
||||
): Promise<T> {
|
||||
const { workflow, node, additionalData, mode, runExecutionData, runIndex } = this;
|
||||
|
||||
|
|
@ -344,6 +346,9 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
|
|||
type,
|
||||
mode,
|
||||
executeData,
|
||||
undefined,
|
||||
undefined,
|
||||
options,
|
||||
)) as T;
|
||||
}
|
||||
}
|
||||
|
|
@ -471,6 +476,7 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
|
|||
executeData,
|
||||
false,
|
||||
expressionResolveValues,
|
||||
options,
|
||||
);
|
||||
|
||||
return decryptedDataObject as T;
|
||||
|
|
@ -484,8 +490,15 @@ export abstract class NodeExecutionContext implements Omit<FunctionsBase, 'getCr
|
|||
*/
|
||||
protected async _getRunlessCredentials<T extends object = ICredentialDataDecryptedObject>(
|
||||
type: string,
|
||||
options?: IGetDecryptedCredentialsOptions,
|
||||
) {
|
||||
return await this._getCredentials<T>(type, { data: {}, node: this.node, source: null });
|
||||
return await this._getCredentials<T>(
|
||||
type,
|
||||
{ data: {}, node: this.node, source: null },
|
||||
undefined,
|
||||
undefined,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@Memoized
|
||||
|
|
|
|||
|
|
@ -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<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getRunlessCredentials<T>(type);
|
||||
return await this._getRunlessCredentials<T>(type, { credentialUsage: 'trigger' });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T extends object = ICredentialDataDecryptedObject>(type: string) {
|
||||
return await this._getRunlessCredentials<T>(type);
|
||||
return await this._getRunlessCredentials<T>(type, { credentialUsage: 'trigger' });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ICredentialDataDecryptedObject>;
|
||||
|
||||
abstract updateCredentials(
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user