mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
fix(core): Prevent assigning unusable credentials in mcp (#32353)
This commit is contained in:
committed by
GitHub
parent
229560e3bc
commit
7ddde951cc
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user