n8n/packages/cli/src/workflows/workflow-validation.service.ts

661 lines
22 KiB
TypeScript

import type { User } from '@n8n/db';
import { CredentialsRepository, WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { In } from '@n8n/typeorm';
import { ensureError } from '@n8n/utils/errors/ensure-error';
import { FULL_ACCESS_NODE_TYPES } from 'n8n-core';
import {
validateWorkflowHasTriggerLikeNode,
NodeHelpers,
mapConnectionsByDestination,
validateNodeCredentials,
isNodeConnected,
isTriggerLikeNode,
isTriggerNode,
classifyTriggerIdentity,
} from 'n8n-workflow';
import type {
INode,
INodes,
IConnections,
INodeType,
IWorkflowSettings,
ICredentialType,
} from 'n8n-workflow';
import { STARTING_NODES } from '@/constants';
import { CredentialTypes } from '@/credential-types';
import { DynamicCredentialsProxy } from '@/credentials/dynamic-credentials-proxy';
import { CredentialsPermissionChecker } from '@/executions/pre-execution-checks/credentials-permission-checker';
import type { NodeTypes } from '@/node-types';
export interface WorkflowValidationResult {
isValid: boolean;
error?: string;
}
export interface SubWorkflowValidationResult extends WorkflowValidationResult {
invalidReferences?: Array<{
nodeName: string;
workflowId: string;
workflowName?: string;
}>;
}
export interface WorkflowStatus {
exists: boolean;
isPublished: boolean;
name?: string;
}
/** Formats credential names as a quoted, comma-separated list for error messages. */
function formatCredentialNames(credentials: Array<{ name: string }>): string {
return credentials.map((c) => `"${c.name}"`).join(', ');
}
@Service()
export class WorkflowValidationService {
constructor(
private readonly workflowRepository: WorkflowRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly dynamicCredentialsProxy: DynamicCredentialsProxy,
private readonly credentialTypes: CredentialTypes,
private readonly credentialsPermissionChecker: CredentialsPermissionChecker,
) {}
/**
* Validates node configuration (credentials, parameters) for connected and enabled nodes.
* Trigger-like nodes are always validated even without connections.
*/
private validateNodeConfiguration(
nodes: INode[],
connections: IConnections,
nodeTypes: NodeTypes,
): WorkflowValidationResult {
try {
const connectionsByDestination = mapConnectionsByDestination(connections);
const issuesFound: Array<{ nodeName: string; issues: string[] }> = [];
for (const node of nodes) {
try {
if (node.disabled) continue;
const nodeType = nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
if (!nodeType) {
issuesFound.push({
nodeName: node.name,
issues: ['Node type not found'],
});
continue;
}
const isNodeTriggerLike = isTriggerLikeNode(nodeType);
const isConnected = isNodeConnected(node.name, connections, connectionsByDestination);
if (!isConnected && !isNodeTriggerLike) continue;
const nodeIssues: string[] = [];
const credentialIssues = validateNodeCredentials(node, nodeType);
// Convert credential issues to error messages
for (const issue of credentialIssues) {
if (issue.type === 'missing') {
nodeIssues.push(`Missing required credential: ${issue.displayName}`);
} else if (issue.type === 'not-configured') {
nodeIssues.push(`Credential not configured: ${issue.displayName}`);
}
}
const parameterIssues = this.validateNodeParameters(node, nodeType);
nodeIssues.push(...parameterIssues);
if (nodeIssues.length > 0) {
issuesFound.push({
nodeName: node.name,
issues: nodeIssues,
});
}
} catch (nodeError) {
issuesFound.push({
nodeName: node.name,
issues: [`Error validating node: ${ensureError(nodeError).message}`],
});
}
}
if (issuesFound.length === 0) {
return { isValid: true };
}
const errorLines = issuesFound.map((item) => {
const issuesList = item.issues.map((issue) => ` - ${issue}`).join('\n');
return `Node "${item.nodeName}":\n${issuesList}`;
});
const nodeCount = issuesFound.length;
const pluralSuffix = nodeCount === 1 ? '' : 's';
const error = `Cannot publish workflow: ${nodeCount} node${pluralSuffix} have configuration issues:\n\n${errorLines.join('\n\n')}`;
return {
isValid: false,
error,
};
} catch (error) {
return {
isValid: false,
error: `Workflow validation failed: ${ensureError(error).message}`,
};
}
}
/**
* Validates node parameters using NodeHelpers.
*/
private validateNodeParameters(node: INode, nodeType: INodeType): string[] {
const issues: string[] = [];
try {
if (!nodeType.description?.properties) {
return issues;
}
const nodeIssues = NodeHelpers.getNodeParametersIssues(
nodeType.description.properties,
node,
nodeType.description,
);
if (nodeIssues?.parameters) {
const paramNames = Object.keys(nodeIssues.parameters);
if (paramNames.length > 0) {
issues.push(`Missing or invalid required parameters: ${paramNames.join(', ')}`);
}
}
} catch (error) {
issues.push('Error validating node parameters');
}
return issues;
}
/**
* Rejects workflows that bind a credential whose type sets
* `restrictToSupportedNodes: true` to a node not listed in its `supportedNodes`.
* Runs on every save — illegal bindings should never be persisted.
*/
validateCredentialNodeRestrictions(nodes: INode[]): WorkflowValidationResult {
const violations: string[] = [];
for (const node of nodes) {
// Validate disabled nodes too — illegal bindings must never be persisted.
// A disabled node can be re-enabled later (separate save, direct DB write,
// workflow import), and the DB state should remain consistent regardless.
if (!node.credentials) continue;
const activeCredentialTypes = this.getActiveCredentialTypes(node);
for (const credentialType of activeCredentialTypes) {
if (!node.credentials[credentialType]) continue;
let typeDef: ICredentialType | undefined;
try {
typeDef = this.credentialTypes.getByName(credentialType);
} catch {
continue; // unknown type — let other validators surface it
}
if (!typeDef?.restrictToSupportedNodes) continue;
// `typeDef.supportedNodes` from the loader holds short node names
// (e.g. "mattermost"), but `node.type` is fully qualified
// (e.g. "n8n-nodes-base.mattermost"). `getSupportedNodes` returns
// the post-processed FQ list so the comparison can match.
const supportedNodes = this.credentialTypes.getSupportedNodes(credentialType);
if (supportedNodes.includes(node.type)) continue;
violations.push(
`Node "${node.name}" (${node.type}) cannot use credential type "${credentialType}" — it is restricted to: ${
supportedNodes.length > 0 ? supportedNodes.join(', ') : '(no nodes)'
}.`,
);
}
}
if (violations.length === 0) return { isValid: true };
return {
isValid: false,
error: `Cannot save workflow: ${violations.join(' ')}`,
};
}
/**
* Rejects trigger nodes whose ids do not uniquely identify them.
*
* Publication records one `workflow_publication_trigger_status` row per
* enabled trigger node, keyed by the composite primary key
* `(workflowId, nodeId)`. A shared or absent id therefore collapses two
* triggers onto one key: the diff in `computeTriggerDiff` loses one of them,
* and the row insert fails on the constraint. Catching it here turns a
* constraint violation raised mid-publication into a message naming the
* offending nodes.
*
* An absent id is the same defect as a shared one — `undefined` is just an id
* that every id-less node has in common — so both are reported together.
*/
validateTriggerNodeIds(triggerNodes: INode[]): WorkflowValidationResult {
const violations: string[] = [];
const namesById = new Map<string, string[]>();
for (const node of triggerNodes) {
if (!node.id) {
violations.push(`trigger "${node.name}" has no node ID`);
continue;
}
const names = namesById.get(node.id);
if (names) {
names.push(node.name);
} else {
namesById.set(node.id, [node.name]);
}
}
for (const [nodeId, names] of namesById) {
if (names.length < 2) continue;
const nameList = names.map((name) => `"${name}"`).join(', ');
violations.push(`triggers ${nameList} share the node ID "${nodeId}"`);
}
if (violations.length === 0) return { isValid: true };
return {
isValid: false,
error: `Cannot publish workflow: ${violations.join('; ')}. Remove and re-add the affected nodes to give them new IDs.`,
};
}
/**
* Returns the credential types that are actively in use on a node — the
* subset of `node.credentials` keys we should validate against.
*
* For "full-access" nodes (HTTP Request + its tool variants), the editor
* spreads `node.credentials` instead of replacing it when the user switches
* credential type, leaving inactive keys behind. Only the credential
* pointed at by the active `authentication` parameter is in use, so we
* limit the check to that one — otherwise the validator would reject a
* save based on an entry the user can't even see in the UI.
*
* For all other nodes, every entry in `node.credentials` corresponds to a
* declared credential the node may use (gated by `displayOptions` at
* runtime), so all keys remain candidates.
*/
private getActiveCredentialTypes(node: INode): string[] {
if (!node.credentials) return [];
if (!FULL_ACCESS_NODE_TYPES.has(node.type)) {
return Object.keys(node.credentials);
}
const params = (node.parameters ?? {}) as Record<string, unknown>;
const auth = typeof params.authentication === 'string' ? params.authentication : null;
if (auth === 'predefinedCredentialType') {
const cred = params.nodeCredentialType;
return typeof cred === 'string' && cred.length > 0 ? [cred] : [];
}
if (auth === 'genericCredentialType') {
const cred = params.genericAuthType;
return typeof cred === 'string' && cred.length > 0 ? [cred] : [];
}
return [];
}
validateForActivation(
nodes: INodes,
connections: IConnections,
nodeTypes: NodeTypes,
): WorkflowValidationResult {
// Validate workflow entry points: active, poll, webhook, or schedule triggers.
const triggerValidation = validateWorkflowHasTriggerLikeNode(nodes, nodeTypes, STARTING_NODES);
if (!triggerValidation.isValid) {
return {
isValid: false,
error:
triggerValidation.error ??
'Workflow cannot be activated because it has no trigger node. At least one active trigger, poll trigger, webhook trigger, or schedule trigger node is required.',
};
}
// Validate node configuration (credentials, parameters)
const nodesArray = Object.values(nodes);
const configValidation = this.validateNodeConfiguration(nodesArray, connections, nodeTypes);
if (!configValidation.isValid) {
return configValidation;
}
return { isValid: true };
}
/**
* Validates that a workflow using dynamic (resolvable) credentials has a
* resolver configured and a trigger that provides the identity it needs.
*
* The resolver is workflow-level (`settings.credentialResolverId` override or
* the seeded system resolver):
* - A custom resolver (OAuth, Slack, …) keys on an external identity extracted
* from trigger data, so it needs a trigger with a context establishment hook.
* - The default/system resolver keys on the n8n user identity, so it needs a
* trigger that establishes it (manual, sub-workflow, Chat Hub chat, MCP, form, or webhook with n8n user auth).
*/
async validateDynamicCredentials(
nodes: INode[],
nodeTypes: NodeTypes,
workflowSettings?: IWorkflowSettings,
): Promise<WorkflowValidationResult> {
const credentialIds = this.collectCredentialIds(nodes);
if (credentialIds.size === 0) {
return { isValid: true };
}
const resolvableCredentials = await this.credentialsRepository.find({
where: { id: In([...credentialIds]), isResolvable: true, usageScope: 'project' },
select: ['id', 'name'],
});
if (resolvableCredentials.length === 0) {
return { isValid: true };
}
const credNames = formatCredentialNames(resolvableCredentials);
// Workflow override if present, otherwise the seeded system resolver (null when neither).
const workflowResolverId =
this.dynamicCredentialsProxy.getEffectiveResolverId(workflowSettings);
const triggers = this.classifyTriggerIdentities(nodes, nodeTypes);
const error = this.getDynamicCredentialsError(workflowResolverId, credNames, triggers);
return error
? { isValid: false, error: `Cannot publish workflow: ${error}` }
: { isValid: true };
}
/** A published workflow runs as its publisher, so the publisher must be able to use every credential it references. */
async validatePublisherCredentialAccess(
user: User,
nodes: INode[],
): Promise<WorkflowValidationResult> {
const inaccessible = await this.credentialsPermissionChecker.findInaccessibleForUser(
user.id,
nodes,
);
if (inaccessible.length === 0) return { isValid: true };
const unshared = inaccessible.filter((c) => c.exists);
const missing = inaccessible.filter((c) => !c.exists);
const sentences: string[] = [];
if (unshared.length > 0) {
const plural = unshared.length > 1;
const credNames = formatCredentialNames(unshared);
sentences.push(
`You do not have access to credential${plural ? 's' : ''} ${credNames}. Ask ${plural ? 'their owners' : 'its owner'} to share ${plural ? 'them' : 'it'} with you.`,
);
}
if (missing.length > 0) {
const plural = missing.length > 1;
const credNames = formatCredentialNames(missing);
sentences.push(
`Credential${plural ? 's' : ''} ${credNames} no longer exist${plural ? '' : 's'}. Update the node to use a different credential.`,
);
}
return { isValid: false, error: `Cannot publish workflow: ${sentences.join(' ')}` };
}
/**
* Returns the publish error for the workflow's resolvable credentials, or
* `undefined` when they are valid.
*/
private getDynamicCredentialsError(
workflowResolverId: string | null,
credNames: string,
triggers: {
allTriggersProvideExternalIdentity: boolean;
allTriggersProvideN8nIdentity: boolean;
},
): string | undefined {
if (!workflowResolverId) {
return `end-user credentials (${credNames}) require a resolver to be configured.`;
}
const { allTriggersProvideExternalIdentity, allTriggersProvideN8nIdentity } = triggers;
if (workflowResolverId === this.dynamicCredentialsProxy.getSystemResolverId()) {
// System resolver: every trigger must establish the n8n user identity. Chat and MCP only
// qualify in their identity-carrying configurations.
if (allTriggersProvideN8nIdentity) return undefined;
const triggersList = this.getN8nUserAuthTriggersList();
return `end-user credentials (${credNames}) are only supported with ${triggersList}. To use another trigger, switch the credential to Fixed.`;
}
// Custom resolver: every trigger must provide an external identity.
if (allTriggersProvideExternalIdentity) return undefined;
return `end-user credentials (${credNames}) require a trigger with an identity extractor configured. Please configure an identity extractor on the trigger node.`;
}
/**
* Describes which trigger configurations the system resolver currently accepts,
* for the publish-error copy. Chat qualifies when available in Chat Hub, or with
* `n8nUserAuth` in hosted-chat mode specifically — embedded/webhook-mode chat has
* no page to run the OAuth2 handshake on, so it establishes no identity there; MCP
* only with n8n user auth (OAuth2). Mirrors `classifyTriggerIdentity`.
*/
private getN8nUserAuthTriggersList(): string {
return 'manual and sub-workflow triggers, chat triggers available in n8n Chat Hub or using n8n user authentication in hosted chat mode, and MCP, form, or webhook triggers with n8n user authentication';
}
/** Collects the ids of all credentials referenced by enabled nodes. */
private collectCredentialIds(nodes: INode[]): Set<string> {
const credentialIds = new Set<string>();
for (const node of nodes) {
if (node.disabled) continue;
for (const credName of Object.keys(node.credentials ?? {})) {
const credId = node.credentials?.[credName]?.id;
if (credId) {
credentialIds.add(credId);
}
}
}
return credentialIds;
}
/**
* Classifies a workflow's triggers by the identity every one of them can provide:
* - `allTriggersProvideExternalIdentity`: every enabled trigger provides an external
* identity (context hook, Chat Hub, sub-workflow).
* - `allTriggersProvideN8nIdentity`: every enabled trigger provides the n8n user
* identity (manual, Chat Hub, MCP with n8n OAuth2, sub-workflow).
*
* A single unsupported trigger disqualifies the whole workflow, so a manual trigger
* cannot mask another trigger that can't establish identity. A workflow with no
* triggers provides neither.
*
* The per-trigger classification lives in `classifyTriggerIdentity` (n8n-workflow)
* so the editor's trigger-compatibility warning can reuse the exact same rules.
*/
private classifyTriggerIdentities(
nodes: INode[],
nodeTypes: NodeTypes,
): {
allTriggersProvideExternalIdentity: boolean;
allTriggersProvideN8nIdentity: boolean;
} {
let allTriggersProvideExternalIdentity = true;
let allTriggersProvideN8nIdentity = true;
let hasTrigger = false;
for (const node of nodes) {
if (node.disabled) continue;
const nodeType = nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
// Only real workflow entry-points count. `isTriggerLikeNode` keys on a `webhook`/
// `poll`/`trigger` method, which also matches "Send and Wait for Response" action
// nodes (and their AI-tool variants) — those carry a HITL webhook but are not
// triggers, and would wrongly poison the identity check. `isTriggerNode` keys on
// the node group, which only true triggers declare.
if (!nodeType?.description || !isTriggerNode(nodeType.description)) continue;
hasTrigger = true;
const { providesExternalIdentity, providesN8nIdentity } = classifyTriggerIdentity(
node.type,
node.parameters,
);
allTriggersProvideExternalIdentity &&= providesExternalIdentity;
allTriggersProvideN8nIdentity &&= providesN8nIdentity;
}
if (!hasTrigger) {
return { allTriggersProvideExternalIdentity: false, allTriggersProvideN8nIdentity: false };
}
return { allTriggersProvideExternalIdentity, allTriggersProvideN8nIdentity };
}
/**
* Validates that all sub-workflow references in a workflow are published.
*
* This prevents publishing a parent workflow that references draft-only sub-workflows,
* which would cause runtime errors when the workflow is triggered in production.
*
*/
async validateSubWorkflowReferences(
workflowId: string,
nodes: INode[],
): Promise<SubWorkflowValidationResult> {
const executeWorkflowNodes = nodes.filter(
(node) => node.type === 'n8n-nodes-base.executeWorkflow' && !node.disabled,
);
if (executeWorkflowNodes.length === 0) {
return { isValid: true };
}
const invalidReferences: Array<{
nodeName: string;
workflowId: string;
workflowName?: string;
}> = [];
for (const node of executeWorkflowNodes) {
const subWorkflowId = this.extractWorkflowId(node);
const source: string | undefined =
typeof node.parameters?.source === 'string' ? node.parameters.source : undefined;
if (this.shouldSkipSubWorkflowValidation(subWorkflowId, source)) {
continue;
}
const status = await this.getWorkflowStatus(workflowId, subWorkflowId!);
if (!status.exists || !status.isPublished) {
invalidReferences.push({
nodeName: node.name,
workflowId: subWorkflowId!,
workflowName: status.exists ? status.name : undefined,
});
}
}
if (invalidReferences.length > 0) {
const errorMessages = invalidReferences.map((ref) => {
const workflowName = ref.workflowName ? ` ("${ref.workflowName}")` : '';
return `Node "${ref.nodeName}" references workflow ${ref.workflowId}${workflowName} which is not published`;
});
return {
isValid: false,
error: `Cannot publish workflow: ${errorMessages.join('; ')}. Please publish all referenced sub-workflows first.`,
invalidReferences,
};
}
return { isValid: true };
}
/**
* Gets the status of a sub-workflow for validation purposes.
* Allows self-referencing workflows (workflow calling itself).
*/
private async getWorkflowStatus(
parentWorkflowId: string,
subWorkflowId: string,
): Promise<WorkflowStatus> {
// Allow self-reference (workflow calling itself)
if (subWorkflowId === parentWorkflowId) {
return { exists: true, isPublished: true };
}
const subWorkflow = await this.workflowRepository.get({ id: subWorkflowId }, { relations: [] });
if (!subWorkflow) {
return { exists: false, isPublished: false };
}
return {
exists: true,
isPublished: subWorkflow.activeVersionId !== null,
name: subWorkflow.name,
};
}
/**
* Type guard to check if a value is an object with a value property.
*/
private hasValueProperty(obj: unknown): obj is { value?: string } {
return typeof obj === 'object' && obj !== null && 'value' in obj;
}
/**
* Extracts the workflow ID from an Execute Workflow node's parameters.
* Handles both old format (string) and new format (object with value property).
*/
private extractWorkflowId(node: INode): string | undefined {
const workflowIdParam = node.parameters?.workflowId;
if (this.hasValueProperty(workflowIdParam)) {
return workflowIdParam.value;
}
if (typeof workflowIdParam === 'string') {
return workflowIdParam;
}
return undefined;
}
/**
* Determines if a sub-workflow reference should be skipped during validation.
* Skips when:
* - No workflow ID is provided
* - Workflow ID is an expression (starts with '=')
* - Source is not 'database' (e.g., url, parameter, localFile)
*/
private shouldSkipSubWorkflowValidation(
workflowId: string | undefined,
source: string | undefined,
): boolean {
if (!workflowId) return true;
if (workflowId.startsWith('=')) return true;
if (source && source !== 'database') return true;
return false;
}
}