fix(core): Prevent assigning unusable credentials in mcp (#32353)

This commit is contained in:
Milorad FIlipović
2026-06-16 23:00:10 +02:00
committed by GitHub
parent 229560e3bc
commit 7ddde951cc
6 changed files with 1000 additions and 36 deletions
@@ -6,6 +6,7 @@ import { z } from 'zod';
import { createCreateWorkflowFromCodeTool } from '../tools/workflow-builder/create-workflow-from-code.tool';
import { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { NodeTypes } from '@/node-types';
import { UrlService } from '@/services/url.service';
import { Telemetry } from '@/telemetry';
@@ -627,6 +628,97 @@ describe('create-workflow-from-code MCP tool', () => {
});
});
describe('credential validation', () => {
const httpNodeWithGithub = (credentialId: string): INode => ({
id: 'http-1',
name: 'Fetch PR Comments',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
position: [0, 0],
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: 'githubApi',
},
credentials: { githubApi: { id: credentialId, name: 'GitHub account' } },
});
afterEach(() => {
// Restore module-scoped defaults so later suites aren't polluted.
(credentialsService.getCredentialsAUserCanUseInAWorkflow as jest.Mock).mockResolvedValue(
[],
);
(credentialsService.getOne as jest.Mock).mockReset();
});
test('rejects a credential id that belongs to another project', async () => {
(credentialsService.getCredentialsAUserCanUseInAWorkflow as jest.Mock).mockResolvedValue(
[],
);
(credentialsService.getOne as jest.Mock).mockResolvedValue({
id: '6CoUMkVOJRNsbmr2',
name: 'GitHub account',
type: 'githubApi',
});
mockParseAndValidate.mockResolvedValue({
workflow: {
...mockWorkflowJson,
nodes: [httpNodeWithGithub('6CoUMkVOJRNsbmr2')],
},
});
const result = await callHandler({ code: 'const wf = ...' });
const response = parseResult(result);
expect(result.isError).toBe(true);
expect(response.error).toContain('Fetch PR Comments');
expect(response.error).toContain("credential '6CoUMkVOJRNsbmr2' is not usable");
expect(response.error).toContain("this workflow's project");
expect(workflowCreationService.createWorkflow).not.toHaveBeenCalled();
});
test('rejects a credential id that does not exist', async () => {
(credentialsService.getCredentialsAUserCanUseInAWorkflow as jest.Mock).mockResolvedValue(
[],
);
(credentialsService.getOne as jest.Mock).mockRejectedValue(
new NotFoundError('Credential with ID "ghost" could not be found.'),
);
mockParseAndValidate.mockResolvedValue({
workflow: {
...mockWorkflowJson,
nodes: [httpNodeWithGithub('ghost')],
},
});
const result = await callHandler({ code: 'const wf = ...' });
const response = parseResult(result);
expect(result.isError).toBe(true);
expect(response.error).toContain("credential 'ghost' not found or not accessible");
expect(workflowCreationService.createWorkflow).not.toHaveBeenCalled();
});
test('accepts a credential id that is reachable from the project', async () => {
(credentialsService.getCredentialsAUserCanUseInAWorkflow as jest.Mock).mockResolvedValue([
{ id: 'in-project-cred', name: 'GitHub account 2', type: 'githubApi' },
]);
mockParseAndValidate.mockResolvedValue({
workflow: {
...mockWorkflowJson,
nodes: [httpNodeWithGithub('in-project-cred')],
},
});
const result = await callHandler({ code: 'const wf = ...' });
expect(result.isError).toBeUndefined();
expect(workflowCreationService.createWorkflow).toHaveBeenCalled();
});
});
test('refuses to save when an agent is wired as a tool to another agent', async () => {
mockParseAndValidate.mockResolvedValue({
workflow: {
@@ -0,0 +1,277 @@
import type { User } from '@n8n/db';
import type { INode, INodeTypeDescription, INodeCredentialDescription } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import { validateWorkflowCredentialReferences } from '../tools/workflow-builder/credential-validation';
import type { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import type { NodeTypes } from '@/node-types';
const user = { id: 'user-1' } as User;
const projectId = 'project-1';
function makeNode(overrides: Partial<INode> = {}): INode {
return {
id: 'node-1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 1,
position: [0, 0],
parameters: {},
...overrides,
};
}
function makeNodeTypeDescription(
overrides: Partial<INodeTypeDescription> = {},
): INodeTypeDescription {
const credentials: INodeCredentialDescription[] = [{ name: 'slackApi', required: true }];
return {
displayName: 'Slack',
name: 'n8n-nodes-base.slack',
group: ['transform'],
version: 1,
description: '',
defaults: { name: 'Slack' },
inputs: ['main'],
outputs: ['main'],
properties: [],
credentials,
...overrides,
} as unknown as INodeTypeDescription;
}
function createMocks({
usableCredentials = [],
getOneImpl,
nodeTypeDescriptions = new Map<string, INodeTypeDescription>(),
}: {
usableCredentials?: Array<{ id: string; name: string; type: string }>;
getOneImpl?: (id: string) => Promise<{ id: string; name: string; type: string }>;
nodeTypeDescriptions?: Map<string, INodeTypeDescription>;
} = {}) {
const credentialsService = {
getCredentialsAUserCanUseInAWorkflow: jest.fn().mockResolvedValue(usableCredentials),
getOne: jest.fn().mockImplementation(async (_user: User, id: string) => {
if (getOneImpl) return await getOneImpl(id);
throw new NotFoundError(`Credential with ID "${id}" could not be found.`);
}),
} as unknown as CredentialsService;
const nodeTypes = {
getByNameAndVersion: jest.fn().mockImplementation((type: string) => {
const desc = nodeTypeDescriptions.get(type);
if (!desc) throw new Error(`Unknown node type: ${type}`);
return { description: desc };
}),
} as unknown as NodeTypes;
return { credentialsService, nodeTypes };
}
describe('validateWorkflowCredentialReferences', () => {
beforeEach(() => {
jest.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(true);
});
afterEach(() => jest.restoreAllMocks());
test('passes when no node has a credential reference', async () => {
const { credentialsService, nodeTypes } = createMocks({
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', makeNodeTypeDescription()]]),
});
const result = await validateWorkflowCredentialReferences(
[makeNode()],
user,
credentialsService,
nodeTypes,
projectId,
);
expect(result.ok).toBe(true);
// Lazy: never builds the classifier when there's nothing to check.
expect(credentialsService.getCredentialsAUserCanUseInAWorkflow).not.toHaveBeenCalled();
});
test('passes when the credential is reachable from the project', async () => {
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [{ id: 'cred-1', name: 'My Slack', type: 'slackApi' }],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', makeNodeTypeDescription()]]),
});
const result = await validateWorkflowCredentialReferences(
[makeNode({ credentials: { slackApi: { id: 'cred-1', name: 'My Slack' } } })],
user,
credentialsService,
nodeTypes,
projectId,
);
expect(result.ok).toBe(true);
expect(credentialsService.getCredentialsAUserCanUseInAWorkflow).toHaveBeenCalledWith(user, {
projectId,
});
});
test('fails for a credential that exists but belongs to another project', async () => {
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
getOneImpl: async (id) => ({ id, name: 'Other Project Slack', type: 'slackApi' }),
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', makeNodeTypeDescription()]]),
});
const result = await validateWorkflowCredentialReferences(
[
makeNode({
credentials: { slackApi: { id: 'cred-foreign', name: 'Other Project Slack' } },
}),
],
user,
credentialsService,
nodeTypes,
projectId,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error('unreachable');
expect(result.error).toContain('Slack');
expect(result.error).toContain("credential 'cred-foreign' is not usable");
expect(result.error).toContain("this workflow's project");
});
test('fails for a credential that cannot be found at all', async () => {
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', makeNodeTypeDescription()]]),
});
const result = await validateWorkflowCredentialReferences(
[makeNode({ credentials: { slackApi: { id: 'ghost', name: 'Ghost' } } })],
user,
credentialsService,
nodeTypes,
projectId,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error('unreachable');
expect(result.error).toContain("credential 'ghost' not found or not accessible");
});
test('fails when the usable credential has a mismatched type', async () => {
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [{ id: 'cred-1', name: 'Wrong', type: 'discordApi' }],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', makeNodeTypeDescription()]]),
});
const result = await validateWorkflowCredentialReferences(
[makeNode({ credentials: { slackApi: { id: 'cred-1', name: 'Wrong' } } })],
user,
credentialsService,
nodeTypes,
projectId,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error('unreachable');
expect(result.error).toContain("is type 'discordApi'");
});
test('skips disabled nodes', async () => {
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', makeNodeTypeDescription()]]),
});
const result = await validateWorkflowCredentialReferences(
[
makeNode({
disabled: true,
credentials: { slackApi: { id: 'cred-foreign', name: 'Foreign' } },
}),
],
user,
credentialsService,
nodeTypes,
projectId,
);
expect(result.ok).toBe(true);
expect(credentialsService.getCredentialsAUserCanUseInAWorkflow).not.toHaveBeenCalled();
});
test('skips credential types the node does not actively use', async () => {
jest.spyOn(NodeHelpers, 'displayParameter').mockReturnValue(false);
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
nodeTypeDescriptions: new Map([['n8n-nodes-base.slack', makeNodeTypeDescription()]]),
});
const result = await validateWorkflowCredentialReferences(
[makeNode({ credentials: { slackApi: { id: 'cred-foreign', name: 'Foreign' } } })],
user,
credentialsService,
nodeTypes,
projectId,
);
// slackApi is not displayed, so it isn't an active type → not checked.
expect(result.ok).toBe(true);
});
test('checks HTTP Request credentials declared via nodeCredentialType', async () => {
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
getOneImpl: async (id) => ({ id, name: 'GitHub account', type: 'githubApi' }),
nodeTypeDescriptions: new Map([
[
'n8n-nodes-base.httpRequest',
makeNodeTypeDescription({ name: 'n8n-nodes-base.httpRequest', credentials: [] }),
],
]),
});
const result = await validateWorkflowCredentialReferences(
[
makeNode({
name: 'Fetch PR Comments',
type: 'n8n-nodes-base.httpRequest',
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: 'githubApi',
},
credentials: { githubApi: { id: 'cred-foreign', name: 'GitHub account' } },
}),
],
user,
credentialsService,
nodeTypes,
projectId,
);
expect(result.ok).toBe(false);
if (result.ok) throw new Error('unreachable');
expect(result.error).toContain('Fetch PR Comments');
expect(result.error).toContain("credential 'cred-foreign' is not usable");
});
test('validates every credential when the node type cannot be resolved', async () => {
const { credentialsService, nodeTypes } = createMocks({
usableCredentials: [],
getOneImpl: async (id) => ({ id, name: 'Foreign', type: 'slackApi' }),
nodeTypeDescriptions: new Map(), // no types registered → getByNameAndVersion throws
});
const result = await validateWorkflowCredentialReferences(
[makeNode({ credentials: { slackApi: { id: 'cred-foreign', name: 'Foreign' } } })],
user,
credentialsService,
nodeTypes,
projectId,
);
expect(result.ok).toBe(false);
});
});
@@ -642,12 +642,37 @@ describe('update-workflow MCP tool', () => {
if (type === 'n8n-nodes-base.set') {
return { description: { credentials: [] } };
}
if (type === 'n8n-nodes-base.httpRequest') {
// HTTP Request declares its predefined/generic credential selectors
// as `credentialsSelect` properties rather than static credentials.
return {
description: {
credentials: [{ name: 'httpSslAuth' }],
properties: [
{ name: 'nodeCredentialType', type: 'credentialsSelect' },
{ name: 'genericAuthType', type: 'credentialsSelect' },
],
},
};
}
return { description: {} };
}) as typeof nodeTypes.getByNameAndVersion);
// Credentials reachable from the workflow's project (mirrors the
// runtime permission gate).
(credentialsService.getCredentialsAUserCanUseInAWorkflow as jest.Mock).mockResolvedValue([
{ id: 'cred-slack', name: 'My Slack', type: 'slackApi' },
{ id: 'cred-wrong-type', name: 'Wrong', type: 'discordApi' },
]);
// getOne is the user-scoped fallback used only to tell a missing
// credential apart from a cross-project one.
(credentialsService.getOne as jest.Mock).mockImplementation(async (_user, id: string) => {
if (id === 'cred-slack') return { id, name: 'My Slack', type: 'slackApi' };
if (id === 'cred-wrong-type') return { id, name: 'Wrong', type: 'discordApi' };
if (id === 'cred-other-project') {
return { id, name: 'Other Project Slack', type: 'slackApi' };
}
throw new NotFoundError(`Credential with ID "${id}" could not be found.`);
});
});
@@ -759,6 +784,283 @@ describe('update-workflow MCP tool', () => {
expect(workflowService.update).toHaveBeenCalled();
});
test('accepts setNodeCredential for a predefined credential type on an HTTP Request node', async () => {
(credentialsService.getCredentialsAUserCanUseInAWorkflow as jest.Mock).mockResolvedValue([
{ id: 'cred-github', name: 'My GitHub', type: 'githubApi' },
]);
findWorkflowMock.mockResolvedValue(
Object.assign(buildExistingWorkflow(), {
nodes: [
makeNode({
id: 'h',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: 'githubApi',
},
}),
],
connections: {},
}),
);
const result = await callHandler({
workflowId: 'wf-1',
operations: [
{
type: 'setNodeCredential',
nodeName: 'HTTP Request',
credentialKey: 'githubApi',
credentialId: 'cred-github',
credentialName: 'My GitHub',
},
],
});
expect(result.isError).toBeUndefined();
expect(workflowService.update).toHaveBeenCalled();
});
test('accepts addNode binding a predefined credential type on an HTTP Request node', async () => {
(credentialsService.getCredentialsAUserCanUseInAWorkflow as jest.Mock).mockResolvedValue([
{ id: 'cred-github', name: 'My GitHub', type: 'githubApi' },
]);
const result = await callHandler({
workflowId: 'wf-1',
operations: [
{
type: 'addNode',
node: {
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: 'githubApi',
},
credentials: { githubApi: { id: 'cred-github', name: 'My GitHub' } },
},
},
],
});
expect(result.isError).toBeUndefined();
expect(workflowService.update).toHaveBeenCalled();
});
test('rejects a predefined credential type when the HTTP Request node is not configured for it', async () => {
findWorkflowMock.mockResolvedValue(
Object.assign(buildExistingWorkflow(), {
nodes: [
makeNode({
id: 'h',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
parameters: {},
}),
],
connections: {},
}),
);
const result = await callHandler({
workflowId: 'wf-1',
operations: [
{
type: 'setNodeCredential',
nodeName: 'HTTP Request',
credentialKey: 'githubApi',
credentialId: 'cred-github',
credentialName: 'My GitHub',
},
],
});
const response = parseResult(result);
expect(result.isError).toBe(true);
expect(response.error).toContain("does not accept credential 'githubApi'");
expect(workflowService.update).not.toHaveBeenCalled();
});
test('accepts a predefined credential configured via updateNodeParameters earlier in the same batch', async () => {
(credentialsService.getCredentialsAUserCanUseInAWorkflow as jest.Mock).mockResolvedValue([
{ id: 'cred-github', name: 'My GitHub', type: 'githubApi' },
]);
findWorkflowMock.mockResolvedValue(
Object.assign(buildExistingWorkflow(), {
nodes: [
makeNode({
id: 'h',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
parameters: {},
}),
],
connections: {},
}),
);
const result = await callHandler({
workflowId: 'wf-1',
operations: [
{
type: 'updateNodeParameters',
nodeName: 'HTTP Request',
parameters: {
authentication: 'predefinedCredentialType',
nodeCredentialType: 'githubApi',
},
},
{
type: 'setNodeCredential',
nodeName: 'HTTP Request',
credentialKey: 'githubApi',
credentialId: 'cred-github',
credentialName: 'My GitHub',
},
],
});
expect(result.isError).toBeUndefined();
expect(workflowService.update).toHaveBeenCalled();
});
test('accepts a predefined credential configured via setNodeParameter earlier in the same batch', async () => {
(credentialsService.getCredentialsAUserCanUseInAWorkflow as jest.Mock).mockResolvedValue([
{ id: 'cred-github', name: 'My GitHub', type: 'githubApi' },
]);
findWorkflowMock.mockResolvedValue(
Object.assign(buildExistingWorkflow(), {
nodes: [
makeNode({
id: 'h',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4,
parameters: {},
}),
],
connections: {},
}),
);
const result = await callHandler({
workflowId: 'wf-1',
operations: [
{
type: 'setNodeParameter',
nodeName: 'HTTP Request',
path: '/nodeCredentialType',
value: 'githubApi',
},
{
type: 'setNodeCredential',
nodeName: 'HTTP Request',
credentialKey: 'githubApi',
credentialId: 'cred-github',
credentialName: 'My GitHub',
},
],
});
expect(result.isError).toBeUndefined();
expect(workflowService.update).toHaveBeenCalled();
});
test('rejects a dynamic credential key on a node that does not declare a credential selector', async () => {
// A Set node carries nodeCredentialType but exposes no credentialsSelect
// property, so it must not be able to "accept" githubApi just by setting
// the parameter.
findWorkflowMock.mockResolvedValue(
Object.assign(buildExistingWorkflow(), {
nodes: [
makeNode({
id: 's',
name: 'Setter',
type: 'n8n-nodes-base.set',
parameters: { nodeCredentialType: 'githubApi' },
}),
],
connections: {},
}),
);
const result = await callHandler({
workflowId: 'wf-1',
operations: [
{
type: 'setNodeCredential',
nodeName: 'Setter',
credentialKey: 'githubApi',
credentialId: 'cred-github',
credentialName: 'My GitHub',
},
],
});
const response = parseResult(result);
expect(result.isError).toBe(true);
expect(response.error).toContain("does not accept credential 'githubApi'");
expect(workflowService.update).not.toHaveBeenCalled();
});
test('rejects setNodeCredential with a credential from another project', async () => {
findWorkflowMock.mockResolvedValue(
Object.assign(buildExistingWorkflow(), {
nodes: [makeNode({ id: 's', name: 'Slack', type: 'n8n-nodes-base.slack' })],
connections: {},
}),
);
const result = await callHandler({
workflowId: 'wf-1',
operations: [
{
type: 'setNodeCredential',
nodeName: 'Slack',
credentialKey: 'slackApi',
credentialId: 'cred-other-project',
credentialName: 'Other Project Slack',
},
],
});
const response = parseResult(result);
expect(result.isError).toBe(true);
expect(response.error).toContain("credential 'cred-other-project' is not usable");
expect(response.error).toContain("this workflow's project");
expect(workflowService.update).not.toHaveBeenCalled();
});
test('rejects addNode whose credential belongs to another project', async () => {
const result = await callHandler({
workflowId: 'wf-1',
operations: [
{
type: 'addNode',
node: {
name: 'Slack',
type: 'n8n-nodes-base.slack',
typeVersion: 1,
credentials: {
slackApi: { id: 'cred-other-project', name: 'Other Project Slack' },
},
},
},
],
});
const response = parseResult(result);
expect(result.isError).toBe(true);
expect(response.error).toContain("credential 'cred-other-project' is not usable");
expect(workflowService.update).not.toHaveBeenCalled();
});
test('rejects addNode with an unknown credential id', async () => {
const result = await callHandler({
workflowId: 'wf-1',
@@ -3,6 +3,7 @@ import z from 'zod';
import { buildInvalidAiToolSourceErrorResponse } from './connection-structure-check';
import { MCP_CREATE_WORKFLOW_FROM_CODE_TOOL, CODE_BUILDER_VALIDATE_TOOL } from './constants';
import { validateWorkflowCredentialReferences } from './credential-validation';
import { autoPopulateNodeCredentials, stripNullCredentialStubs } from './credentials-auto-assign';
import { validateDataTableReferencesForWorkflow } from './data-table-validation';
import { sanitizeSkillsUsed } from './skills-used';
@@ -241,6 +242,21 @@ export const createCreateWorkflowFromCodeTool = (
effectiveProjectId,
);
// Explicit credential ids in the generated code bypass auto-assignment,
// so verify they're reachable from the target project. This matches the
// runtime permission gate and prevents persisting a cross-project id that
// would only fail at execution time.
const credentialCheck = await validateWorkflowCredentialReferences(
newWorkflow.nodes,
user,
credentialsService,
nodeTypes,
effectiveProjectId,
);
if (!credentialCheck.ok) {
throw new Error(credentialCheck.error);
}
const savedWorkflow = await workflowCreationService.createWorkflow(user, newWorkflow, {
projectId: effectiveProjectId,
parentFolderId: folderId,
@@ -1,5 +1,6 @@
import type { User } from '@n8n/db';
import type { IWorkflowBase } from 'n8n-workflow';
import type { INode, INodeTypeDescription, IWorkflowBase } from 'n8n-workflow';
import { NodeHelpers } from 'n8n-workflow';
import type { CredentialsService } from '@/credentials/credentials.service';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -19,26 +20,255 @@ export interface CredentialValidationSuccess {
export type CredentialValidationResult = CredentialValidationSuccess | CredentialValidationFailure;
export interface WorkflowCredentialValidationFailure {
ok: false;
error: string;
}
export type WorkflowCredentialValidationResult =
| CredentialValidationSuccess
| WorkflowCredentialValidationFailure;
interface NodeMeta {
type: string;
typeVersion: number;
/**
* Node parameters, used to resolve the predefined (`nodeCredentialType`) and
* generic (`genericAuthType`) credential mechanisms that nodes like HTTP
* Request use instead of declaring service credentials statically.
*/
parameters?: Record<string, unknown>;
}
/**
* Whether the node type exposes a `credentialsSelect` property with the given
* name. Only nodes that declare such a selector (e.g. HTTP Request) genuinely
* support binding arbitrary credential types through the `nodeCredentialType`
* (predefined) / `genericAuthType` (generic) parameters. Gating on the property
* stops an arbitrary node from "accepting" a credential just by carrying one of
* those parameter values.
*/
function declaresCredentialSelect(
description: INodeTypeDescription,
propertyName: 'nodeCredentialType' | 'genericAuthType',
): boolean {
return (
description.properties?.some(
(p) => p.name === propertyName && p.type === 'credentialsSelect',
) ?? false
);
}
/**
* Whether a node accepts a given credential key.
*
* A node's statically-declared `credentials` only cover a subset of what some
* nodes can actually use. HTTP Request (and similar) attach service-specific
* credentials at runtime via the `nodeCredentialType` (predefined) and
* `genericAuthType` (generic) parameters, which never appear in
* `description.credentials`. We honor those parameters — like the runtime gate
* (`CredentialsPermissionChecker.getActiveCredentialTypes`) — but only when the
* node actually declares the matching `credentialsSelect` selector, so a node
* can't be made to "accept" a credential just by carrying the parameter.
*/
function nodeAcceptsCredentialKey(
description: INodeTypeDescription,
parameters: Record<string, unknown> | undefined,
credentialKey: string,
): boolean {
if (description.credentials?.some((c) => c.name === credentialKey)) {
return true;
}
const nodeCredentialType = parameters?.nodeCredentialType;
if (
typeof nodeCredentialType === 'string' &&
nodeCredentialType === credentialKey &&
declaresCredentialSelect(description, 'nodeCredentialType')
) {
return true;
}
const genericAuthType = parameters?.genericAuthType;
if (
typeof genericAuthType === 'string' &&
genericAuthType === credentialKey &&
declaresCredentialSelect(description, 'genericAuthType')
) {
return true;
}
return false;
}
/**
* Scope used to determine which credentials are reachable from a workflow.
* For an existing workflow we scope by `workflowId` (covering every project the
* workflow is shared into); for a not-yet-saved workflow we scope by the target
* `projectId`.
*/
export type CredentialProjectScope = { workflowId: string } | { projectId: string };
/**
* Classification of a credential id relative to the workflow's project scope:
* - `usable`: reachable from the workflow's project (mirrors the runtime gate).
* - `cross-project`: the user can see it, but it lives in a project the
* workflow can't use, so execution would reject it.
* - `not-found`: the credential doesn't exist or the user can't access it.
*/
type CredentialClassification =
| { status: 'usable'; type: string }
| { status: 'cross-project'; type: string }
| { status: 'not-found' };
type CredentialClassifier = (credentialId: string) => Promise<CredentialClassification>;
const fail = (opIndex: number, message: string): CredentialValidationFailure => ({
ok: false,
opIndex,
error: `Operation ${opIndex} failed: ${message}`,
});
/**
* Build a classifier that mirrors the runtime credential permission gate
* (`CredentialsPermissionChecker`): a credential is only "usable" if it is
* reachable from the workflow's project(s), not merely accessible to the
* calling user.
*
* The user-scoped `getOne` lookup is used purely as a fallback to tell a
* genuinely missing credential apart from one that exists but belongs to a
* different project, so we can return an actionable error message.
*/
async function buildProjectCredentialClassifier(
user: User,
scope: CredentialProjectScope,
credentialsService: CredentialsService,
): Promise<CredentialClassifier> {
const usable = await credentialsService.getCredentialsAUserCanUseInAWorkflow(user, scope);
const usableTypeById = new Map<string, string>();
for (const credential of usable) {
usableTypeById.set(credential.id, credential.type);
}
const fallbackCache = new Map<string, CredentialClassification>();
return async (credentialId: string): Promise<CredentialClassification> => {
const usableType = usableTypeById.get(credentialId);
if (usableType !== undefined) {
return { status: 'usable', type: usableType };
}
const cached = fallbackCache.get(credentialId);
if (cached) return cached;
let classification: CredentialClassification;
try {
const credential = await credentialsService.getOne(user, credentialId, false);
classification = { status: 'cross-project', type: credential.type };
} catch (error) {
if (error instanceof NotFoundError) {
classification = { status: 'not-found' };
} else {
throw error;
}
}
fallbackCache.set(credentialId, classification);
return classification;
};
}
/**
* Lazily build the project credential classifier so callers that never touch a
* credential reference don't pay for the (potentially expensive) credential
* lookup. The classifier is memoised on first use.
*/
function createLazyClassifier(
user: User,
scope: CredentialProjectScope,
credentialsService: CredentialsService,
): () => Promise<CredentialClassifier> {
let classifierPromise: Promise<CredentialClassifier> | undefined;
return async () => {
classifierPromise ??= buildProjectCredentialClassifier(user, scope, credentialsService);
return await classifierPromise;
};
}
/**
* Turn a credential classification into a human-readable reason, or `null` when
* the reference is valid. Shared between the create and update validation paths
* so both surface identical wording.
*/
function describeCredentialProblem(
classification: CredentialClassification,
credentialId: string,
credentialKey: string,
): string | null {
if (classification.status === 'not-found') {
return `credential '${credentialId}' not found or not accessible`;
}
if (classification.status === 'cross-project') {
return `credential '${credentialId}' is not usable in this workflow's project. Omit it so a credential from the project is auto-assigned, share the credential with the project, or use a credential that belongs to the project`;
}
if (classification.type !== credentialKey) {
return `credential '${credentialId}' is type '${classification.type}' but '${credentialKey}' is expected`;
}
return null;
}
/**
* Determine which credential types a node actively uses, mirroring the runtime
* `CredentialsPermissionChecker.getActiveCredentialTypes`. Returns `null` when
* the node type can't be resolved, signalling that every credential reference
* should be checked as a safe fallback.
*/
function computeActiveCredentialTypes(node: INode, nodeTypes: NodeTypes): Set<string> | null {
let description: INodeTypeDescription;
try {
description = nodeTypes.getByNameAndVersion(node.type, node.typeVersion).description;
} catch {
return null;
}
const activeTypes = new Set<string>();
for (const credDef of description.credentials ?? []) {
if (NodeHelpers.displayParameter(node.parameters, credDef, node, description)) {
activeTypes.add(credDef.name);
}
}
// Nodes using a predefined credential type (e.g. HTTP Request) declare the
// active credential via the nodeCredentialType parameter rather than the
// static credentials array.
const { nodeCredentialType } = node.parameters;
if (typeof nodeCredentialType === 'string' && nodeCredentialType) {
activeTypes.add(nodeCredentialType);
}
// Generic credential types (e.g. HTTP Request with
// authentication=genericCredentialType) live in genericAuthType.
const { genericAuthType } = node.parameters;
if (typeof genericAuthType === 'string' && genericAuthType) {
activeTypes.add(genericAuthType);
}
return activeTypes;
}
/**
* Validate every credential reference introduced by the batch against the
* caller's accessible credentials and against the target node-type's declared
* credential keys.
* credentials reachable from the workflow's project and against the target
* node-type's declared credential keys.
*
* Only credentials touched by ops in this batch are checked — pre-existing
* credential references on nodes the agent didn't touch are left alone, so a
* pre-existing invalid reference can't block an unrelated edit.
*
* The check is project-scoped to match the runtime permission gate, so a
* credential that is accessible to the user but not reachable from the
* workflow's project is rejected here instead of failing only at execution.
*
* The check is non-destructive: it only does DB reads and node-type lookups.
* On the first failure we stop and return the offending op index so the
* handler can surface it via the standard `Operation N failed: ...` envelope.
@@ -49,30 +279,18 @@ export async function validateCredentialReferences(
user: User,
credentialsService: CredentialsService,
nodeTypes: NodeTypes,
scope: CredentialProjectScope,
): Promise<CredentialValidationResult> {
const nameToNodeMeta = new Map<string, NodeMeta>();
for (const node of existingWorkflow.nodes) {
nameToNodeMeta.set(node.name, { type: node.type, typeVersion: node.typeVersion });
nameToNodeMeta.set(node.name, {
type: node.type,
typeVersion: node.typeVersion,
parameters: node.parameters,
});
}
const credentialCache = new Map<string, { type: string } | 'not-found'>();
const lookupCredential = async (credentialId: string) => {
const cached = credentialCache.get(credentialId);
if (cached) return cached;
try {
const credential = await credentialsService.getOne(user, credentialId, false);
const result = { type: credential.type };
credentialCache.set(credentialId, result);
return result;
} catch (error) {
if (error instanceof NotFoundError) {
credentialCache.set(credentialId, 'not-found');
return 'not-found' as const;
}
throw error;
}
};
const getClassifier = createLazyClassifier(user, scope, credentialsService);
const checkCredentialReference = async (
opIndex: number,
@@ -87,25 +305,17 @@ export async function validateCredentialReferences(
return null;
}
const accepted = description.credentials?.find((c) => c.name === credentialKey);
if (!accepted) {
if (!nodeAcceptsCredentialKey(description, nodeMeta.parameters, credentialKey)) {
return fail(
opIndex,
`node type '${nodeMeta.type}' does not accept credential '${credentialKey}'`,
);
}
const credential = await lookupCredential(credentialId);
if (credential === 'not-found') {
return fail(opIndex, `credential '${credentialId}' not found or not accessible`);
}
if (credential.type !== credentialKey) {
return fail(
opIndex,
`credential '${credentialId}' is type '${credential.type}' but '${credentialKey}' is expected`,
);
}
const classify = await getClassifier();
const classification = await classify(credentialId);
const problem = describeCredentialProblem(classification, credentialId, credentialKey);
if (problem) return fail(opIndex, problem);
return null;
};
@@ -114,7 +324,11 @@ export async function validateCredentialReferences(
const op = operations[i];
if (op.type === 'addNode') {
const nodeMeta: NodeMeta = { type: op.node.type, typeVersion: op.node.typeVersion };
const nodeMeta: NodeMeta = {
type: op.node.type,
typeVersion: op.node.typeVersion,
parameters: op.node.parameters,
};
if (op.node.credentials) {
for (const [key, value] of Object.entries(op.node.credentials)) {
if (!value.id) continue;
@@ -131,6 +345,23 @@ export async function validateCredentialReferences(
}
} else if (op.type === 'removeNode') {
nameToNodeMeta.delete(op.nodeName);
} else if (op.type === 'updateNodeParameters') {
// Keep tracked parameters current so a credential bound later in the
// same batch is validated against the node's updated configuration
// rather than its pre-batch state.
const meta = nameToNodeMeta.get(op.nodeName);
if (meta) {
meta.parameters = op.replace
? { ...op.parameters }
: { ...(meta.parameters ?? {}), ...op.parameters };
}
} else if (op.type === 'setNodeParameter') {
// Only the top-level auth selectors decide which credential a node
// accepts (see nodeAcceptsCredentialKey); other paths don't affect it.
const meta = nameToNodeMeta.get(op.nodeName);
if (meta && (op.path === '/nodeCredentialType' || op.path === '/genericAuthType')) {
meta.parameters = { ...(meta.parameters ?? {}), [op.path.slice(1)]: op.value };
}
} else if (op.type === 'setNodeCredential') {
const meta = nameToNodeMeta.get(op.nodeName);
if (!meta) continue;
@@ -141,3 +372,48 @@ export async function validateCredentialReferences(
return { ok: true };
}
/**
* Validate every credential reference already present on a set of workflow
* nodes against the credentials reachable from the target project.
*
* Used by the create-from-code path, where explicit credential ids baked into
* the generated SDK code bypass auto-assignment (which only fills *missing*
* credentials). Without this check, an id pointing at a credential in another
* project would be written verbatim and only fail at execution time.
*
* Mirrors the runtime permission gate: disabled nodes are skipped, and only
* credential types the node actively uses are checked, so we don't block on a
* binding the node wouldn't actually load.
*/
export async function validateWorkflowCredentialReferences(
nodes: INode[],
user: User,
credentialsService: CredentialsService,
nodeTypes: NodeTypes,
projectId: string,
): Promise<WorkflowCredentialValidationResult> {
const getClassifier = createLazyClassifier(user, { projectId }, credentialsService);
for (const node of nodes) {
if (node.disabled || !node.credentials) continue;
const activeTypes = computeActiveCredentialTypes(node, nodeTypes);
for (const [credentialKey, ref] of Object.entries(node.credentials)) {
const credentialId = ref?.id;
if (!credentialId) continue;
// Skip credentials the node's current configuration doesn't load.
if (activeTypes !== null && !activeTypes.has(credentialKey)) continue;
const classify = await getClassifier();
const classification = await classify(credentialId);
const problem = describeCredentialProblem(classification, credentialId, credentialKey);
if (problem) {
return { ok: false, error: `Node "${node.name}": ${problem}` };
}
}
}
return { ok: true };
}
@@ -310,6 +310,7 @@ export const createUpdateWorkflowTool = (
user,
credentialsService,
nodeTypes,
{ workflowId: existingWorkflow.id },
);
if (!credentialCheck.ok) {
throw new Error(credentialCheck.error);