feat(API): Add endpoint to create custom roles (#35522)

Co-authored-by: Sam Wooler <swooler592@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Dmitrii
2026-08-07 17:43:36 +03:00
committed by GitHub
parent 76fb3bcfac
commit d225d0317b
18 changed files with 450 additions and 20 deletions
+1 -1
View File
@@ -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)
+8 -3
View File
@@ -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
+1
View File
@@ -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,
@@ -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(),
}) {}
@@ -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';
@@ -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;
@@ -12,8 +12,6 @@ describe('RoleController', () => {
const roleService = mock<RoleService>();
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<AuthenticatedRequest>({
user: { id: '123', role: { scopes: [{ slug: 'role:manage' }] } },
@@ -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<RoleProjectMembersResponse> {
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<RoleAssignmentsResponse> {
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<RoleDTO> {
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<RoleDTO> {
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<RoleDTO> {
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,
@@ -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;
@@ -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';
@@ -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<RolePublicDto> {
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(),
};
}
}
@@ -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
@@ -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
@@ -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
@@ -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<User>({ 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);
});
});
@@ -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);
}
@@ -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');
});
});
});
@@ -5,6 +5,9 @@
"status": "covered",
"nodeOperation": "audit:generate"
},
"POST /roles": {
"status": "gap"
},
"GET /settings/security-policy": {
"status": "gap"
},