mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user