n8n/packages/cli/src/workflows/workflows.controller.ts

742 lines
22 KiB
TypeScript

import {
ActivateWorkflowDto,
ArchiveWorkflowDto,
CreateWorkflowDto,
DeactivateWorkflowDto,
ExecutionRedactionQueryDtoSchema,
ImportWorkflowFromUrlDto,
ROLE,
TransferWorkflowBodyDto,
UpdateWorkflowDto,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { GlobalConfig, SsrfProtectionConfig } from '@n8n/config';
import {
SharedWorkflow,
WorkflowEntity,
ProjectRelationRepository,
ProjectRepository,
WorkflowRepository,
AuthenticatedRequest,
} from '@n8n/db';
import {
Body,
Delete,
Get,
Licensed,
Param,
Patch,
Post,
ProjectScope,
Put,
Query,
RestController,
} from '@n8n/decorators';
import { PROJECT_OWNER_ROLE_SLUG } from '@n8n/permissions';
// eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import
import { In, type FindOptionsRelations } from '@n8n/typeorm';
import axios, { type AxiosRequestConfig } from 'axios';
import express from 'express';
import { calculateWorkflowChecksum, ensureError } from 'n8n-workflow';
import { CollaborationService } from '../collaboration/collaboration.service';
import { WorkflowCreationService } from './workflow-creation.service';
import { WorkflowExecutionService } from './workflow-execution.service';
import { WorkflowFinderService } from './workflow-finder.service';
import { WorkflowRequest } from './workflow.request';
import { WorkflowService } from './workflow.service';
import { EnterpriseWorkflowService } from './workflow.service.ee';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { EventService } from '@/events/event.service';
import { ExecutionService } from '@/executions/execution.service';
import type { IWorkflowResponse } from '@/interfaces';
import { License } from '@/license';
import { listQueryMiddleware } from '@/middlewares';
import { userHasScopes } from '@/permissions.ee/check-access';
import * as ResponseHelper from '@/response-helper';
import { NamingService } from '@/services/naming.service';
import { ProjectService } from '@/services/project.service.ee';
import { SsrfBlockedIpError } from '@/services/ssrf/ssrf-blocked-ip.error';
import { SsrfProtectionService } from '@/services/ssrf/ssrf-protection.service';
import { UserManagementMailer } from '@/user-management/email';
import * as utils from '@/utils';
@RestController('/workflows')
export class WorkflowsController {
constructor(
private readonly logger: Logger,
private readonly enterpriseWorkflowService: EnterpriseWorkflowService,
private readonly namingService: NamingService,
private readonly workflowRepository: WorkflowRepository,
private readonly workflowService: WorkflowService,
private readonly workflowCreationService: WorkflowCreationService,
private readonly workflowExecutionService: WorkflowExecutionService,
private readonly license: License,
private readonly mailer: UserManagementMailer,
private readonly projectRepository: ProjectRepository,
private readonly projectService: ProjectService,
private readonly projectRelationRepository: ProjectRelationRepository,
private readonly eventService: EventService,
private readonly globalConfig: GlobalConfig,
private readonly workflowFinderService: WorkflowFinderService,
private readonly executionService: ExecutionService,
private readonly collaborationService: CollaborationService,
private readonly ssrfConfig: SsrfProtectionConfig,
private readonly ssrfProtectionService: SsrfProtectionService,
) {}
@Post('/')
async create(req: AuthenticatedRequest, _res: unknown, @Body body: CreateWorkflowDto) {
if (body.id) {
const workflowExists = await this.workflowRepository.existsBy({ id: body.id });
if (workflowExists) {
throw new BadRequestError(`Workflow with id ${body.id} exists already.`);
}
}
const newWorkflow = new WorkflowEntity();
// Security: Object.assign is now safe because the DTO validates and filters all input
// Only fields defined in CreateWorkflowDto are assigned; internal fields like
// triggerCount, versionCounter, isArchived, etc. are never set from user input
Object.assign(newWorkflow, body);
const savedWorkflow = await this.workflowCreationService.createWorkflow(req.user, newWorkflow, {
tagIds: body.tags,
parentFolderId: body.parentFolderId,
projectId: body.projectId,
autosaved: body.autosaved,
uiContext: body.uiContext,
});
const savedWorkflowWithMetaData =
this.enterpriseWorkflowService.addOwnerAndSharings(savedWorkflow);
// @ts-expect-error: This is added as part of addOwnerAndSharings but
// shouldn't be returned to the frontend
delete savedWorkflowWithMetaData.shared;
const scopes = await this.workflowService.getWorkflowScopes(req.user, savedWorkflow.id);
const checksum = await calculateWorkflowChecksum(savedWorkflow);
return { ...savedWorkflowWithMetaData, scopes, checksum };
}
@Get('/', { middlewares: listQueryMiddleware })
async getAll(req: WorkflowRequest.GetMany, res: express.Response) {
try {
const userCanListProjectFolders = req.listQueryOptions?.filter?.projectId
? await userHasScopes(req.user, ['folder:list'], false, {
projectId: req.listQueryOptions?.filter?.projectId as string,
})
: true;
const { workflows: data, count } = await this.workflowService.getMany(
req.user,
req.listQueryOptions,
!!req.query.includeScopes,
userCanListProjectFolders && !!req.query.includeFolders,
!!req.query.onlySharedWithMe,
);
res.json({ count, data });
} catch (maybeError) {
const error = utils.toError(maybeError);
ResponseHelper.reportError(error);
ResponseHelper.sendErrorResponse(res, error);
}
}
@Get('/new')
async getNewName(req: WorkflowRequest.NewName) {
const projectId = req.query.projectId;
if (
!(await this.projectService.getProjectWithScope(req.user, projectId, ['workflow:create']))
) {
throw new ForbiddenError(
"You don't have the permissions to create a workflow in this project.",
);
}
const requestedName = req.query.name ?? this.globalConfig.workflows.defaultName;
const name = await this.namingService.getUniqueWorkflowName(requestedName);
return { name };
}
@Get('/from-url')
async getFromUrl(
req: AuthenticatedRequest,
_res: express.Response,
@Query query: ImportWorkflowFromUrlDto,
) {
const projectId = query.projectId;
if (
!(await this.projectService.getProjectWithScope(req.user, projectId, ['workflow:create']))
) {
throw new ForbiddenError(
"You don't have the permissions to create a workflow in this project.",
);
}
const workflowData = await this.fetchWorkflowFromUrl(query.url);
// Do a very basic check if it is really a n8n-workflow-json
if (
workflowData?.nodes === undefined ||
!Array.isArray(workflowData.nodes) ||
workflowData.connections === undefined ||
typeof workflowData.connections !== 'object' ||
Array.isArray(workflowData.connections)
) {
throw new BadRequestError(
'The data in the file does not seem to be a n8n workflow JSON file!',
);
}
return workflowData;
}
@Get('/:workflowId')
@ProjectScope('workflow:read')
async getWorkflow(req: WorkflowRequest.Get) {
const { workflowId } = req.params;
if (this.license.isSharingEnabled()) {
const relations: FindOptionsRelations<WorkflowEntity> = {
shared: {
project: {
projectRelations: true,
},
},
};
if (!this.globalConfig.tags.disabled) {
relations.tags = true;
}
const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId,
req.user,
['workflow:read'],
{
includeTags: !this.globalConfig.tags.disabled,
includeParentFolder: true,
includeActiveVersion: true,
},
);
if (!workflow) {
throw new NotFoundError(`Workflow with ID "${workflowId}" does not exist`);
}
const enterpriseWorkflowService = this.enterpriseWorkflowService;
const workflowWithMetaData = enterpriseWorkflowService.addOwnerAndSharings(workflow);
await enterpriseWorkflowService.addCredentialsToWorkflow(workflowWithMetaData, req.user);
// @ts-expect-error: This is added as part of addOwnerAndSharings but
// shouldn't be returned to the frontend
delete workflowWithMetaData.shared;
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
const checksum = await calculateWorkflowChecksum(workflow);
return { ...workflowWithMetaData, scopes, checksum };
}
// sharing disabled
const workflow = await this.workflowFinderService.findWorkflowForUser(
workflowId,
req.user,
['workflow:read'],
{
includeTags: !this.globalConfig.tags.disabled,
includeParentFolder: true,
includeActiveVersion: true,
},
);
if (!workflow) {
this.logger.warn('User attempted to access a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new NotFoundError(
'Could not load the workflow - you can only access workflows owned by you',
);
}
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
const checksum = await calculateWorkflowChecksum(workflow);
return { ...workflow, scopes, checksum };
}
/**
* Checks whether a workflow with the given ID exists.
*
* @note We cannot use @ProjectScope here because we want to check for the id's existence
* Adding a scope would disable the route if the user didn't have access to the workflow
*/
@Get('/:workflowId/exists')
async exists(req: WorkflowRequest.Get) {
const exists = await this.workflowRepository.existsBy({ id: req.params.workflowId });
return { exists };
}
@Patch('/:workflowId')
@ProjectScope('workflow:update')
async update(
req: WorkflowRequest.Update,
_res: unknown,
@Param('workflowId') workflowId: string,
@Body body: UpdateWorkflowDto,
) {
const forceSave = req.query.forceSave === 'true';
const clientId = req.headers['push-ref'];
await this.collaborationService.validateWriteLock(req.user.id, clientId, workflowId, 'update');
let updateData = new WorkflowEntity();
const { tags, parentFolderId, aiBuilderAssisted, expectedChecksum, autosaved, ...rest } = body;
// Validate timeSavedMode if present
if (
body.settings?.timeSavedMode !== undefined &&
!['fixed', 'dynamic'].includes(body.settings.timeSavedMode)
) {
throw new BadRequestError('Invalid timeSavedMode');
}
// Security: Object.assign is now safe because the DTO validates and filters all input
// Only fields defined in UpdateWorkflowDto are assigned; internal fields like
// triggerCount, versionCounter, isArchived, active, activeVersionId, etc. are never set from user input
Object.assign(updateData, rest);
const isSharingEnabled = this.license.isSharingEnabled();
if (isSharingEnabled) {
updateData = await this.enterpriseWorkflowService.preventTampering(
updateData,
workflowId,
req.user,
);
}
const updatedWorkflow = await this.workflowService.update(req.user, updateData, workflowId, {
tagIds: tags,
parentFolderId,
forceSave: isSharingEnabled ? forceSave : true,
expectedChecksum,
aiBuilderAssisted,
autosaved,
});
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
const checksum = await calculateWorkflowChecksum(updatedWorkflow);
await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id);
return { ...updatedWorkflow, scopes, checksum };
}
@Get('/:workflowId/collaboration/write-lock')
@ProjectScope('workflow:read')
async getWriteLock(
req: AuthenticatedRequest,
_res: Response,
@Param('workflowId') workflowId: string,
) {
const writeLock = await this.collaborationService.getWriteLock(req.user.id, workflowId);
return writeLock;
}
@Delete('/:workflowId')
@ProjectScope('workflow:delete')
async delete(req: AuthenticatedRequest, _res: Response, @Param('workflowId') workflowId: string) {
const clientId = req.headers['push-ref'];
await this.collaborationService.validateWriteLock(req.user.id, clientId, workflowId, 'delete');
const workflow = await this.workflowService.delete(req.user, workflowId);
if (!workflow) {
this.logger.warn('User attempted to delete a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ForbiddenError(
'Could not delete the workflow - workflow was not found in your projects',
);
}
return true;
}
@Post('/:workflowId/archive')
@ProjectScope('workflow:delete')
async archive(
req: AuthenticatedRequest,
_res: Response,
@Param('workflowId') workflowId: string,
@Body body: ArchiveWorkflowDto,
) {
const clientId = req.headers['push-ref'];
await this.collaborationService.validateWriteLock(req.user.id, clientId, workflowId, 'archive');
const { expectedChecksum } = body;
const workflow = await this.workflowService.archive(req.user, workflowId, {
expectedChecksum,
});
if (!workflow) {
this.logger.warn('User attempted to archive a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ForbiddenError(
'Could not archive the workflow - workflow was not found in your projects',
);
}
const checksum = await calculateWorkflowChecksum(workflow);
await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id);
return { ...workflow, checksum };
}
@Post('/:workflowId/unarchive')
@ProjectScope('workflow:delete')
async unarchive(
req: AuthenticatedRequest,
_res: Response,
@Param('workflowId') workflowId: string,
) {
const clientId = req.headers['push-ref'];
await this.collaborationService.validateWriteLock(
req.user.id,
clientId,
workflowId,
'unarchive',
);
const workflow = await this.workflowService.unarchive(req.user, workflowId);
if (!workflow) {
this.logger.warn('User attempted to unarchive a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ForbiddenError(
'Could not unarchive the workflow - workflow was not found in your projects',
);
}
const checksum = await calculateWorkflowChecksum(workflow);
await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id);
return { ...workflow, checksum };
}
@Post('/:workflowId/activate')
@ProjectScope('workflow:publish')
async activate(
req: WorkflowRequest.Activate,
_res: unknown,
@Param('workflowId') workflowId: string,
@Body body: ActivateWorkflowDto,
) {
const clientId = req.headers['push-ref'];
await this.collaborationService.validateWriteLock(
req.user.id,
clientId,
workflowId,
'activate',
);
const { versionId, name, description, expectedChecksum } = body;
const workflow = await this.workflowService.activateWorkflow(req.user, workflowId, {
versionId,
name,
description,
expectedChecksum,
});
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
const checksum = await calculateWorkflowChecksum(workflow);
await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id);
return { ...workflow, scopes, checksum };
}
@Post('/:workflowId/deactivate')
@ProjectScope('workflow:unpublish')
async deactivate(
req: WorkflowRequest.Deactivate,
_res: unknown,
@Param('workflowId') workflowId: string,
@Body body: DeactivateWorkflowDto,
) {
const clientId = req.headers['push-ref'] as string | undefined;
await this.collaborationService.validateWriteLock(
req.user.id,
clientId,
workflowId,
'deactivate',
);
const { expectedChecksum } = body;
const workflow = await this.workflowService.deactivateWorkflow(req.user, workflowId, {
expectedChecksum,
});
const scopes = await this.workflowService.getWorkflowScopes(req.user, workflowId);
const checksum = await calculateWorkflowChecksum(workflow);
await this.collaborationService.broadcastWorkflowUpdate(workflowId, req.user.id);
return { ...workflow, scopes, checksum };
}
@Post('/:workflowId/run')
@ProjectScope('workflow:execute')
async runManually(req: WorkflowRequest.ManualRun, _res: unknown) {
const workflowId = req.params.workflowId;
// Always load the stored workflow from the database.
// This prevents execution of arbitrary workflow definitions —
// users can only execute workflows as they exist in the DB.
const dbWorkflow = await this.workflowRepository.get({ id: workflowId });
if (!dbWorkflow) {
throw new NotFoundError(`Workflow with ID "${workflowId}" not found`);
}
const result = await this.workflowExecutionService.executeManually(
dbWorkflow,
req.body,
req.user,
req.headers['push-ref'],
);
if ('executionId' in result) {
this.eventService.emit('workflow-executed', {
user: {
id: req.user.id,
email: req.user.email,
firstName: req.user.firstName,
lastName: req.user.lastName,
role: req.user.role,
},
workflowId: dbWorkflow.id,
workflowName: dbWorkflow.name,
executionId: result.executionId,
source: 'user-manual',
});
}
return result;
}
@Licensed('feat:sharing')
@Put('/:workflowId/share')
async share(req: WorkflowRequest.Share) {
const { workflowId } = req.params;
const { shareWithIds } = req.body;
if (
!Array.isArray(shareWithIds) ||
!shareWithIds.every((userId) => typeof userId === 'string')
) {
throw new BadRequestError('Bad request');
}
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, req.user, [
'workflow:read',
]);
if (!workflow) {
throw new ForbiddenError();
}
const currentPersonalProjectIDs = workflow.shared
.filter((sw) => sw.role === 'workflow:editor')
.map((sw) => sw.projectId);
const newPersonalProjectIDs = shareWithIds;
const toShare = utils.rightDiff(
[currentPersonalProjectIDs, (id) => id],
[newPersonalProjectIDs, (id) => id],
);
const toUnshare = utils.rightDiff(
[newPersonalProjectIDs, (id) => id],
[currentPersonalProjectIDs, (id) => id],
);
if (toShare.length > 0) {
const canShare = await userHasScopes(req.user, ['workflow:share'], false, { workflowId });
if (!canShare) {
throw new ForbiddenError();
}
}
if (toUnshare.length > 0) {
const canUnshare = await userHasScopes(req.user, ['workflow:unshare'], false, {
workflowId,
});
if (!canUnshare) {
throw new ForbiddenError();
}
}
let newShareeIds: string[] = [];
const { manager: dbManager } = this.projectRepository;
await dbManager.transaction(async (trx) => {
await trx.delete(SharedWorkflow, {
workflowId,
projectId: In(toUnshare),
});
await this.enterpriseWorkflowService.shareWithProjects(workflow.id, toShare, trx);
newShareeIds = toShare;
});
this.eventService.emit('workflow-sharing-updated', {
workflowId,
userIdSharer: req.user.id,
userIdList: shareWithIds,
});
const projectsRelations = await this.projectRelationRepository.findBy({
projectId: In(newShareeIds),
role: { slug: PROJECT_OWNER_ROLE_SLUG },
});
await this.mailer.notifyWorkflowShared({
sharer: req.user,
newShareeIds: projectsRelations.map((pr) => pr.userId),
workflow,
});
}
@Put('/:workflowId/transfer')
@ProjectScope('workflow:move')
async transfer(
req: AuthenticatedRequest,
_res: unknown,
@Param('workflowId') workflowId: string,
@Body body: TransferWorkflowBodyDto,
) {
return await this.enterpriseWorkflowService.transferWorkflow(
req.user,
workflowId,
body.destinationProjectId,
body.shareCredentials,
body.destinationParentFolderId,
);
}
@Get('/:workflowId/executions/last-successful')
@ProjectScope('workflow:read')
async getLastSuccessfulExecution(
req: AuthenticatedRequest,
_res: unknown,
@Param('workflowId') workflowId: string,
) {
const redactQuery = ExecutionRedactionQueryDtoSchema.safeParse(req.query);
const redactExecutionData = redactQuery.success
? redactQuery.data.redactExecutionData
: undefined;
const lastExecution = await this.executionService.getLastSuccessfulExecution(
workflowId,
req.user,
redactExecutionData,
);
return lastExecution ?? null;
}
@Post('/with-node-types')
async getWorkflowsWithNodesIncluded(req: AuthenticatedRequest, res: express.Response) {
try {
const hasPermission = req.user.role.slug === ROLE.Owner || req.user.role.slug === ROLE.Admin;
if (!hasPermission) {
res.json({ data: [], count: 0 });
return;
}
const { nodeTypes } = req.body as { nodeTypes: string[] };
const workflows = await this.workflowService.getWorkflowsWithNodesIncluded(
req.user,
nodeTypes,
);
res.json({
data: workflows,
count: workflows.length,
});
} catch (maybeError) {
const error = utils.toError(maybeError);
ResponseHelper.reportError(error);
ResponseHelper.sendErrorResponse(res, error);
}
}
private async fetchWorkflowFromUrl(url: string) {
try {
if (!this.ssrfConfig.enabled) {
const { data } = await axios.get<IWorkflowResponse>(url);
return data;
}
const result = await this.ssrfProtectionService.validateUrl(url);
if (!result.ok) throw result.error;
const config: AxiosRequestConfig = {
lookup: this.ssrfProtectionService.createSecureLookup() as AxiosRequestConfig['lookup'],
beforeRedirect: (redirectedRequest: Record<string, string>) => {
this.ssrfProtectionService.validateRedirectSync(redirectedRequest.href);
},
};
const { data } = await axios.get<IWorkflowResponse>(url, config);
return data;
} catch (error) {
const blockedError = this.findSsrfBlockedError(error);
if (blockedError) throw blockedError;
throw new BadRequestError('The URL does not point to valid JSON file!');
}
}
/**
* Walk the error cause chain to find a {@link SsrfBlockedIpError} buried
* inside axios/redirect wrappers (AxiosError → RedirectionError → SsrfBlockedIpError).
*/
private findSsrfBlockedError(error: unknown): SsrfBlockedIpError | undefined {
let current = ensureError(error);
for (let depth = 0; depth < 4 && current; depth++) {
if (current instanceof SsrfBlockedIpError) return current;
current = ensureError(current.cause);
}
return undefined;
}
}