From d225d0317b97f5574690b461664031ac2bb07512 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Fri, 7 Aug 2026 17:43:36 +0300 Subject: [PATCH] feat(API): Add endpoint to create custom roles (#35522) Co-authored-by: Sam Wooler Co-authored-by: Claude Sonnet 5 --- .agents/skills/public-api/SKILL.md | 2 +- .agents/skills/public-api/reference.md | 11 +- packages/@n8n/api-types/src/dto/index.ts | 1 + .../src/dto/roles/role-public.dto.ts | 14 ++ packages/@n8n/permissions/src/constants.ee.ts | 1 + .../src/public-api-permissions.ee.ts | 1 + .../__tests__/role.controller.test.ts | 2 - .../cli/src/controllers/role.controller.ts | 21 +- .../public-api-controller.registry.ts | 18 ++ .../src/public-api/v1/controllers/index.ts | 1 + .../v1/controllers/roles.public.controller.ts | 63 ++++++ .../roles/spec/paths/createRole.generated.yml | 82 ++++++++ .../v1/openapi.decorator-routes.generated.yml | 3 + packages/cli/src/public-api/v1/openapi.yml | 2 + .../__tests__/role-authorization.test.ts | 32 +++ .../cli/src/services/role-authorization.ts | 15 ++ .../test/integration/public-api/roles.test.ts | 198 ++++++++++++++++++ .../nodes/N8n/n8n-api-coverage.json | 3 + 18 files changed, 450 insertions(+), 20 deletions(-) create mode 100644 packages/@n8n/api-types/src/dto/roles/role-public.dto.ts create mode 100644 packages/cli/src/public-api/v1/controllers/roles.public.controller.ts create mode 100644 packages/cli/src/public-api/v1/handlers/roles/spec/paths/createRole.generated.yml create mode 100644 packages/cli/src/services/__tests__/role-authorization.test.ts create mode 100644 packages/cli/src/services/role-authorization.ts create mode 100644 packages/cli/test/integration/public-api/roles.test.ts diff --git a/.agents/skills/public-api/SKILL.md b/.agents/skills/public-api/SKILL.md index 58ab65c6352..a7b8860106d 100644 --- a/.agents/skills/public-api/SKILL.md +++ b/.agents/skills/public-api/SKILL.md @@ -107,7 +107,7 @@ model; reuse only what applies. Decorators, all from `@n8n/decorators`: | `@ApiErrorResponse(status)` | Declares an additional documented non-2xx status (e.g. `404`, `409`). Stack multiple for more than one. `400`/`401`/`403` are added automatically (body/query present, always, and `@ApiKeyScope` present, respectively) — don't declare those yourself. | | `@ApiSummary(text)` / `@ApiDescription(text)` / `@ApiTags([...])` | OpenAPI summary/description/tags. `@ApiTags` sorts alphabetically regardless of the order you pass. All optional but expected on every real route. | | `@Query` / `@Body` / `@Param('name')` | Bind + validate via a `Z.class` DTO / path param. | -| `@Licensed('feat')` | **Not yet enforced for `@PublicApiController` routes** — `PublicApiControllerRegistry` doesn't read `licenseFeature` (only the internal `@RestController` registry does). If the endpoint gates an EE feature, check the license manually in the handler (`Container.get(License).isLicensed(LICENSE_FEATURES.X)`, throwing `ForbiddenError` on failure) instead of relying on this decorator alone. | +| `@Licensed('feat')` | Gates the route on a single `BooleanLicenseFeature`; `PublicApiControllerRegistry` runs its own license middleware (after auth/`@ApiKeyScope`/`@ProjectScope`|`@GlobalScope`, before the handler) and 403s unlicensed requests. Only takes one feature — if the gate is an any-of/all-of combination (e.g. `LicenseState.isProvisioningLicensed()`, which is `feat:saml` OR `feat:oidc`), `@Licensed` can't express that; check manually in the handler instead, same as the internal `provisioning.controller.ee.ts`/`role-mapping-rule.controller.ee.ts` do today (throwing `ForbiddenError` on failure). | ## Authorization (easy to get wrong) diff --git a/.agents/skills/public-api/reference.md b/.agents/skills/public-api/reference.md index 40caffcb2f4..e5b78d731ad 100644 --- a/.agents/skills/public-api/reference.md +++ b/.agents/skills/public-api/reference.md @@ -122,9 +122,14 @@ not templates. separate from the path's own `$ref` in `openapi.yml` and are easy to miss; left dangling, the next bundle fails on a broken `$ref`. - If the legacy handler gated on a license (`isLicensed('feat:x')` middleware), - `@Licensed` does not replicate that for a controller route (see the decorator - table in [SKILL.md](SKILL.md#declaring-a-controller)) — replicate the check - manually in the controller, don't drop it. + `@Licensed('feat:x')` now replicates that for a controller route (see the + decorator table in [SKILL.md](SKILL.md#declaring-a-controller)) — but only for + a single feature. If the legacy check was an any-of/all-of over several flags + (e.g. `LicenseState.isProvisioningLicensed()`), `@Licensed` can't express + that; replicate it manually in the controller instead, don't drop it - this + is exactly what the internal `provisioning.controller.ee.ts` and + `role-mapping-rule.controller.ee.ts` already do, since neither uses + `@Licensed` for that reason. - As a legacy file drops repository access / the `export =` tuple, remove its entry from the `off` allowlists for `no-repository-in-public-api-handler` and `require-public-api-controller` in `packages/cli/eslint.config.mjs` (shrink-only diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 7ed6f17bace..c351e6931df 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -180,6 +180,7 @@ export { export { UpdateRoleDto } from './roles/update-role.dto'; export { CreateRoleDto } from './roles/create-role.dto'; +export { RolePublicDto } from './roles/role-public.dto'; export { CreateRoleMappingRuleDto } from './roles/create-role-mapping-rule.dto'; export { PatchRoleMappingRuleDto, diff --git a/packages/@n8n/api-types/src/dto/roles/role-public.dto.ts b/packages/@n8n/api-types/src/dto/roles/role-public.dto.ts new file mode 100644 index 00000000000..9311602cad5 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/roles/role-public.dto.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +import { Z } from '../../zod-class'; + +export class RolePublicDto extends Z.class({ + slug: z.string(), + displayName: z.string(), + description: z.string().nullable(), + systemRole: z.boolean(), + roleType: z.enum(['project', 'global']), + scopes: z.array(z.string()), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}) {} diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 5fa4e40b7be..9fef535513e 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -111,6 +111,7 @@ export const API_KEY_RESOURCES = { dataTableColumn: ['create', 'read', 'delete', 'update'] as const, folder: ['create', 'delete', 'read', 'update', 'list'] as const, insights: ['read'] as const, + role: ['manage', 'manageProject'] as const, } as const; export const GLOBAL_OWNER_ROLE_SLUG = 'global:owner'; diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index 77e76a25df7..d789140d9d3 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -88,6 +88,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'dataTableColumn:update', 'dataTableColumn:delete', 'insights:read', + 'role:manage', ]; export const ADMIN_API_KEY_SCOPES: ApiKeyScope[] = OWNER_API_KEY_SCOPES; diff --git a/packages/cli/src/controllers/__tests__/role.controller.test.ts b/packages/cli/src/controllers/__tests__/role.controller.test.ts index ce61bcf7508..e5ee85ba6f0 100644 --- a/packages/cli/src/controllers/__tests__/role.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/role.controller.test.ts @@ -12,8 +12,6 @@ describe('RoleController', () => { const roleService = mock(); const controller = new RoleController(roleService, eventService); - // A user whose global role grants role:manage, so the controller's - // authorization guard short-circuits and these tests can focus on events. const managerRequest = () => mock({ user: { id: '123', role: { scopes: [{ slug: 'role:manage' }] } }, diff --git a/packages/cli/src/controllers/role.controller.ts b/packages/cli/src/controllers/role.controller.ts index 7b48af9bc14..59a0d26a126 100644 --- a/packages/cli/src/controllers/role.controller.ts +++ b/packages/cli/src/controllers/role.controller.ts @@ -27,12 +27,11 @@ import { Query, RestController, } from '@n8n/decorators'; -import { hasGlobalScope, Role as RoleDTO, RoleNamespace } from '@n8n/permissions'; +import { hasGlobalScope, Role as RoleDTO } from '@n8n/permissions'; import { EventService } from '@/events/event.service'; +import { assertCanManageRoleType } from '@/services/role-authorization'; import { RoleService } from '@/services/role.service'; -import { RESPONSE_ERROR_MESSAGES } from '@/constants'; -import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; @RestController('/roles') export class RoleController { @@ -41,12 +40,6 @@ export class RoleController { private readonly eventService: EventService, ) {} - private assertCanManageRoleType(user: User, roleType: RoleNamespace): void { - if (hasGlobalScope(user, 'role:manage')) return; - if (roleType === 'project' && hasGlobalScope(user, 'role:manageProject')) return; - throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.MISSING_SCOPE); - } - /** * Reassigning a deleted role's users is effectively a bulk instance-role change, * so it is only honored for callers entitled to change users' instance roles @@ -87,7 +80,7 @@ export class RoleController { @Param('projectId') projectId: string, ): Promise { const role = await this.roleService.getRole(slug); - this.assertCanManageRoleType(req.user, role.roleType); + assertCanManageRoleType(req.user, role.roleType); const result = await this.roleService.getRoleProjectMembers(slug, projectId); return RoleProjectMembersResponseDto.parse(result); } @@ -99,7 +92,7 @@ export class RoleController { @Param('slug') slug: string, ): Promise { const role = await this.roleService.getRole(slug); - this.assertCanManageRoleType(req.user, role.roleType); + assertCanManageRoleType(req.user, role.roleType); const result = await this.roleService.getRoleAssignments(slug); return RoleAssignmentsResponseDto.parse(result); } @@ -134,7 +127,7 @@ export class RoleController { @Body updateRole: UpdateRoleDto, ): Promise { const role = await this.roleService.getRole(slug); - this.assertCanManageRoleType(req.user, role.roleType); + assertCanManageRoleType(req.user, role.roleType); const result = await this.roleService.updateCustomRole(slug, updateRole); this.eventService.emit('custom-role-updated', { userId: req.user.id, @@ -153,7 +146,7 @@ export class RoleController { @Query query: RoleDeleteQueryDto, ): Promise { const role = await this.roleService.getRole(slug); - this.assertCanManageRoleType(req.user, role.roleType); + assertCanManageRoleType(req.user, role.roleType); const reassignRoleSlug = this.canReassignUsers(req.user, role) ? query.reassignRoleSlug : undefined; @@ -172,7 +165,7 @@ export class RoleController { _res: Response, @Body createRole: CreateRoleDto, ): Promise { - this.assertCanManageRoleType(req.user, createRole.roleType); + assertCanManageRoleType(req.user, createRole.roleType); const result = await this.roleService.createCustomRole(createRole); this.eventService.emit('custom-role-created', { userId: req.user.id, 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 2f98258a3d3..3fa51d2234a 100644 --- a/packages/cli/src/public-api/public-api-controller.registry.ts +++ b/packages/cli/src/public-api/public-api-controller.registry.ts @@ -1,3 +1,4 @@ +import type { BooleanLicenseFeature } from '@n8n/constants'; import type { AuthenticatedRequest } from '@n8n/db'; import { ControllerRegistryMetadata } from '@n8n/decorators'; import type { AccessScope, ApiKeyScopeRequirement, Controller } from '@n8n/decorators'; @@ -5,8 +6,10 @@ import { Container, Service } from '@n8n/di'; import type { Request, RequestHandler, Response, Router } from 'express'; import { Router as createRouter } from 'express'; +import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { EventService } from '@/events/event.service'; +import { License } from '@/license'; import { userHasScopes } from '@/permissions.ee/check-access'; import { apiKeyScopesSatisfy, @@ -103,6 +106,10 @@ export class PublicApiControllerRegistry { middlewares.push(this.createAccessScopeMiddleware(route.accessScope)); } + if (route.licenseFeature) { + middlewares.push(this.createLicenseMiddleware(route.licenseFeature)); + } + middlewares.push(...controllerMiddlewares, ...(route.middlewares ?? [])); const finalHandler: RequestHandler = async (req, res, next) => { @@ -164,6 +171,17 @@ export class PublicApiControllerRegistry { }; } + private createLicenseMiddleware(feature: BooleanLicenseFeature): RequestHandler { + return (_req, res, next) => { + if (!Container.get(License).isLicensed(feature)) { + res.status(403).json({ message: new FeatureNotLicensedError(feature).message }); + return; + } + + next(); + }; + } + private createAccessScopeMiddleware(accessScope: AccessScope): RequestHandler { return async (req, res, next) => { const authReq = req as AuthenticatedRequest; diff --git a/packages/cli/src/public-api/v1/controllers/index.ts b/packages/cli/src/public-api/v1/controllers/index.ts index a95a5a8beb3..a7e4a973e79 100644 --- a/packages/cli/src/public-api/v1/controllers/index.ts +++ b/packages/cli/src/public-api/v1/controllers/index.ts @@ -3,5 +3,6 @@ * decorator metadata is registered before PublicApiControllerRegistry / * scope-parity / discover run. */ +import './roles.public.controller'; import './tags.public.controller'; import './workflows.public.controller'; diff --git a/packages/cli/src/public-api/v1/controllers/roles.public.controller.ts b/packages/cli/src/public-api/v1/controllers/roles.public.controller.ts new file mode 100644 index 00000000000..aef6ea9d44b --- /dev/null +++ b/packages/cli/src/public-api/v1/controllers/roles.public.controller.ts @@ -0,0 +1,63 @@ +import { CreateRoleDto, RolePublicDto } from '@n8n/api-types'; +import { LICENSE_FEATURES } from '@n8n/constants'; +import { AuthenticatedRequest } from '@n8n/db'; +import { + ApiDescription, + ApiKeyScope, + ApiResponse, + ApiSummary, + ApiTags, + Body, + Licensed, + Post, + PublicApiController, +} from '@n8n/decorators'; +import type { Response } from 'express'; + +import { EventService } from '@/events/event.service'; +import { assertCanManageRoleType } from '@/services/role-authorization'; +import { RoleService } from '@/services/role.service'; + +@PublicApiController('/roles') +export class RolesPublicController { + constructor( + private readonly roleService: RoleService, + private readonly eventService: EventService, + ) {} + + @Post('/') + @ApiKeyScope({ anyOf: ['role:manage', 'role:manageProject'] }) + @Licensed(LICENSE_FEATURES.CUSTOM_ROLES) + @ApiSummary('Create a custom role') + @ApiDescription( + 'Creates a custom role. Set `roleType` to `global` for an instance-wide role or `project` for a project role.', + ) + @ApiTags(['Role']) + @ApiResponse(201, RolePublicDto) + async createRole( + req: AuthenticatedRequest, + _res: Response, + @Body createRole: CreateRoleDto, + ): Promise { + assertCanManageRoleType(req.user, createRole.roleType); + + const role = await this.roleService.createCustomRole(createRole); + + this.eventService.emit('custom-role-created', { + userId: req.user.id, + roleSlug: role.slug, + scopes: role.scopes, + }); + + return { + slug: role.slug, + displayName: role.displayName, + description: role.description, + systemRole: role.systemRole, + roleType: createRole.roleType, + scopes: role.scopes, + createdAt: role.createdAt!.toISOString(), + updatedAt: role.updatedAt!.toISOString(), + }; + } +} diff --git a/packages/cli/src/public-api/v1/handlers/roles/spec/paths/createRole.generated.yml b/packages/cli/src/public-api/v1/handlers/roles/spec/paths/createRole.generated.yml new file mode 100644 index 00000000000..0fdda6001e6 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/roles/spec/paths/createRole.generated.yml @@ -0,0 +1,82 @@ +operationId: createRole +tags: + - Role +summary: Create a custom role +description: Creates a custom role. Set `roleType` to `global` for an instance-wide role or `project` for a project role. +x-required-scope: role:manage,role:manageProject +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +requestBody: + content: + application/json: + schema: + type: object + properties: + displayName: + type: string + minLength: 2 + maxLength: 100 + description: + type: string + maxLength: 500 + roleType: + type: string + enum: + - project + - global + scopes: + type: array + items: + type: string + required: + - displayName + - roleType + - scopes +responses: + '201': + description: Operation successful. + content: + application/json: + schema: + type: object + properties: + slug: + type: string + displayName: + type: string + description: + type: string + nullable: true + systemRole: + type: boolean + roleType: + type: string + enum: + - project + - global + scopes: + type: array + items: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - slug + - displayName + - description + - systemRole + - roleType + - scopes + - createdAt + - updatedAt + '400': + $ref: ../../../../shared/spec/responses/badRequest.yml + '401': + $ref: ../../../../shared/spec/responses/unauthorized.yml + '403': + $ref: ../../../../shared/spec/responses/forbidden.yml 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 f8e7ede3dff..268b9f8a386 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 @@ -3,6 +3,9 @@ info: title: decorator-routes version: 0.0.0 paths: + /roles: + post: + $ref: ./handlers/roles/spec/paths/createRole.generated.yml /tags: get: $ref: ./handlers/tags/spec/paths/getTags.generated.yml diff --git a/packages/cli/src/public-api/v1/openapi.yml b/packages/cli/src/public-api/v1/openapi.yml index 140f14c9c6f..bfa1f7b443c 100644 --- a/packages/cli/src/public-api/v1/openapi.yml +++ b/packages/cli/src/public-api/v1/openapi.yml @@ -47,6 +47,8 @@ tags: description: Beta — breaking changes may still occur without major version bump. - name: Projects description: Operations about projects + - name: Role + description: Operations about roles - name: SecurityPolicy description: Operations about the instance security policy settings - name: SettingsLdap diff --git a/packages/cli/src/services/__tests__/role-authorization.test.ts b/packages/cli/src/services/__tests__/role-authorization.test.ts new file mode 100644 index 00000000000..0fb2a5fdf91 --- /dev/null +++ b/packages/cli/src/services/__tests__/role-authorization.test.ts @@ -0,0 +1,32 @@ +import type { User } from '@n8n/db'; +import type { Scope } from '@n8n/permissions'; +import { mock } from 'vitest-mock-extended'; + +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; +import { assertCanManageRoleType } from '@/services/role-authorization'; + +describe('assertCanManageRoleType', () => { + const userWithScopes = (...scopes: Scope[]) => + mock({ role: { scopes: scopes.map((slug) => ({ slug })) } }); + + it('allows any role type for a user with role:manage', () => { + const user = userWithScopes('role:manage'); + + expect(() => assertCanManageRoleType(user, 'global')).not.toThrow(); + expect(() => assertCanManageRoleType(user, 'project')).not.toThrow(); + }); + + it('allows only project roles for a user with role:manageProject', () => { + const user = userWithScopes('role:manageProject'); + + expect(() => assertCanManageRoleType(user, 'project')).not.toThrow(); + expect(() => assertCanManageRoleType(user, 'global')).toThrow(ForbiddenError); + }); + + it('rejects a user with neither scope', () => { + const user = userWithScopes('role:read'); + + expect(() => assertCanManageRoleType(user, 'project')).toThrow(ForbiddenError); + expect(() => assertCanManageRoleType(user, 'global')).toThrow(ForbiddenError); + }); +}); diff --git a/packages/cli/src/services/role-authorization.ts b/packages/cli/src/services/role-authorization.ts new file mode 100644 index 00000000000..4f83cbac50f --- /dev/null +++ b/packages/cli/src/services/role-authorization.ts @@ -0,0 +1,15 @@ +import type { User } from '@n8n/db'; +import { hasGlobalScope, type RoleNamespace } from '@n8n/permissions'; + +import { RESPONSE_ERROR_MESSAGES } from '@/constants'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; + +/** + * Managing a role requires `role:manage`; `role:manageProject` grants it for + * project roles only. Shared by the internal and public API controllers. + */ +export function assertCanManageRoleType(user: User, roleType: RoleNamespace): void { + if (hasGlobalScope(user, 'role:manage')) return; + if (roleType === 'project' && hasGlobalScope(user, 'role:manageProject')) return; + throw new ForbiddenError(RESPONSE_ERROR_MESSAGES.MISSING_SCOPE); +} diff --git a/packages/cli/test/integration/public-api/roles.test.ts b/packages/cli/test/integration/public-api/roles.test.ts new file mode 100644 index 00000000000..16e724e5a74 --- /dev/null +++ b/packages/cli/test/integration/public-api/roles.test.ts @@ -0,0 +1,198 @@ +import { testDb } from '@n8n/backend-test-utils'; +import { RoleRepository, type User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { createCustomRoleWithScopeSlugs } from '@test-integration/db/roles'; +import { addApiKey, createOwnerWithApiKey, createUser } from '@test-integration/db/users'; +import { setupTestServer } from '@test-integration/utils'; + +describe('Roles in Public API', () => { + let owner: User; + const testServer = setupTestServer({ + endpointGroups: ['publicApi'], + enabledFeatures: ['feat:customRoles'], + }); + + // A user whose custom GLOBAL role grants only role:manageProject (not role:manage). + // Its API key derives role:manageProject, so it can create project roles but not global ones. + const makeManageProjectUserAgent = async () => { + const role = await createCustomRoleWithScopeSlugs(['role:read', 'role:manageProject'], { + roleType: 'global', + }); + const user = await createUser({ role }); + user.apiKeys = [await addApiKey(user)]; + return testServer.publicApiAgentFor(user); + }; + + beforeAll(async () => { + await testDb.init(); + }); + + beforeEach(async () => { + // Truncate users first so the cleanup below can delete custom roles they referenced. + await testDb.truncate(['User']); + await Container.get(RoleRepository).delete({ systemRole: false }); + owner = await createOwnerWithApiKey(); + }); + + describe('POST /roles', () => { + it('creates a global role and returns 201', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .post('/roles') + .send({ + displayName: 'PA global role', + roleType: 'global', + scopes: ['user:read', 'user:list'], + }); + + expect(response.status).toBe(201); + // Full-shape assertion also proves no extra fields leak (e.g. usedByUsers). + expect(response.body).toEqual({ + slug: expect.stringMatching(/^global:.+-[a-z0-9]{6}$/), + displayName: 'PA global role', + description: null, + systemRole: false, + roleType: 'global', + scopes: expect.arrayContaining(['user:read', 'user:list']), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + expect(response.body.scopes).toHaveLength(2); + }); + + it('creates a project role and returns 201', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .post('/roles') + .send({ + displayName: 'PA project role', + roleType: 'project', + scopes: ['workflow:read'], + }); + + expect(response.status).toBe(201); + expect(response.body.slug).toMatch(/^project:.+-[a-z0-9]{6}$/); + expect(response.body.roleType).toBe('project'); + expect(response.body.scopes).toEqual(['workflow:read']); + }); + + it('creates a role with no scopes and returns 201 (matches internal behaviour)', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .post('/roles') + .send({ displayName: 'PA empty role', roleType: 'global', scopes: [] }); + + expect(response.status).toBe(201); + expect(response.body.scopes).toEqual([]); + }); + + it('lets a role:manageProject key create a project role (201)', async () => { + const agent = await makeManageProjectUserAgent(); + + const response = await agent + .post('/roles') + .send({ displayName: 'MP project role', roleType: 'project', scopes: ['workflow:read'] }); + + expect(response.status).toBe(201); + expect(response.body.roleType).toBe('project'); + }); + + it('forbids a role:manageProject key from creating a global role (403)', async () => { + const agent = await makeManageProjectUserAgent(); + + const response = await agent + .post('/roles') + .send({ displayName: 'MP global role', roleType: 'global', scopes: ['user:read'] }); + + expect(response.status).toBe(403); + }); + + it('rejects an unknown scope slug with 400', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .post('/roles') + .send({ displayName: 'PA bad slug', roleType: 'global', scopes: ['not:a-real-scope'] }); + + expect(response.status).toBe(400); + expect(response.body.message).toContain('Invalid scope'); + }); + + it('rejects a scope not allowed for the role type with 400', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .post('/roles') + .send({ displayName: 'PA wrong scope', roleType: 'global', scopes: ['workflow:read'] }); + + expect(response.status).toBe(400); + expect(response.body.message).toContain('not allowed for global roles'); + }); + + it('rejects a too-short displayName with 400', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .post('/roles') + .send({ displayName: 'a', roleType: 'global', scopes: ['user:read'] }); + + expect(response.status).toBe(400); + expect(response.body.message).toContain('at least 2'); + }); + + it('rejects a duplicate role name with 400', async () => { + const body = { + displayName: 'PA duplicate role', + roleType: 'global', + scopes: ['user:read'], + }; + + const first = await testServer.publicApiAgentFor(owner).post('/roles').send(body); + expect(first.status).toBe(201); + + const second = await testServer.publicApiAgentFor(owner).post('/roles').send(body); + expect(second.status).toBe(400); + expect(second.body.message).toContain('already exists'); + }); + + it('rejects with 401 without an API key', async () => { + const response = await testServer + .publicApiAgentWithoutApiKey() + .post('/roles') + .send({ displayName: 'PA no key', roleType: 'global', scopes: ['user:read'] }); + + expect(response.status).toBe(401); + }); + + it('rejects with 401 with an invalid API key', async () => { + const response = await testServer + .publicApiAgentWithApiKey('invalid-key') + .post('/roles') + .send({ displayName: 'PA bad key', roleType: 'global', scopes: ['user:read'] }); + + expect(response.status).toBe(401); + }); + + it('rejects with 403 when the key lacks a role scope', async () => { + const scopedOwner = await createOwnerWithApiKey({ scopes: ['user:read'] }); + + const response = await testServer + .publicApiAgentFor(scopedOwner) + .post('/roles') + .send({ displayName: 'PA no scope', roleType: 'global', scopes: ['user:read'] }); + + expect(response.status).toBe(403); + }); + + it('rejects with 403 when the custom roles feature is not licensed', async () => { + testServer.license.disable('feat:customRoles'); + + const response = await testServer + .publicApiAgentFor(owner) + .post('/roles') + .send({ displayName: 'PA unlicensed', roleType: 'global', scopes: ['user:read'] }); + + expect(response.status).toBe(403); + + testServer.license.enable('feat:customRoles'); + }); + }); +}); diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 36aa83e23cd..6529233a8f4 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -5,6 +5,9 @@ "status": "covered", "nodeOperation": "audit:generate" }, + "POST /roles": { + "status": "gap" + }, "GET /settings/security-policy": { "status": "gap" },