diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 01618c770e3..2fc17d8d776 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -195,6 +195,7 @@ export { RolePublicDto, RoleListPublicDto, RoleListQueryPublicDto, + RoleGetPublicDto, } from './roles/role-public.dto'; export { CreateRoleMappingRuleDto } from './roles/create-role-mapping-rule.dto'; export { RoleMappingRulePublicDto } from './roles/role-mapping-rule-public.dto'; 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 index ed475b6e7e4..9e65a4e567d 100644 --- a/packages/@n8n/api-types/src/dto/roles/role-public.dto.ts +++ b/packages/@n8n/api-types/src/dto/roles/role-public.dto.ts @@ -30,3 +30,9 @@ export class RoleListPublicDto extends Z.class({ export class RoleListQueryPublicDto extends Z.class({ withUsageCount: booleanFromString.optional().default('false'), }) {} + +export class RoleGetPublicDto extends RolePublicDto.extend({ + licensed: z.boolean(), + usedByUsers: z.number().optional(), + usedByProjects: z.number().optional(), +}) {} diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 2dd1738f328..639ffb49b85 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -111,7 +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', 'list'] as const, + role: ['manage', 'manageProject', 'list', 'read'] as const, roleMappingRule: ['create'] as const, } as const; diff --git a/packages/@n8n/permissions/src/public-api-permissions.ee.ts b/packages/@n8n/permissions/src/public-api-permissions.ee.ts index edc1b07fb4d..fe4e4008538 100644 --- a/packages/@n8n/permissions/src/public-api-permissions.ee.ts +++ b/packages/@n8n/permissions/src/public-api-permissions.ee.ts @@ -90,6 +90,7 @@ export const OWNER_API_KEY_SCOPES: ApiKeyScope[] = [ 'insights:read', 'role:manage', 'role:list', + 'role:read', 'roleMappingRule:create', ]; 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 index c0b96224704..a8386e033b5 100644 --- a/packages/cli/src/public-api/v1/controllers/roles.public.controller.ts +++ b/packages/cli/src/public-api/v1/controllers/roles.public.controller.ts @@ -1,5 +1,6 @@ import { CreateRoleDto, + RoleGetPublicDto, RoleListPublicDto, RoleListQueryPublicDto, RolePublicDto, @@ -8,6 +9,7 @@ import { LICENSE_FEATURES } from '@n8n/constants'; import { AuthenticatedRequest } from '@n8n/db'; import { ApiDescription, + ApiErrorResponse, ApiKeyScope, ApiResponse, ApiSummary, @@ -15,6 +17,7 @@ import { Body, Get, Licensed, + Param, Post, PublicApiController, Query, @@ -22,6 +25,7 @@ import { import { RoleNamespace, type Role as RoleDTO } from '@n8n/permissions'; import type { Response } from 'express'; +import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; import { assertCanManageRoleType } from '@/services/role-authorization'; import { RoleService } from '@/services/role.service'; @@ -30,6 +34,22 @@ type PublicRoleNamespace = Extract; const isPublicRole = (role: RoleDTO): role is RoleDTO & { roleType: PublicRoleNamespace } => role.roleType === 'global' || role.roleType === 'project'; +const toPublicRole = ( + role: RoleDTO & { roleType: T }, + withUsageCount = false, +) => ({ + slug: role.slug, + displayName: role.displayName, + description: role.description, + systemRole: role.systemRole, + roleType: role.roleType, + licensed: role.licensed, + scopes: role.scopes, + createdAt: role.createdAt!.toISOString(), + updatedAt: role.updatedAt!.toISOString(), + ...(withUsageCount ? { usedByUsers: role.usedByUsers, usedByProjects: role.usedByProjects } : {}), +}); + @PublicApiController('/roles') export class RolesPublicController { constructor( @@ -57,20 +77,7 @@ export class RolesPublicController { const groupOf = (roleType: T) => publicRoles .filter((role): role is RoleDTO & { roleType: T } => role.roleType === roleType) - .map((role) => ({ - slug: role.slug, - displayName: role.displayName, - description: role.description, - systemRole: role.systemRole, - roleType: role.roleType, - licensed: role.licensed, - scopes: role.scopes, - createdAt: role.createdAt!.toISOString(), - updatedAt: role.updatedAt!.toISOString(), - ...(withUsageCount - ? { usedByUsers: role.usedByUsers, usedByProjects: role.usedByProjects } - : {}), - })); + .map((role) => toPublicRole(role, withUsageCount)); return { global: groupOf('global'), @@ -78,6 +85,29 @@ export class RolesPublicController { }; } + @Get('/:slug') + @ApiKeyScope('role:read') + @ApiSummary('Retrieve a role') + @ApiDescription( + 'Returns a single role with its scopes. Set `withUsageCount` to include how many users and projects use the role.', + ) + @ApiTags(['Role']) + @ApiResponse(200, RoleGetPublicDto) + @ApiErrorResponse(404) + async getRole( + _req: AuthenticatedRequest, + _res: Response, + @Param('slug') slug: string, + @Query query: RoleListQueryPublicDto, + ): Promise { + const { withUsageCount } = query; + const role = await this.roleService.getRole(slug, withUsageCount); + if (!isPublicRole(role)) { + throw new NotFoundError('Role not found'); + } + return toPublicRole(role, withUsageCount); + } + @Post('/') @ApiKeyScope({ anyOf: ['role:manage', 'role:manageProject'] }) @Licensed(LICENSE_FEATURES.CUSTOM_ROLES) diff --git a/packages/cli/src/public-api/v1/handlers/roles/spec/paths/getRole.generated.yml b/packages/cli/src/public-api/v1/handlers/roles/spec/paths/getRole.generated.yml new file mode 100644 index 00000000000..51bb6a707a5 --- /dev/null +++ b/packages/cli/src/public-api/v1/handlers/roles/spec/paths/getRole.generated.yml @@ -0,0 +1,80 @@ +operationId: getRole +tags: + - Role +summary: Retrieve a role +description: Returns a single role with its scopes. Set `withUsageCount` to include how many users and projects use the role. +x-required-scope: role:read +x-eov-operation-id: unreachable +x-eov-operation-handler: v1/handlers/decorator-routed.handler +x-decorator-routed: true +parameters: + - schema: + type: string + required: true + name: slug + in: path + - schema: + type: string + enum: + - 'true' + - 'false' + default: 'false' + required: false + name: withUsageCount + in: query +responses: + '200': + 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 + licensed: + type: boolean + usedByUsers: + type: number + usedByProjects: + type: number + required: + - slug + - displayName + - description + - systemRole + - roleType + - scopes + - createdAt + - updatedAt + - licensed + '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/openapi.decorator-routes.generated.yml b/packages/cli/src/public-api/v1/openapi.decorator-routes.generated.yml index 70da43be27e..ca20e142d10 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 @@ -11,6 +11,9 @@ paths: $ref: ./handlers/roles/spec/paths/getAllRoles.generated.yml post: $ref: ./handlers/roles/spec/paths/createRole.generated.yml + /roles/{slug}: + get: + $ref: ./handlers/roles/spec/paths/getRole.generated.yml /tags: get: $ref: ./handlers/tags/spec/paths/getTags.generated.yml diff --git a/packages/cli/test/integration/public-api/roles.test.ts b/packages/cli/test/integration/public-api/roles.test.ts index 87fa0883130..7f24edf5f22 100644 --- a/packages/cli/test/integration/public-api/roles.test.ts +++ b/packages/cli/test/integration/public-api/roles.test.ts @@ -1,7 +1,6 @@ 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'; @@ -130,6 +129,123 @@ describe('Roles in Public API', () => { }); }); + describe('GET /roles/:slug', () => { + const systemRoleBody = (slug: string, roleType: string) => ({ + slug, + displayName: expect.any(String), + description: expect.any(String), + systemRole: true, + roleType, + licensed: expect.any(Boolean), + scopes: expect.any(Array), + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + + it('returns a system role with its scopes', async () => { + const response = await testServer.publicApiAgentFor(owner).get('/roles/global:owner'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(systemRoleBody('global:owner', 'global')); + expect(response.body.scopes.length).toBeGreaterThan(0); + }); + + it('returns a newly created custom role', async () => { + const created = await testServer + .publicApiAgentFor(owner) + .post('/roles') + .send({ displayName: 'PA get role', roleType: 'global', scopes: ['user:read'] }); + expect(created.status).toBe(201); + + const response = await testServer.publicApiAgentFor(owner).get(`/roles/${created.body.slug}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + slug: created.body.slug, + displayName: 'PA get role', + description: null, + systemRole: false, + roleType: 'global', + licensed: expect.any(Boolean), + scopes: ['user:read'], + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + it('returns a project role', async () => { + const response = await testServer.publicApiAgentFor(owner).get('/roles/project:admin'); + + expect(response.status).toBe(200); + expect(response.body).toEqual(systemRoleBody('project:admin', 'project')); + }); + + it('omits usage counts by default', async () => { + const response = await testServer.publicApiAgentFor(owner).get('/roles/global:owner'); + + expect(response.status).toBe(200); + expect(response.body).not.toHaveProperty('usedByUsers'); + expect(response.body).not.toHaveProperty('usedByProjects'); + }); + + it('includes usage counts when withUsageCount is set', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .get('/roles/global:owner') + .query({ withUsageCount: 'true' }); + + expect(response.status).toBe(200); + expect(response.body.usedByUsers).toBeGreaterThanOrEqual(1); + expect(response.body.usedByProjects).toBeGreaterThanOrEqual(0); + }); + + it('returns 404 for an unknown slug', async () => { + const response = await testServer + .publicApiAgentFor(owner) + .get('/roles/global:does-not-exist'); + + expect(response.status).toBe(404); + }); + + it('returns 404 for a non-public role type', async () => { + const response = await testServer.publicApiAgentFor(owner).get('/roles/credential:owner'); + + expect(response.status).toBe(404); + }); + + it('works when the custom roles feature is not licensed', async () => { + testServer.license.disable('feat:customRoles'); + + const response = await testServer.publicApiAgentFor(owner).get('/roles/global:owner'); + + expect(response.status).toBe(200); + + testServer.license.enable('feat:customRoles'); + }); + + it('rejects with 401 without an API key', async () => { + const response = await testServer.publicApiAgentWithoutApiKey().get('/roles/global:owner'); + + expect(response.status).toBe(401); + }); + + it('rejects with 401 with an invalid API key', async () => { + const response = await testServer + .publicApiAgentWithApiKey('invalid-key') + .get('/roles/global:owner'); + + expect(response.status).toBe(401); + }); + + it('rejects with 403 when the key lacks the role:read scope', async () => { + const scopedOwner = await createOwnerWithApiKey({ scopes: ['role:list'] }); + + const response = await testServer.publicApiAgentFor(scopedOwner).get('/roles/global:owner'); + + expect(response.status).toBe(403); + }); + }); + describe('POST /roles', () => { it('creates a global role and returns 201', async () => { const response = await testServer diff --git a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json index 04bb3377801..bba689824b6 100644 --- a/packages/nodes-base/nodes/N8n/n8n-api-coverage.json +++ b/packages/nodes-base/nodes/N8n/n8n-api-coverage.json @@ -8,6 +8,9 @@ "GET /roles": { "status": "gap" }, + "GET /roles/{slug}": { + "status": "gap" + }, "POST /roles": { "status": "gap" },