n8n/packages/cli/src/controllers/project.controller.ts

281 lines
8.1 KiB
TypeScript

import {
CreateProjectDto,
DeleteProjectDto,
UpdateProjectDto,
AddUsersToProjectDto,
ChangeUserRoleInProject,
ListProjectsQueryDto,
} from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import {
Get,
Post,
GlobalScope,
RestController,
Licensed,
Patch,
ProjectScope,
Delete,
Body,
Param,
Query,
} from '@n8n/decorators';
import { combineScopes, getAuthPrincipalScopes } from '@n8n/permissions';
import { Response } from 'express';
import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors';
import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
import type { ProjectRequest } from '@/requests';
import {
ProjectService,
TeamProjectOverQuotaError,
UnlicensedProjectRoleError,
} from '@/services/project.service.ee';
@RestController('/projects')
export class ProjectController {
constructor(
private readonly projectsService: ProjectService,
private readonly provisioningService: ProvisioningService,
) {}
@Get('/')
async getAllProjects(
req: AuthenticatedRequest,
res: Response,
@Query payload: ListProjectsQueryDto,
) {
const { projects, count } = await this.projectsService.getAccessibleProjectsAndCount(
req.user,
payload,
);
// When pagination params are provided, return { count, data } envelope
// with role and scopes enriched per project.
// Otherwise return a bare array for backward compatibility with existing callers.
if (payload.take !== undefined || payload.skip !== undefined) {
const enriched = await this.projectsService.addUserScopes(req.user, projects);
return res.json({ count, data: enriched });
}
return projects;
}
@Get('/count')
async getProjectCounts() {
return await this.projectsService.getProjectCounts();
}
// Lists projects a caller can pick as share targets, including peer
// personal projects so the workflow / credential share dropdowns can
// surface other users. Gated on `user:list` (the same boundary that
// `GET /rest/users` enforces) — restricted roles without that scope
// (e.g. chat-only users) cannot enumerate peer personal projects here.
@Get('/sharing-candidates')
@GlobalScope('user:list')
async getSharingCandidates(
req: AuthenticatedRequest,
res: Response,
@Query payload: ListProjectsQueryDto,
) {
const { projects, count } = await this.projectsService.getShareableProjectsAndCount(
req.user,
payload,
);
const enriched = await this.projectsService.addUserScopes(req.user, projects);
return res.json({ count, data: enriched });
}
@Post('/')
@GlobalScope('project:create')
// Using admin as all plans that contain projects should allow admins at the very least
@Licensed('feat:projectRole:admin')
async createProject(req: AuthenticatedRequest, _res: Response, @Body payload: CreateProjectDto) {
try {
const project = await this.projectsService.createTeamProject(req.user, payload);
return {
...project,
role: 'project:admin',
scopes: await this.projectsService.getProjectScopesForUser(req.user, project.id),
};
} catch (e) {
if (e instanceof TeamProjectOverQuotaError) {
throw new BadRequestError(e.message);
}
throw e;
}
}
@Get('/my-projects')
async getMyProjects(
req: AuthenticatedRequest,
_res: Response,
): Promise<ProjectRequest.GetMyProjectsResponse> {
return await this.projectsService.getMyProjects(req.user);
}
@Get('/personal')
async getPersonalProject(req: AuthenticatedRequest) {
const project = await this.projectsService.getPersonalProject(req.user);
if (!project) {
throw new NotFoundError('Could not find a personal project for this user');
}
const scopes = await this.projectsService.getProjectScopesForUser(req.user, project.id);
return {
...project,
scopes,
// Personal projects have a single owner and are never subject to managed team roles.
rolesManaged: false,
};
}
@Get('/:projectId')
@ProjectScope('project:read')
async getProject(
req: AuthenticatedRequest,
_res: Response,
@Param('projectId') projectId: string,
): Promise<ProjectRequest.ProjectWithRelations> {
const project = await this.projectsService.getProject(projectId);
const { id, name, icon, type, description, customTelemetryTags } = project;
const [relations, rolesManaged, implicitMembers] = await Promise.all([
this.projectsService.getProjectRelations(projectId),
this.provisioningService.isProjectRoleManaged(),
this.projectsService.getImplicitProjectMembers(project),
]);
const myRelation = relations.find((r) => r.userId === req.user.id);
return {
id,
name,
icon,
type,
description,
customTelemetryTags,
creatorId: project.creatorId ?? null,
relations: relations.map((r) => ({
id: r.user.id,
email: r.user.email,
firstName: r.user.firstName,
lastName: r.user.lastName,
role: r.role.slug,
})),
implicitMembers: implicitMembers.map((user) => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
globalRole: { slug: user.role.slug, displayName: user.role.displayName },
})),
scopes: [
...combineScopes({
global: getAuthPrincipalScopes(req.user),
...(myRelation ? { project: myRelation.role.scopes.map((scope) => scope.slug) } : {}),
}),
],
rolesManaged,
};
}
@Patch('/:projectId')
@ProjectScope('project:update')
async updateProject(
req: AuthenticatedRequest,
_res: Response,
@Body payload: UpdateProjectDto,
@Param('projectId') projectId: string,
) {
await this.projectsService.updateProject(req.user, projectId, payload);
}
/** Throws when project roles are provisioned automatically, so manual membership changes are disallowed. */
private async assertProjectRolesNotManaged() {
if (await this.provisioningService.isProjectRoleManaged()) {
throw new ForbiddenError(
'Project roles are managed automatically and cannot be changed manually',
);
}
}
@Post('/:projectId/users')
@ProjectScope('project:manageMembers')
async addProjectUsers(
req: AuthenticatedRequest,
res: Response,
@Param('projectId') projectId: string,
@Body payload: AddUsersToProjectDto,
) {
await this.assertProjectRolesNotManaged();
try {
const { added, conflicts } = await this.projectsService.addUsersWithConflictSemantics(
req.user,
projectId,
payload.relations,
);
// Response semantics:
// - If at least one user was added, return 201. When there are also conflicts, include them in the body.
// - If no users were added but conflicts exist, return 409 with conflicts.
if (added.length > 0) {
return conflicts.length > 0 ? res.status(201).json({ conflicts }) : res.status(201).send();
}
if (conflicts.length > 0) return res.status(409).json({ conflicts });
return res.status(200).send();
} catch (e) {
if (e instanceof UnlicensedProjectRoleError) {
throw new BadRequestError(e.message);
}
throw e;
}
}
@Patch('/:projectId/users/:userId')
@ProjectScope('project:manageMembers')
async changeProjectUserRole(
req: AuthenticatedRequest,
res: Response,
@Param('projectId') projectId: string,
@Param('userId') userId: string,
@Body body: ChangeUserRoleInProject,
) {
await this.assertProjectRolesNotManaged();
try {
await this.projectsService.changeUserRoleInProject(req.user, projectId, userId, body.role);
return res.status(204).send();
} catch (e) {
if (e instanceof UnlicensedProjectRoleError) {
throw new BadRequestError(e.message);
}
throw e;
}
}
@Delete('/:projectId/users/:userId')
@ProjectScope('project:manageMembers')
async deleteProjectUser(
req: AuthenticatedRequest,
res: Response,
@Param('projectId') projectId: string,
@Param('userId') userId: string,
) {
await this.assertProjectRolesNotManaged();
await this.projectsService.deleteUserFromProject(req.user, projectId, userId);
return res.status(204).send();
}
@Delete('/:projectId')
@ProjectScope('project:delete')
async deleteProject(
req: AuthenticatedRequest,
_res: Response,
@Query query: DeleteProjectDto,
@Param('projectId') projectId: string,
) {
await this.projectsService.deleteProject(req.user, projectId, {
migrateToProject: query.transferId,
});
}
}