diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 295e76a1feb..8c3ef1c4c7e 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -83,6 +83,14 @@ export { DeleteProjectQueryPublicDto, } from './project/project-public.dto'; export type { ProjectPublic } from './project/project-public.dto'; +export { + projectMemberPublicSchema, + ProjectMemberPublicDto, + ProjectMemberListPublicDto, + ListProjectMembersQueryPublicDto, + AddProjectMembersPublicDto, + ChangeProjectMemberRolePublicDto, +} from './project/project-member-public.dto'; export { SamlAcsDto } from './saml/saml-acs.dto'; export { SamlPreferences } from './saml/saml-preferences.dto'; diff --git a/packages/@n8n/api-types/src/dto/project/__tests__/project-member-public.dto.test.ts b/packages/@n8n/api-types/src/dto/project/__tests__/project-member-public.dto.test.ts new file mode 100644 index 00000000000..193419ba4ad --- /dev/null +++ b/packages/@n8n/api-types/src/dto/project/__tests__/project-member-public.dto.test.ts @@ -0,0 +1,92 @@ +import { + AddProjectMembersPublicDto, + ChangeProjectMemberRolePublicDto, + ListProjectMembersQueryPublicDto, + ProjectMemberListPublicDto, + ProjectMemberPublicDto, +} from '../project-member-public.dto'; + +const member = { + id: '123e4567-e89b-12d3-a456-426614174000', + email: 'john.doe@company.com', + firstName: 'John', + lastName: 'Doe', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + role: 'project:viewer', +}; + +describe('ProjectMemberPublicDto', () => { + test('accepts all the expected fields', () => { + expect(ProjectMemberPublicDto.safeParse(member).success).toBe(true); + }); + + test('accepts null names and a null role', () => { + const result = ProjectMemberPublicDto.safeParse({ + ...member, + firstName: null, + lastName: null, + role: null, + }); + + expect(result.success).toBe(true); + }); + + test('strips a field the schema does not name', () => { + const result = ProjectMemberPublicDto.safeParse({ ...member, password: 'secret' }); + + expect(result.success).toBe(true); + expect(result.data).not.toHaveProperty('password'); + }); +}); + +describe('ProjectMemberListPublicDto', () => { + test('accepts a page with a next cursor', () => { + const result = ProjectMemberListPublicDto.safeParse({ data: [member], nextCursor: 'abc' }); + + expect(result.success).toBe(true); + }); + + test('accepts the last page', () => { + expect(ProjectMemberListPublicDto.safeParse({ data: [], nextCursor: null }).success).toBe(true); + }); + + test('rejects a missing nextCursor', () => { + expect(ProjectMemberListPublicDto.safeParse({ data: [] }).success).toBe(false); + }); +}); + +describe('ListProjectMembersQueryPublicDto', () => { + test('exposes limit and cursor and drops offset', () => { + const result = ListProjectMembersQueryPublicDto.safeParse({ + limit: '5', + cursor: 'abc', + offset: '3', + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ limit: 5, cursor: 'abc' }); + }); +}); + +describe('AddProjectMembersPublicDto', () => { + test('accepts relations and, as the legacy schema did, ignores an unknown key', () => { + const result = AddProjectMembersPublicDto.safeParse({ + relations: [{ userId: member.id, role: 'project:viewer', extra: true }], + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual({ relations: [{ userId: member.id, role: 'project:viewer' }] }); + }); + + test('rejects an empty relations list', () => { + expect(AddProjectMembersPublicDto.safeParse({ relations: [] }).success).toBe(false); + }); +}); + +describe('ChangeProjectMemberRolePublicDto', () => { + test('accepts any non-empty role; the service checks that it exists', () => { + expect(ChangeProjectMemberRolePublicDto.safeParse({ role: 'custom:role' }).success).toBe(true); + expect(ChangeProjectMemberRolePublicDto.safeParse({ role: '' }).success).toBe(false); + }); +}); diff --git a/packages/@n8n/api-types/src/dto/project/project-member-public.dto.ts b/packages/@n8n/api-types/src/dto/project/project-member-public.dto.ts new file mode 100644 index 00000000000..9f8ac587a80 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/project/project-member-public.dto.ts @@ -0,0 +1,54 @@ +import '../../openapi-extend'; + +import { z } from 'zod'; + +import { + addProjectMembersFieldDocs, + projectMemberDocs, + projectMemberFieldDocs, + projectMemberListFieldDocs, +} from './project-member-public.openapi'; +import { Z } from '../../zod-class'; +import { publicApiPaginationSchema } from '../pagination/pagination.dto'; + +export const projectMemberPublicSchema = z + .object({ + id: z.string().openapi(projectMemberFieldDocs.id), + email: z.string().openapi(projectMemberFieldDocs.email), + firstName: z.string().nullable().openapi(projectMemberFieldDocs.firstName), + lastName: z.string().nullable().openapi(projectMemberFieldDocs.lastName), + createdAt: z.string().datetime().openapi(projectMemberFieldDocs.createdAt), + updatedAt: z.string().datetime().openapi(projectMemberFieldDocs.updatedAt), + role: z.string().nullable().openapi(projectMemberFieldDocs.role), + }) + .openapi(projectMemberDocs); + +export class ProjectMemberPublicDto extends Z.class(projectMemberPublicSchema.shape) {} + +export class ProjectMemberListPublicDto extends Z.class({ + data: z.array(projectMemberPublicSchema), + nextCursor: z.string().nullable().openapi(projectMemberListFieldDocs.nextCursor), +}) {} + +export class ListProjectMembersQueryPublicDto extends Z.class({ + limit: publicApiPaginationSchema.limit, + cursor: z.string().optional(), +}) {} + +const projectMemberRoleSchema = z.string().min(1).openapi(addProjectMembersFieldDocs.role); + +export class AddProjectMembersPublicDto extends Z.class({ + relations: z + .array( + z.object({ + userId: z.string().min(1).openapi(addProjectMembersFieldDocs.userId), + role: projectMemberRoleSchema, + }), + ) + .min(1) + .openapi(addProjectMembersFieldDocs.relations), +}) {} + +export class ChangeProjectMemberRolePublicDto extends Z.class({ + role: projectMemberRoleSchema, +}) {} diff --git a/packages/@n8n/api-types/src/dto/project/project-member-public.openapi.ts b/packages/@n8n/api-types/src/dto/project/project-member-public.openapi.ts new file mode 100644 index 00000000000..7a33c74f944 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/project/project-member-public.openapi.ts @@ -0,0 +1,56 @@ +import type { ZodOpenAPIMetadata } from '@asteasolutions/zod-to-openapi'; + +export const projectMemberDocs: ZodOpenAPIMetadata = { + description: 'A project member (user with their role in the project).', +}; + +export const projectMemberFieldDocs = { + id: { + description: "The user's unique identifier.", + readOnly: true, + example: '123e4567-e89b-12d3-a456-426614174000', + }, + email: { + format: 'email', + description: "The user's email address.", + readOnly: true, + example: 'john.doe@company.com', + }, + firstName: { + maxLength: 32, + description: "The user's first name.", + readOnly: true, + example: 'john', + }, + lastName: { + maxLength: 32, + description: "The user's last name.", + readOnly: true, + example: 'Doe', + }, + createdAt: { description: 'When the user was created.', readOnly: true }, + updatedAt: { description: 'When the user was last updated.', readOnly: true }, + role: { + description: "The user's role in the project (e.g. project:admin, project:viewer).", + readOnly: true, + example: 'project:viewer', + }, +} as const satisfies Record; + +export const projectMemberListFieldDocs = { + nextCursor: { + description: + 'Paginate through project members by setting the cursor parameter to the nextCursor ' + + 'attribute returned by a previous request. Default value fetches the first page of the collection.', + example: 'eyJvZmZzZXQiOjEwMCwibGltaXQiOjEwMH0=', + }, +} as const satisfies Record; + +export const addProjectMembersFieldDocs = { + relations: { description: 'A list of userIds and roles to add to the project.' }, + userId: { + description: 'The unique identifier of the user.', + example: '91765f0d-3b29-45df-adb9-35b23937eb92', + }, + role: { description: 'The role assigned to the user in the project.', example: 'project:viewer' }, +} as const satisfies Record; diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index 6a4601f5955..6c684ee494e 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -137,6 +137,7 @@ export { promotionProviderIdParamSchema, roleMappingRuleIdParamSchema, roleSlugParamSchema, + userIdParamSchema, workflowIdParamSchema, workflowVersionIdParamSchema, } from './schemas/public-api-path-params.schema'; diff --git a/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts b/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts index 9cba90de858..3f221bdb062 100644 --- a/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts +++ b/packages/@n8n/api-types/src/schemas/public-api-path-params.schema.ts @@ -18,6 +18,7 @@ export const executionIdParamSchema = numericIdParamSchema('The ID of the execut export const workflowIdParamSchema = stringIdParamSchema('The ID of the workflow.'); export const workflowVersionIdParamSchema = stringIdParamSchema('The ID of the workflow version.'); export const projectIdParamSchema = stringIdParamSchema('The ID of the project.'); +export const userIdParamSchema = stringIdParamSchema('The ID of the user.'); export const roleSlugParamSchema = stringIdParamSchema('The slug of the role.'); export const roleMappingRuleIdParamSchema = stringIdParamSchema('The ID of the role mapping rule.'); export const promotionConnectionIdParamSchema = stringIdParamSchema( diff --git a/packages/cli/eslint.config.mjs b/packages/cli/eslint.config.mjs index 193fa6e8ffb..a2ff14159b2 100644 --- a/packages/cli/eslint.config.mjs +++ b/packages/cli/eslint.config.mjs @@ -160,7 +160,6 @@ export default defineConfig( './src/public-api/v1/handlers/log-streaming/log-streaming.handler.ts', './src/public-api/v1/handlers/n8n-packages/n8n-packages.handler.ts', './src/public-api/v1/handlers/otel/otel.handler.ts', - './src/public-api/v1/handlers/projects/projects.handler.ts', './src/public-api/v1/handlers/security-policy/security-policy.handler.ts', './src/public-api/v1/handlers/sso-oidc/sso-oidc.handler.ts', './src/public-api/v1/handlers/sso-saml/sso-saml.handler.ts', diff --git a/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts b/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts index 6ccabbfbacc..c8a81e00003 100644 --- a/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts +++ b/packages/cli/src/public-api/__tests__/public-api-controller.registry.test.ts @@ -141,6 +141,23 @@ describe('PublicApiControllerRegistry', () => { ); }); + describe('success response without a DTO', () => { + it('sends an empty body without a content-type when the handler returns nothing', async () => { + @Service() + class WidgetsPublicController { + @Post('/') + @ApiResponse(201) + create() {} + } + markPublicApiController(WidgetsPublicController as Controller, '/widgets'); + + const response = await request(activate()).post('/api/v1/widgets').expect(201); + + expect(response.text).toBe(''); + expect(response.headers['content-type']).toBeUndefined(); + }); + }); + describe('validation failures', () => { class WidgetValidationDto extends Z.class({ name: z.string(), diff --git a/packages/cli/src/public-api/public-api-controller.registry.ts b/packages/cli/src/public-api/public-api-controller.registry.ts index 76a2f4e2c7d..91c4b234919 100644 --- a/packages/cli/src/public-api/public-api-controller.registry.ts +++ b/packages/cli/src/public-api/public-api-controller.registry.ts @@ -107,17 +107,14 @@ export class PublicApiControllerRegistry { if (res.headersSent) return; - if (successStatus === 204) { - res.status(204).send(); + if (successStatus === 204 || (!route.responseDto && result === undefined)) { + res.status(successStatus).send(); return; } - if (route.responseDto) { - res.status(successStatus).json(route.responseDto.parse(result)); - return; - } - - res.status(successStatus).json(result); + res + .status(successStatus) + .json(route.responseDto ? route.responseDto.parse(result) : result); }; const middlewares: RequestHandler[] = []; diff --git a/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts b/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts index d391a50eb46..7eb91e34f12 100644 --- a/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/projects.public.controller.ts @@ -1,15 +1,21 @@ import { + AddProjectMembersPublicDto, + ChangeProjectMemberRolePublicDto, CreatedProjectPublicDto, CreateProjectPublicDto, DeleteProjectQueryPublicDto, + ListProjectMembersQueryPublicDto, ListProjectsQueryPublicDto, ProjectListPublicDto, + ProjectMemberListPublicDto, + ProjectMemberPublicDto, ProjectPublicDto, UpdateProjectPublicDto, projectIdParamSchema, + userIdParamSchema, } from '@n8n/api-types'; import { LICENSE_FEATURES } from '@n8n/constants'; -import type { AuthenticatedRequest, Project } from '@n8n/db'; +import type { AuthenticatedRequest, Project, ProjectRelation } from '@n8n/db'; import { ApiDescription, ApiErrorResponse, @@ -22,6 +28,7 @@ import { Get, Licensed, Param, + Patch, Post, PublicApiController, Put, @@ -29,6 +36,9 @@ import { } from '@n8n/decorators'; import type { Response } from 'express'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import { encodeNextCursor, resolveOffsetPagination, @@ -49,9 +59,22 @@ const toProjectPublicDto = (project: Project): ProjectPublicDto => ({ updatedAt: project.updatedAt.toISOString(), }); +const toProjectMemberPublicDto = (relation: ProjectRelation): ProjectMemberPublicDto => ({ + id: relation.user.id, + email: relation.user.email, + firstName: relation.user.firstName, + lastName: relation.user.lastName, + createdAt: relation.user.createdAt.toISOString(), + updatedAt: relation.user.updatedAt.toISOString(), + role: relation.role?.slug ?? null, +}); + @PublicApiController('/projects') export class ProjectsPublicController { - constructor(private readonly projectService: ProjectService) {} + constructor( + private readonly projectService: ProjectService, + private readonly provisioningService: ProvisioningService, + ) {} @Get('/') @Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN) @@ -130,4 +153,109 @@ export class ProjectsPublicController { ): Promise { await this.projectService.deleteProject(req.user, projectId); } + + @Get('/:projectId/users') + @Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN) + @ApiKeyScope('user:list') + @ApiSummary('List project members') + @ApiDescription( + 'Returns a list of all members of a project including their role. Requires user:list scope.', + ) + @ApiTags(tags) + @ApiResponse(200, ProjectMemberListPublicDto) + @ApiErrorResponse(404) + async getProjectUsers( + req: AuthenticatedRequest, + _res: Response, + @Param('projectId', projectIdParamSchema) projectId: string, + @Query query: ListProjectMembersQueryPublicDto, + ): Promise { + const { offset, limit } = resolveOffsetPagination(query); + + const project = await this.projectService.getProjectWithScope(req.user, projectId, [ + 'project:list', + ]); + if (!project) { + throw new NotFoundError(`Could not find project with ID "${projectId}"`); + } + + const { members, count } = await this.projectService.getProjectMembersAndCount(projectId, { + offset, + limit, + }); + + return { + data: members.map(toProjectMemberPublicDto), + nextCursor: encodeNextCursor({ offset, limit, numberOfTotalRecords: count }), + }; + } + + @Post('/:projectId/users') + @Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN) + @ApiKeyScope('project:manageMembers') + @ApiSummary('Add one or more users to a project') + @ApiDescription('Add one or more users to a project on your instance.') + @ApiTags(tags) + @ApiResponse(201) + @ApiErrorResponse(400) + @ApiErrorResponse(404) + async addUsersToProject( + req: AuthenticatedRequest, + _res: Response, + @Param('projectId', projectIdParamSchema) projectId: string, + @Body body: AddProjectMembersPublicDto, + ): Promise { + await this.assertProjectRolesNotManaged(); + + await this.projectService.addUsersToProject(req.user, projectId, body.relations); + } + + @Patch('/:projectId/users/:userId') + @Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN) + @ApiKeyScope('project:manageMembers') + @ApiSummary("Change a user's role in a project") + @ApiDescription("Change a user's role in a project.") + @ApiTags(tags) + @ApiResponse(204) + @ApiErrorResponse(400) + @ApiErrorResponse(404) + async changeUserRoleInProject( + req: AuthenticatedRequest, + _res: Response, + @Param('projectId', projectIdParamSchema) projectId: string, + @Param('userId', userIdParamSchema) userId: string, + @Body body: ChangeProjectMemberRolePublicDto, + ): Promise { + await this.assertProjectRolesNotManaged(); + + await this.projectService.changeUserRoleInProject(req.user, projectId, userId, body.role); + } + + @Delete('/:projectId/users/:userId') + @Licensed(LICENSE_FEATURES.PROJECT_ROLE_ADMIN) + @ApiKeyScope('project:manageMembers') + @ApiSummary('Delete a user from a project') + @ApiDescription('Delete a user from a project on your instance.') + @ApiTags(tags) + @ApiResponse(204) + @ApiErrorResponse(404) + async deleteUserFromProject( + req: AuthenticatedRequest, + _res: Response, + @Param('projectId', projectIdParamSchema) projectId: string, + @Param('userId', userIdParamSchema) userId: string, + ): Promise { + await this.assertProjectRolesNotManaged(); + + await this.projectService.deleteUserFromProject(req.user, projectId, userId); + } + + /** Mirrors the ProjectController guard: manual membership changes are disallowed when roles are provisioned. */ + private async assertProjectRolesNotManaged() { + if (await this.provisioningService.isProjectRoleManaged()) { + throw new ForbiddenError( + 'Project roles are managed automatically and cannot be changed manually', + ); + } + } } diff --git a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts b/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts deleted file mode 100644 index e04411cff88..00000000000 --- a/packages/cli/src/public-api/v1/handlers/projects/projects.handler.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { AddUsersToProjectDto, ChangeUserRoleInProject } from '@n8n/api-types'; -import type { AuthenticatedRequest } from '@n8n/db'; -import { Container } from '@n8n/di'; -import pick from 'lodash/pick'; - -import type { PublicAPIEndpoint } from '../../shared/handler.types'; -import { - apiKeyHasScopeWithGlobalScopeFallback, - isLicensed, - validCursor, -} from '../../shared/middlewares/global.middleware'; -import { encodeNextCursor } from '../../shared/services/pagination.service'; - -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 { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; -import type { PaginatedRequest } from '@/public-api/types'; -import { ProjectService } from '@/services/project.service.ee'; - -type GetProjectUsersRequest = AuthenticatedRequest<{ projectId: string }> & PaginatedRequest; - -type ProjectHandlers = { - getProjectUsers: PublicAPIEndpoint; - addUsersToProject: PublicAPIEndpoint>; - changeUserRoleInProject: PublicAPIEndpoint< - AuthenticatedRequest<{ projectId: string; userId: string }> - >; - deleteUserFromProject: PublicAPIEndpoint< - AuthenticatedRequest<{ projectId: string; userId: string }> - >; -}; - -/** Mirrors the ProjectController guard: manual membership changes are disallowed when roles are provisioned. */ -async function assertProjectRolesNotManaged() { - if (await Container.get(ProvisioningService).isProjectRoleManaged()) { - throw new ForbiddenError( - 'Project roles are managed automatically and cannot be changed manually', - ); - } -} - -const projectHandlers: ProjectHandlers = { - getProjectUsers: [ - isLicensed('feat:projectRole:admin'), - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'user:list' }), - validCursor, - async (req, res) => { - const { projectId } = req.params; - const offset = Number(req.query.offset) || 0; - const limit = Number(req.query.limit) || 100; - - const projectService = Container.get(ProjectService); - const project = await projectService.getProjectWithScope(req.user, projectId, [ - 'project:list', - ]); - if (!project) { - throw new NotFoundError(`Could not find project with ID "${projectId}"`); - } - - const { members, count } = await projectService.getProjectMembersAndCount(projectId, { - offset, - limit, - }); - - const memberFields = [ - 'id', - 'email', - 'firstName', - 'lastName', - 'createdAt', - 'updatedAt', - ] as const; - const data = members.map((relation) => ({ - ...pick(relation.user, memberFields), - role: relation.role?.slug ?? null, - })); - - return res.json({ - data, - nextCursor: encodeNextCursor({ - offset, - limit, - numberOfTotalRecords: count, - }), - }); - }, - ], - addUsersToProject: [ - isLicensed('feat:projectRole:admin'), - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:manageMembers' }), - async (req, res) => { - await assertProjectRolesNotManaged(); - - const payload = AddUsersToProjectDto.safeParse(req.body); - if (payload.error) { - throw new BadRequestError(payload.error.errors[0].message); - } - - await Container.get(ProjectService).addUsersToProject( - req.user, - req.params.projectId, - payload.data.relations, - ); - - return res.status(201).send(); - }, - ], - changeUserRoleInProject: [ - isLicensed('feat:projectRole:admin'), - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:manageMembers' }), - async (req, res) => { - await assertProjectRolesNotManaged(); - - const payload = ChangeUserRoleInProject.safeParse(req.body); - if (payload.error) { - throw new BadRequestError(payload.error.errors[0].message); - } - - const { projectId, userId } = req.params; - const { role } = payload.data; - await Container.get(ProjectService).changeUserRoleInProject( - req.user, - projectId, - userId, - role, - ); - - return res.status(204).send(); - }, - ], - deleteUserFromProject: [ - isLicensed('feat:projectRole:admin'), - apiKeyHasScopeWithGlobalScopeFallback({ scope: 'project:manageMembers' }), - async (req, res) => { - await assertProjectRolesNotManaged(); - - const { projectId, userId } = req.params; - - await Container.get(ProjectService).deleteUserFromProject(req.user, projectId, userId); - - return res.status(204).send(); - }, - ], -}; - -export = projectHandlers; diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/addUsersToProject.generated.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/addUsersToProject.generated.yml new file mode 100644 index 00000000000..6b5d7007342 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/addUsersToProject.generated.yml @@ -0,0 +1,58 @@ +operationId: addUsersToProject +tags: + - Projects +summary: Add one or more users to a project +description: Add one or more users to a project on your instance. +x-required-scope: project:manageMembers +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the project. + name: projectId + in: path +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + relations: + type: array + items: + type: object + properties: + userId: + type: string + minLength: 1 + description: The unique identifier of the user. + example: 91765f0d-3b29-45df-adb9-35b23937eb92 + role: + type: string + minLength: 1 + description: The role assigned to the user in the project. + example: project:viewer + required: + - userId + - role + minItems: 1 + description: A list of userIds and roles to add to the project. + required: + - relations +responses: + '201': + description: Operation successful. + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/changeUserRoleInProject.generated.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/changeUserRoleInProject.generated.yml new file mode 100644 index 00000000000..950217cc2fb --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/changeUserRoleInProject.generated.yml @@ -0,0 +1,49 @@ +operationId: changeUserRoleInProject +tags: + - Projects +summary: Change a user's role in a project +description: Change a user's role in a project. +x-required-scope: project:manageMembers +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the project. + name: projectId + in: path + - schema: + type: string + required: true + description: The ID of the user. + name: userId + in: path +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + role: + type: string + minLength: 1 + description: The role assigned to the user in the project. + example: project:viewer + required: + - role +responses: + '204': + description: Operation successful. + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml + '415': + $ref: ../../../../shared/spec/responses/unsupportedMediaType.yml diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/deleteUserFromProject.generated.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/deleteUserFromProject.generated.yml new file mode 100644 index 00000000000..5d23fc8f16a --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/deleteUserFromProject.generated.yml @@ -0,0 +1,31 @@ +operationId: deleteUserFromProject +tags: + - Projects +summary: Delete a user from a project +description: Delete a user from a project on your instance. +x-required-scope: project:manageMembers +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + description: The ID of the project. + name: projectId + in: path + - schema: + type: string + required: true + description: The ID of the user. + name: userId + in: path +responses: + '204': + description: Operation successful. + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/getProjectUsers.generated.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/getProjectUsers.generated.yml new file mode 100644 index 00000000000..89e080ce580 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/getProjectUsers.generated.yml @@ -0,0 +1,97 @@ +operationId: getProjectUsers +tags: + - Projects +summary: List project members +description: Returns a list of all members of a project including their role. Requires user:list scope. +x-required-scope: user:list +parameters: + - $ref: ../../../../shared/spec/parameters/limit.yml + - $ref: ../../../../shared/spec/parameters/cursor.yml + - schema: + type: string + required: true + description: The ID of the project. + name: projectId + in: path +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +responses: + '200': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + description: The user's unique identifier. + readOnly: true + example: 123e4567-e89b-12d3-a456-426614174000 + email: + type: string + format: email + description: The user's email address. + readOnly: true + example: john.doe@company.com + firstName: + type: string + nullable: true + maxLength: 32 + description: The user's first name. + readOnly: true + example: john + lastName: + type: string + nullable: true + maxLength: 32 + description: The user's last name. + readOnly: true + example: Doe + createdAt: + type: string + format: date-time + description: When the user was created. + readOnly: true + updatedAt: + type: string + format: date-time + description: When the user was last updated. + readOnly: true + role: + type: string + nullable: true + description: The user's role in the project (e.g. project:admin, project:viewer). + readOnly: true + example: project:viewer + required: + - id + - email + - firstName + - lastName + - createdAt + - updatedAt + - role + description: A project member (user with their role in the project). + nextCursor: + type: string + nullable: true + description: Paginate through project members by setting the cursor parameter to the nextCursor attribute returned by a previous request. Default value fetches the first page of the collection. + example: eyJvZmZzZXQiOjEwMCwibGltaXQiOjEwMH0= + required: + - data + - nextCursor + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml + '404': + $ref: ../../../../shared/spec/responses/notFound.yml diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.userId.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.userId.yml deleted file mode 100644 index f4c54657956..00000000000 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.userId.yml +++ /dev/null @@ -1,73 +0,0 @@ -delete: - x-eov-operation-id: deleteUserFromProject - x-required-scope: project:manageMembers - x-eov-operation-handler: v1/handlers/projects/projects.handler - tags: - - Projects - summary: Delete a user from a project - description: Delete a user from a project on your instance. - parameters: - - name: projectId - in: path - description: The ID of the project. - required: true - schema: - type: string - - name: userId - in: path - description: The ID of the user. - required: true - schema: - type: string - responses: - '204': - description: Operation successful. - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' -patch: - x-eov-operation-id: changeUserRoleInProject - x-required-scope: project:manageMembers - x-eov-operation-handler: v1/handlers/projects/projects.handler - tags: - - Projects - summary: Change a user's role in a project - description: Change a user's role in a project. - parameters: - - name: projectId - in: path - description: The ID of the project. - required: true - schema: - type: string - - name: userId - in: path - description: The ID of the user. - required: true - schema: - type: string - requestBody: - description: Payload containing the new role to assign to the project user. - content: - application/json: - schema: - type: object - properties: - role: - type: string - description: The role assigned to the user in the project. - example: 'project:viewer' - required: - - role - responses: - '204': - description: Operation successful. - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml deleted file mode 100644 index f94cd4d3744..00000000000 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/paths/projects.projectId.users.yml +++ /dev/null @@ -1,80 +0,0 @@ -get: - x-eov-operation-id: getProjectUsers - x-required-scope: user:list - x-eov-operation-handler: v1/handlers/projects/projects.handler - tags: - - Projects - summary: List project members - description: Returns a list of all members of a project including their role. Requires user:list scope. - parameters: - - name: projectId - in: path - description: The ID of the project. - required: true - schema: - type: string - - $ref: '../../../../shared/spec/parameters/limit.yml' - - $ref: '../../../../shared/spec/parameters/cursor.yml' - responses: - '200': - description: Operation successful. - content: - application/json: - schema: - $ref: '../schemas/projectMemberList.yml' - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' -post: - x-eov-operation-id: addUsersToProject - x-required-scope: project:manageMembers - x-eov-operation-handler: v1/handlers/projects/projects.handler - tags: - - Projects - summary: Add one or more users to a project - description: Add one or more users to a project on your instance. - parameters: - - name: projectId - in: path - description: The ID of the project. - required: true - schema: - type: string - requestBody: - description: Payload containing an array of one or more users to add to the project. - content: - application/json: - schema: - type: object - properties: - relations: - type: array - description: A list of userIds and roles to add to the project. - items: - type: object - properties: - userId: - type: string - description: The unique identifier of the user. - example: '91765f0d-3b29-45df-adb9-35b23937eb92' - role: - type: string - description: The role assigned to the user in the project. - example: 'project:viewer' - required: - - userId - - role - required: - - relations - responses: - '201': - description: Operation successful. - '401': - $ref: '../../../../shared/spec/responses/unauthorized.yml' - '403': - $ref: '../../../../shared/spec/responses/forbidden.yml' - '404': - $ref: '../../../../shared/spec/responses/notFound.yml' diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/parameters/projectId.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/parameters/projectId.yml deleted file mode 100644 index 32961f46016..00000000000 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/parameters/projectId.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: projectId -in: path -description: The ID of the project. -required: true -schema: - type: string diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/project.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/project.yml deleted file mode 100644 index 7a4d2ec432b..00000000000 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/project.yml +++ /dev/null @@ -1,13 +0,0 @@ -type: object -additionalProperties: false -required: - - name -properties: - id: - type: string - readOnly: true - name: - type: string - type: - type: string - readOnly: true diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/projectList.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/projectList.yml deleted file mode 100644 index 7d88be72fb0..00000000000 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/projectList.yml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -properties: - data: - type: array - items: - $ref: './project.yml' - nextCursor: - type: string - description: Paginate through projects by setting the cursor parameter to a nextCursor attribute returned by a previous request. Default value fetches the first "page" of the collection. - nullable: true - example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/projectMember.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/projectMember.yml deleted file mode 100644 index 0fb7e9f37c7..00000000000 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/projectMember.yml +++ /dev/null @@ -1,41 +0,0 @@ -type: object -description: A project member (user with their role in the project). -properties: - id: - type: string - description: The user's unique identifier. - readOnly: true - example: 123e4567-e89b-12d3-a456-426614174000 - email: - type: string - format: email - description: The user's email address. - readOnly: true - example: john.doe@company.com - firstName: - type: string - maxLength: 32 - description: The user's first name. - readOnly: true - example: john - lastName: - type: string - maxLength: 32 - description: The user's last name. - readOnly: true - example: Doe - createdAt: - type: string - format: date-time - description: When the user was created. - readOnly: true - updatedAt: - type: string - format: date-time - description: When the user was last updated. - readOnly: true - role: - type: string - description: The user's role in the project (e.g. project:admin, project:viewer). - readOnly: true - example: project:viewer diff --git a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/projectMemberList.yml b/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/projectMemberList.yml deleted file mode 100644 index 0f4983ad7fe..00000000000 --- a/packages/cli/src/public-api/v1/handlers/projects/spec/schemas/projectMemberList.yml +++ /dev/null @@ -1,11 +0,0 @@ -type: object -properties: - data: - type: array - items: - $ref: './projectMember.yml' - nextCursor: - type: string - description: Paginate through project members by setting the cursor parameter to the nextCursor attribute returned by a previous request. Default value fetches the first page of the collection. - nullable: true - example: MTIzZTQ1NjctZTg5Yi0xMmQzLWE0NTYtNDI2NjE0MTc0MDA diff --git a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 6201573a1e5..456e75ce263 100644 --- a/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml +++ b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml @@ -41,6 +41,16 @@ paths: $ref: ./handlers/projects/spec/paths/updateProject.generated.yml delete: $ref: ./handlers/projects/spec/paths/deleteProject.generated.yml + /projects/{projectId}/users: + get: + $ref: ./handlers/projects/spec/paths/getProjectUsers.generated.yml + post: + $ref: ./handlers/projects/spec/paths/addUsersToProject.generated.yml + /projects/{projectId}/users/{userId}: + patch: + $ref: ./handlers/projects/spec/paths/changeUserRoleInProject.generated.yml + delete: + $ref: ./handlers/projects/spec/paths/deleteUserFromProject.generated.yml /promotions/providers: post: $ref: ./handlers/promotions/spec/paths/createPromotionProvider.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index b7ee8465e5b..c28cb352a26 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -143,10 +143,6 @@ paths: $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.columns.yml' /data-tables/{dataTableId}/columns/{columnId}: $ref: './handlers/data-tables/spec/paths/data-tables.dataTableId.columns.columnId.yml' - /projects/{projectId}/users: - $ref: './handlers/projects/spec/paths/projects.projectId.users.yml' - /projects/{projectId}/users/{userId}: - $ref: './handlers/projects/spec/paths/projects.projectId.users.userId.yml' /community-packages: $ref: './handlers/community-packages/spec/paths/community-packages.yml' /community-packages/{name}: diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 237277ecbb9..7c59c922009 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -1016,6 +1016,7 @@ export class ProjectService { const [members, count] = await this.projectRelationRepository.findAndCount({ where: { projectId }, relations: { user: true, role: true }, + order: { createdAt: 'ASC', userId: 'ASC' }, skip: offset, take: limit, }); diff --git a/packages/cli/test/integration/public-api/projects.test.ts b/packages/cli/test/integration/public-api/projects.test.ts index 146bf08f846..dd680e16cf4 100644 --- a/packages/cli/test/integration/public-api/projects.test.ts +++ b/packages/cli/test/integration/public-api/projects.test.ts @@ -7,6 +7,7 @@ import { testDb, mockInstance, } from '@n8n/backend-test-utils'; +import { GLOBAL_MEMBER_ROLE } from '@n8n/db'; import { Container } from '@n8n/di'; import type { MockInstance } from 'vitest'; @@ -18,6 +19,7 @@ import { createMemberWithApiKey, createOwnerWithApiKey, createMember, + createUserShell, } from '@test-integration/db/users'; import { setupTestServer } from '@test-integration/utils'; @@ -615,7 +617,7 @@ describe('Projects in Public API', () => { const owner = await createOwnerWithApiKey(); const project = await createTeamProject('shared-project', owner); const member1 = await createMember(); - const member2 = await createMember(); + const member2 = await createUserShell(GLOBAL_MEMBER_ROLE); await linkUserToProject(member1, project, 'project:viewer'); await linkUserToProject(member2, project, 'project:editor'); @@ -630,25 +632,23 @@ describe('Projects in Public API', () => { * Assert */ expect(response.status).toBe(200); - expect(response.body).toHaveProperty('data'); expect(response.body).toHaveProperty('nextCursor'); - expect(Array.isArray(response.body.data)).toBe(true); - expect(response.body.data.length).toBe(3); // owner (admin) + member1 + member2 + expect(response.body.data).toHaveLength(3); // owner (admin) + member1 + member2 const memberIds = new Set(response.body.data.map((m: { id: string }) => m.id)); expect(memberIds).toContain(owner.id); expect(memberIds).toContain(member1.id); - expect(memberIds).toContain(member2.id); - for (const row of response.body.data) { - expect(row).toHaveProperty('id'); - expect(row).toHaveProperty('email'); - expect(row).toHaveProperty('firstName'); - expect(row).toHaveProperty('lastName'); - expect(row).toHaveProperty('createdAt'); - expect(row).toHaveProperty('updatedAt'); - expect(row).toHaveProperty('role'); - } + // A shell user has no names yet, so this row shows every field, null columns included + expect(response.body.data).toContainEqual({ + id: member2.id, + email: member2.email, + firstName: null, + lastName: null, + createdAt: expect.any(String), + updatedAt: expect.any(String), + role: 'project:editor', + }); const adminRow = response.body.data.find((m: { id: string }) => m.id === owner.id); expect(adminRow.role).toBe('project:admin'); @@ -716,7 +716,7 @@ describe('Projects in Public API', () => { .get(`/projects/${project.id}/users`); expect(response.status).toBe(401); - expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + expect(response.body).toHaveProperty('message', 'Unauthorized'); }); it('if not licensed, should reject', async () => { @@ -762,6 +762,34 @@ describe('Projects in Public API', () => { `Could not find project with ID "${project.id}"`, ); }); + + it('if missing scope, should reject', async () => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwnerWithApiKey({ scopes: ['project:list'] }); + const project = await createTeamProject('shared-project', owner); + + const response = await testServer + .publicApiAgentFor(owner) + .get(`/projects/${project.id}/users`); + + expect(response.status).toBe(403); + expect(response.body).toHaveProperty('message', 'Forbidden'); + }); + + it('should reject an invalid cursor', async () => { + testServer.license.setQuota('quota:maxTeamProjects', -1); + testServer.license.enable('feat:projectRole:admin'); + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + + const response = await testServer + .publicApiAgentFor(owner) + .get(`/projects/${project.id}/users?cursor=not-a-cursor`); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('message', 'An invalid cursor was provided'); + }); }); describe('POST /projects/:id/users', () => { @@ -773,7 +801,7 @@ describe('Projects in Public API', () => { .post(`/projects/${project.id}/users`); expect(response.status).toBe(401); - expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + expect(response.body).toHaveProperty('message', 'Unauthorized'); }); it('if not licensed, should reject with a 403', async () => { @@ -861,6 +889,22 @@ describe('Projects in Public API', () => { ); }); + it('should reject with 400 if the body is empty', async () => { + const owner = await createOwnerWithApiKey(); + const project = await createTeamProject('shared-project', owner); + + const response = await testServer + .publicApiAgentFor(owner) + .post(`/projects/${project.id}/users`) + .send({}); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty( + 'message', + "request/body must have required property 'relations'", + ); + }); + it('should reject if the relations have a role that do not exist', async () => { const owner = await createOwnerWithApiKey(); const member = await createMember(); @@ -940,6 +984,8 @@ describe('Projects in Public API', () => { }); expect(response.status).toBe(201); + expect(response.text).toBe(''); + expect(response.headers['content-type']).toBeUndefined(); expect(projectBefore.length).toEqual(1); expect(projectBefore[0].userId).toEqual(owner.id); @@ -1027,7 +1073,7 @@ describe('Projects in Public API', () => { .send({ role: 'project:viewer' }); expect(response.status).toBe(401); - expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + expect(response.body).toHaveProperty('message', 'Unauthorized'); }); it('if not licensed, should reject with a 403', async () => { @@ -1179,7 +1225,7 @@ describe('Projects in Public API', () => { .delete(`/projects/${project.id}/users/${member.id}`); expect(response.status).toBe(401); - expect(response.body).toHaveProperty('message', "'X-N8N-API-KEY' header required"); + expect(response.body).toHaveProperty('message', 'Unauthorized'); }); it('if not licensed, should reject with a 403', async () => {