feat(core): Wire credentialDecrypt host point (no-changelog) (#36962)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Rúben Castro
2026-08-27 08:46:38 +00:00
committed by GitHub
parent a8d6af95a6
commit ca3069f528
15 changed files with 335 additions and 13 deletions
@@ -19,6 +19,7 @@ import type {
IAuthenticateGeneric,
ICredentialDataDecryptedObject,
ICredentialType,
IExecuteData,
IHttpRequestHelper,
IHttpRequestOptions,
INode,
@@ -50,6 +51,7 @@ import type { CredentialsOverwrites } from '@/credentials-overwrites';
import { CredentialNotFoundError } from '@/errors/credential-not-found.error';
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
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';
describe('CredentialsHelper', () => {
@@ -60,6 +62,7 @@ describe('CredentialsHelper', () => {
const licenseState = mock<LicenseState>();
const externalSecretsConfig = mock<ExternalSecretsConfig>();
const mockLogger = mock<any>();
const policyEnforcementService = mock<PolicyEnforcementService>();
// Use a real instance of DynamicCredentialsProxy so setResolverProvider works
const dynamicCredentialProxy = new DynamicCredentialsProxy(mockLogger);
@@ -81,6 +84,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
mock<AiGatewayService>(),
policyEnforcementService,
);
describe('getCredentials', () => {
@@ -179,6 +183,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
mock<AiGatewayService>(),
policyEnforcementService,
);
const result = await helper.applyDefaultsAndOverwrites(
@@ -232,6 +237,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
mock<AiGatewayService>(),
policyEnforcementService,
);
await expect(
@@ -275,6 +281,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
mock<AiGatewayService>(),
policyEnforcementService,
);
const externalSecretsProxy =
mock<NonNullable<IWorkflowExecuteAdditionalData['externalSecretsProxy']>>();
@@ -354,6 +361,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
mock<AiGatewayService>(),
policyEnforcementService,
);
// Region left at its default: neither `region` nor `url` was persisted.
@@ -413,6 +421,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
mock<AiGatewayService>(),
policyEnforcementService,
);
const result = await helper.applyDefaultsAndOverwrites(
@@ -455,6 +464,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
mock<AiGatewayService>(),
policyEnforcementService,
);
const registerType = (credentialType: ICredentialType) =>
@@ -1243,6 +1253,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
aiGatewayService,
policyEnforcementService,
);
const syntheticCred = { apiKey: 'mock-jwt', host: 'http://gateway/v1/gateway/google' };
@@ -1288,6 +1299,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
aiGatewayService,
policyEnforcementService,
);
const syntheticCred = { apiKey: 'mock-jwt', host: 'http://gateway/v1/gateway/google' };
@@ -1335,6 +1347,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
aiGatewayService,
policyEnforcementService,
);
const syntheticCred = {
@@ -1570,6 +1583,7 @@ describe('CredentialsHelper', () => {
licenseState,
externalSecretsConfig,
mock<AiGatewayService>(),
policyEnforcementService,
);
const result = await helperWithoutProvider.getDecrypted(
@@ -1830,6 +1844,7 @@ describe('CredentialsHelper', () => {
mock<LicenseState>(),
mock<ExternalSecretsConfig>(),
mock<AiGatewayService>(),
policyEnforcementService,
);
// The loader sets the class's `supportedNodes` to short names (e.g. "restrictedConsumer");
@@ -2384,4 +2399,138 @@ describe('CredentialsHelper', () => {
expect(result).toMatchObject({ accessToken: 'NEW_TOKEN' });
});
});
describe('getDecrypted - credentialDecrypt policy enforcement', () => {
const nodeCredentials: INodeCredentialsDetails = {
id: 'cred-policy',
name: 'Policy Test Credential',
};
const credentialEntity = {
id: 'cred-policy',
name: 'Policy Test Credential',
type: 'testApi',
data: cipher.encrypt({ apiKey: 'test' }),
isResolvable: false,
usageScope: 'project',
} as CredentialsEntity;
let helper: CredentialsHelper;
beforeEach(() => {
vi.clearAllMocks();
credentialsRepository.findOneByOrFail.mockResolvedValue(credentialEntity);
policyEnforcementService.enforceCredentialDecrypt.mockResolvedValue(mock());
helper = new CredentialsHelper(
new CredentialTypes(mockNodesAndCredentials),
mock(),
credentialsRepository,
dynamicCredentialProxy,
secretsProviderRepository,
licenseState,
externalSecretsConfig,
mock<AiGatewayService>(),
policyEnforcementService,
);
});
test('calls enforceCredentialDecrypt with the credential, consumer and project context', async () => {
const executeData = {
node: {
name: 'Slack1',
type: 'n8n-nodes-base.slack',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
data: {},
source: null,
} as IExecuteData;
const additionalData = mock<IWorkflowExecuteAdditionalData>({ projectId: 'proj-1' });
await helper.getDecrypted(
additionalData,
nodeCredentials,
'testApi',
'manual',
executeData,
true,
);
expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith({
credentialType: 'testApi',
credentialId: 'cred-policy',
consumer: { nodeType: 'n8n-nodes-base.slack' },
projectId: 'proj-1',
});
});
test('passes a null consumer when no node is asking, e.g. a credential test', async () => {
const additionalData = mock<IWorkflowExecuteAdditionalData>({ projectId: undefined });
await helper.getDecrypted(
additionalData,
nodeCredentials,
'testApi',
'manual',
undefined,
true,
);
expect(policyEnforcementService.enforceCredentialDecrypt).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ consumer: null, projectId: null }),
);
});
test('resolves the credential before enforcing the policy check', async () => {
const callOrder: string[] = [];
credentialsRepository.findOneByOrFail.mockImplementation(async () => {
callOrder.push('findOneByOrFail');
return credentialEntity;
});
policyEnforcementService.enforceCredentialDecrypt.mockImplementation(async () => {
callOrder.push('enforceCredentialDecrypt');
return await mock();
});
await helper.getDecrypted(
mock<IWorkflowExecuteAdditionalData>(),
nodeCredentials,
'testApi',
'manual',
undefined,
true,
);
expect(callOrder).toEqual(['findOneByOrFail', 'enforceCredentialDecrypt']);
});
test('blocks decryption when the policy check throws', async () => {
const violation = new Error('blocked by policy');
policyEnforcementService.enforceCredentialDecrypt.mockRejectedValueOnce(violation);
await expect(
helper.getDecrypted(
mock<IWorkflowExecuteAdditionalData>(),
nodeCredentials,
'testApi',
'manual',
),
).rejects.toThrow(violation);
});
test('decryption behavior is unchanged when the policy check clears', async () => {
const result = await helper.getDecrypted(
mock<IWorkflowExecuteAdditionalData>(),
nodeCredentials,
'testApi',
'manual',
undefined,
true,
);
expect(result).toEqual({ apiKey: 'test' });
});
});
});
@@ -271,7 +271,9 @@ describe('WorkflowExecuteAdditionalData', () => {
mock<ExecuteWorkflowOptions>({ loadedWorkflowData: undefined, doNotWaitToFinish: false }),
);
expect(getVariablesSpy).toHaveBeenCalledWith(workflowId, undefined);
// getBase backfills projectId from the workflow owner (mocked to
// 'project-id-1' in this describe's beforeEach) before calling getVariables.
expect(getVariablesSpy).toHaveBeenCalledWith(workflowId, 'project-id-1');
});
describe('credential permission check routing', () => {
@@ -1330,6 +1332,52 @@ describe('WorkflowExecuteAdditionalData', () => {
expect(additionalData.workflowSettings).toBe(workflowSettings);
});
describe('projectId resolution', () => {
const ownershipService = mockInstance(OwnershipService);
beforeEach(() => {
ownershipService.getWorkflowProjectCached.mockReset();
// Both this and the executeWorkflow/executeAgent describes call
// mockInstance(OwnershipService), which each Container.set a fresh mock.
// Re-bind ours so the source resolves it.
Container.set(OwnershipService, ownershipService);
});
it('backfills projectId from the workflow owner when missing', async () => {
ownershipService.getWorkflowProjectCached.mockResolvedValue(
mock<Project>({ id: 'owning-project-1' }),
);
const additionalData = await getBase({ workflowId: 'workflow-1' });
expect(ownershipService.getWorkflowProjectCached).toHaveBeenCalledWith('workflow-1');
expect(additionalData.projectId).toBe('owning-project-1');
});
it('keeps the given projectId untouched when already present', async () => {
const additionalData = await getBase({
workflowId: 'workflow-1',
projectId: 'given-project',
});
expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled();
expect(additionalData.projectId).toBe('given-project');
});
it('leaves projectId unset when workflowId is missing', async () => {
const additionalData = await getBase();
expect(ownershipService.getWorkflowProjectCached).not.toHaveBeenCalled();
expect(additionalData.projectId).toBeUndefined();
});
it('rejects when the workflow has no resolvable owning project', async () => {
ownershipService.getWorkflowProjectCached.mockRejectedValue(new Error('not found'));
await expect(getBase({ workflowId: 'workflow-1' })).rejects.toThrow('not found');
});
});
});
describe('executeAgent', () => {
@@ -35,6 +35,11 @@ import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { mock } from 'vitest-mock-extended';
describe('workflow-helpers', () => {
const ownershipService = mockInstance(OwnershipService);
ownershipService.getWorkflowProjectCached.mockResolvedValue(
mock<Project>({ id: '1', name: 'project' }),
);
beforeAll(() => {
mockInstance(VariablesService, {
async getAllCached() {
@@ -64,12 +69,6 @@ describe('workflow-helpers', () => {
] as Variables[];
},
});
mockInstance(OwnershipService, {
async getWorkflowProjectCached(_workflowId: string) {
return { id: '1', name: 'project' } as unknown as Project;
},
});
});
describe('getVariables', () => {
@@ -97,6 +96,12 @@ describe('workflow-helpers', () => {
const variables = await getVariables(undefined, '1');
expect(variables.VAR2).toBe('value1Project');
});
it('should reject when the owning project cannot be resolved', async () => {
ownershipService.getWorkflowProjectCached.mockRejectedValueOnce(new Error('not found'));
await expect(getVariables('1')).rejects.toThrow('not found');
});
});
});
+11
View File
@@ -46,6 +46,7 @@ import {
MANAGED_OAUTH_PINNED_FIELDS,
} from '@/oauth/dcr-managed-fields';
import { ExternalSecretsConfig } from '@/modules/external-secrets.ee/external-secrets.config';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { AiGatewayService } from '@/services/ai-gateway.service';
import { RESPONSE_ERROR_MESSAGES } from './constants';
@@ -100,6 +101,7 @@ export class CredentialsHelper extends ICredentialsHelper {
private readonly licenseState: LicenseState,
private readonly externalSecretsConfig: ExternalSecretsConfig,
private readonly aiGatewayService: AiGatewayService,
private readonly policyEnforcementService: PolicyEnforcementService,
) {
super();
}
@@ -527,6 +529,15 @@ export class CredentialsHelper extends ICredentialsHelper {
}
const credentialsEntity = await this.getCredentialsEntity(nodeCredentials, type);
// Validate against the executing project's policy before any decryption happens.
await this.policyEnforcementService.enforceCredentialDecrypt({
credentialType: type,
credentialId: credentialsEntity.id,
consumer: executeData ? { nodeType: executeData.node.type } : null,
projectId: additionalData.projectId ?? null,
});
const credentials = new Credentials(
{ id: credentialsEntity.id, name: credentialsEntity.name },
credentialsEntity.type,
@@ -789,6 +789,18 @@ export async function getBase({
const globalConfig = Container.get(GlobalConfig);
// Trigger-fired, webhook, and worker-queued executions build additionalData without
// a `projectId`. Resolve it from the workflow's owning project so every downstream
// consumer (e.g. policy enforcement) sees the executing project, same as
// `executeAgent` already does locally for its own use. Left unguarded on purpose,
// matching `getVariables`'s own pre-existing lookup below: an unresolvable owner
// project fails execution setup, it isn't silently tolerated.
if (!projectId && workflowId) {
const { OwnershipService } = await import('@/services/ownership.service.js');
const project = await Container.get(OwnershipService).getWorkflowProjectCached(workflowId);
projectId = project?.id;
}
const variables = await WorkflowHelpers.getVariables(workflowId, projectId);
const eventService = Container.get(EventService);
@@ -100,6 +100,23 @@ describe('HookContext', () => {
expect(credentials).toEqual({ secret: 'token' });
});
it('should surface the node to the credentials helper', async () => {
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
credentialsHelper.isCredentialUsableByNode.mockReturnValue(true);
await hookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
additionalData,
expect.anything(),
testCredentialType,
mode,
expect.objectContaining({ node }),
false,
undefined,
);
});
});
describe('getNodeParameter', () => {
@@ -95,7 +95,7 @@ describe('LoadOptionsContext', () => {
expect.anything(),
testCredentialType,
'internal',
undefined,
expect.objectContaining({ node }),
false,
undefined,
);
@@ -76,6 +76,24 @@ describe('PollContext', () => {
expect(credentials).toEqual({ secret: 'token' });
});
it('should surface the node to the credentials helper', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
credentialsHelper.isCredentialUsableByNode.mockReturnValue(true);
await pollContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
additionalData,
expect.anything(),
testCredentialType,
mode,
expect.objectContaining({ node }),
false,
undefined,
);
});
});
describe('getNodeParameter', () => {
@@ -76,6 +76,24 @@ describe('TriggerContext', () => {
expect(credentials).toEqual({ secret: 'token' });
});
it('should surface the node to the credentials helper', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
credentialsHelper.isCredentialUsableByNode.mockReturnValue(true);
await triggerContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
additionalData,
expect.anything(),
testCredentialType,
mode,
expect.objectContaining({ node }),
false,
undefined,
);
});
});
describe('getNodeParameter', () => {
@@ -140,6 +140,24 @@ describe('WebhookContext', () => {
expect(credentials).toEqual({ secret: 'token' });
});
it('should surface the node to the credentials helper', async () => {
nodeTypes.getByNameAndVersion.mockReturnValue(nodeType);
credentialsHelper.getDecrypted.mockResolvedValue({ secret: 'token' });
credentialsHelper.isCredentialUsableByNode.mockReturnValue(true);
await webhookContext.getCredentials<ICredentialDataDecryptedObject>(testCredentialType);
expect(credentialsHelper.getDecrypted).toHaveBeenCalledWith(
additionalData,
expect.anything(),
testCredentialType,
mode,
expect.objectContaining({ node }),
false,
undefined,
);
});
});
describe('getBodyData', () => {
@@ -1,6 +1,7 @@
import { UnexpectedError } from 'n8n-workflow';
import type {
ICredentialDataDecryptedObject,
IExecuteData,
INode,
IHookFunctions,
IWorkflowExecuteAdditionalData,
@@ -36,7 +37,12 @@ export class HookContext extends NodeExecutionContext implements IHookFunctions
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
// No real task run backs a webhook-registration hook, so this only exists to
// surface `node` to the credentials helper (e.g. for policy checks) — `data`/
// `source` are unused.
const executeData: IExecuteData = { data: {}, node: this.node, source: null };
return await this._getCredentials<T>(type, executeData);
}
getNodeWebhookUrl(name: WebhookType): string | undefined {
@@ -1,6 +1,7 @@
import get from 'lodash/get';
import type {
ICredentialDataDecryptedObject,
IExecuteData,
IGetNodeParameterOptions,
INode,
ILoadOptionsFunctions,
@@ -44,7 +45,12 @@ export class LoadOptionsContext extends NodeExecutionContext implements ILoadOpt
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
// No real task run backs design-time parameter loading, so this only exists to
// surface `node` to the credentials helper (e.g. for policy checks) — `data`/`source`
// are unused.
const executeData: IExecuteData = { data: {}, node: this.node, source: null };
return await this._getCredentials<T>(type, executeData);
}
getCurrentNodeParameter(
@@ -2,6 +2,7 @@ import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import type {
ICredentialDataDecryptedObject,
IDataObject,
IExecuteData,
INode,
IPollFunctions,
IWorkflowExecuteAdditionalData,
@@ -66,6 +67,10 @@ export class PollContext extends NodeExecutionContext implements IPollFunctions
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
// No real task run backs a poll, so this only exists to surface `node` to
// the credentials helper (e.g. for policy checks) — `data`/`source` are unused.
const executeData: IExecuteData = { data: {}, node: this.node, source: null };
return await this._getCredentials<T>(type, executeData);
}
}
@@ -1,6 +1,7 @@
import { createDeferredPromise } from '@n8n/utils/promise/deferred-promise';
import type {
ICredentialDataDecryptedObject,
IExecuteData,
INode,
ITriggerFunctions,
IWorkflowExecuteAdditionalData,
@@ -65,6 +66,10 @@ export class TriggerContext extends NodeExecutionContext implements ITriggerFunc
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
// No real task run backs a trigger, so this only exists to surface `node` to
// the credentials helper (e.g. for policy checks) — `data`/`source` are unused.
const executeData: IExecuteData = { data: {}, node: this.node, source: null };
return await this._getCredentials<T>(type, executeData);
}
}
@@ -99,7 +99,11 @@ export class WebhookContext extends NodeExecutionContext implements IWebhookFunc
}
async getCredentials<T extends object = ICredentialDataDecryptedObject>(type: string) {
return await this._getCredentials<T>(type);
// No real task run backs a webhook call, so this only exists to surface `node`
// to the credentials helper (e.g. for policy checks) — `data`/`source` are unused.
const executeData: IExecuteData = { data: {}, node: this.node, source: null };
return await this._getCredentials<T>(type, executeData);
}
getBodyData() {