diff --git a/packages/@n8n/api-types/src/dto/index.ts b/packages/@n8n/api-types/src/dto/index.ts index 062932901c7..cbbbd93e4b4 100644 --- a/packages/@n8n/api-types/src/dto/index.ts +++ b/packages/@n8n/api-types/src/dto/index.ts @@ -178,6 +178,11 @@ export { type RoleProjectMember, type RoleProjectMembersResponse, } from './roles/role-project-members-response.dto'; +export { + RoleMembersResponseDto, + type RoleMember, + type RoleMembersResponse, +} from './roles/role-members-response.dto'; export { OidcConfigDto, OIDC_PROMPT_VALUES } from './oidc/config.dto'; export { TestOidcConfigResponseDto } from './oidc/test-oidc-config-response.dto'; diff --git a/packages/@n8n/api-types/src/dto/roles/role-member.schema.ts b/packages/@n8n/api-types/src/dto/roles/role-member.schema.ts new file mode 100644 index 00000000000..3871e001904 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/roles/role-member.schema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const roleMemberSchema = z.object({ + userId: z.string(), + firstName: z.string().nullable(), + lastName: z.string().nullable(), + email: z.string(), + role: z.string(), +}); diff --git a/packages/@n8n/api-types/src/dto/roles/role-members-response.dto.ts b/packages/@n8n/api-types/src/dto/roles/role-members-response.dto.ts new file mode 100644 index 00000000000..cf9e9d7e864 --- /dev/null +++ b/packages/@n8n/api-types/src/dto/roles/role-members-response.dto.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +import { roleMemberSchema } from './role-member.schema'; +import { Z } from '../../zod-class'; + +export type RoleMember = z.infer; +export class RoleMembersResponseDto extends Z.class({ + members: z.array(roleMemberSchema), + total: z.number(), +}) {} +export type RoleMembersResponse = InstanceType; diff --git a/packages/@n8n/api-types/src/dto/roles/role-project-members-response.dto.ts b/packages/@n8n/api-types/src/dto/roles/role-project-members-response.dto.ts index d3ca99e4734..0aaba9094d2 100644 --- a/packages/@n8n/api-types/src/dto/roles/role-project-members-response.dto.ts +++ b/packages/@n8n/api-types/src/dto/roles/role-project-members-response.dto.ts @@ -1,19 +1,12 @@ import { z } from 'zod'; +import { roleMemberSchema } from './role-member.schema'; import { Z } from '../../zod-class'; -const roleProjectMemberSchema = z.object({ - userId: z.string(), - firstName: z.string().nullable(), - lastName: z.string().nullable(), - email: z.string(), - role: z.string(), -}); - -export type RoleProjectMember = z.infer; +export type RoleProjectMember = z.infer; export class RoleProjectMembersResponseDto extends Z.class({ - members: z.array(roleProjectMemberSchema), + members: z.array(roleMemberSchema), }) {} export type RoleProjectMembersResponse = InstanceType; diff --git a/packages/@n8n/db/src/repositories/role.repository.ts b/packages/@n8n/db/src/repositories/role.repository.ts index 70b164edcac..dcd8fd09775 100644 --- a/packages/@n8n/db/src/repositories/role.repository.ts +++ b/packages/@n8n/db/src/repositories/role.repository.ts @@ -126,6 +126,26 @@ export class RoleRepository extends Repository { .filter((r) => r !== null); } + async findUsersWithGlobalRole(roleSlug: string): Promise< + Array<{ + userId: string; + firstName: string | null; + lastName: string | null; + email: string; + role: string; + }> + > { + return await this.manager + .createQueryBuilder(User, 'user') + .select('user.id', 'userId') + .addSelect('user.firstName', 'firstName') + .addSelect('user.lastName', 'lastName') + .addSelect('user.email', 'email') + .addSelect('user.roleSlug', 'role') + .where('user.roleSlug = :slug', { slug: roleSlug }) + .getRawMany(); + } + async findAllProjectMembers( projectId: string, roleSlug?: string, diff --git a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap index 460717d1e11..e329a11dbbe 100644 --- a/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap +++ b/packages/@n8n/permissions/src/__tests__/__snapshots__/scope-information.test.ts.snap @@ -169,6 +169,7 @@ exports[`Scope Information > ensure scopes are defined correctly 1`] = ` "workflowTags:list", "workflowTags:*", "role:manage", + "role:read", "role:*", "mcp:manage", "mcp:oauth", diff --git a/packages/@n8n/permissions/src/constants.ee.ts b/packages/@n8n/permissions/src/constants.ee.ts index 822a2245f4d..081c9e1bc4b 100644 --- a/packages/@n8n/permissions/src/constants.ee.ts +++ b/packages/@n8n/permissions/src/constants.ee.ts @@ -62,7 +62,7 @@ export const RESOURCES = { ] as const, execution: ['delete', 'read', 'retry', 'list', 'get', 'reveal'] as const, workflowTags: ['update', 'list'] as const, - role: ['manage'] as const, + role: ['manage', 'read'] as const, mcp: ['manage', 'oauth'] as const, mcpApiKey: ['create', 'rotate'] as const, chatHub: ['manage', 'message'] as const, diff --git a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts index fe940f05d28..fb59d9e4b5c 100644 --- a/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts +++ b/packages/@n8n/permissions/src/roles/scopes/global-scopes.ee.ts @@ -123,6 +123,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [ 'dataTable:readColumn', 'dataTable:writeColumn', 'role:manage', + 'role:read', 'mcp:manage', 'mcp:oauth', 'mcpApiKey:create', diff --git a/packages/cli/src/controllers/__tests__/users.controller.test.ts b/packages/cli/src/controllers/__tests__/users.controller.test.ts index 3c6ff67fab2..f5fb91d925e 100644 --- a/packages/cli/src/controllers/__tests__/users.controller.test.ts +++ b/packages/cli/src/controllers/__tests__/users.controller.test.ts @@ -1,11 +1,14 @@ +import { GLOBAL_ADMIN_ROLE, GLOBAL_OWNER_ROLE } from '@n8n/db'; import type { AuthenticatedRequest, User, UserRepository } from '@n8n/db'; import { mock } from 'jest-mock-extended'; import type { Response } from 'express'; import type { EventService } from '@/events/event.service'; import type { JwtService } from '@/services/jwt.service'; +import type { UserService } from '@/services/user.service'; import type { ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee'; import type { UrlService } from '@/services/url.service'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { UsersController } from '../users.controller'; @@ -13,6 +16,7 @@ import { UsersController } from '../users.controller'; describe('UsersController', () => { const eventService = mock(); const userRepository = mock(); + const userService = mock(); const jwtService = mock(); const urlService = mock(); const provisioningService = mock(); @@ -24,7 +28,7 @@ describe('UsersController', () => { mock(), userRepository, mock(), - mock(), + userService, mock(), mock(), mock(), @@ -62,6 +66,43 @@ describe('UsersController', () => { publicApi: false, }); }); + + it('rejects an owner changing another owner, protecting the last owner', async () => { + const request = mock({ + user: { id: '123', role: { slug: GLOBAL_OWNER_ROLE.slug } }, + }); + provisioningService.isInstanceRoleManaged.mockResolvedValue(false); + userRepository.findOne.mockResolvedValue( + mock({ id: '456', role: { slug: GLOBAL_OWNER_ROLE.slug } }), + ); + + await expect( + controller.changeGlobalRole( + request, + mock(), + mock({ newRoleName: 'global:custom-role-abc' }), + '456', + ), + ).rejects.toThrow(ForbiddenError); + + expect(userService.changeUserRole).not.toHaveBeenCalled(); + }); + + it('rejects an admin changing an owner', async () => { + const request = mock({ + user: { id: '123', role: { slug: GLOBAL_ADMIN_ROLE.slug } }, + }); + provisioningService.isInstanceRoleManaged.mockResolvedValue(false); + userRepository.findOne.mockResolvedValue( + mock({ id: '456', role: { slug: GLOBAL_OWNER_ROLE.slug } }), + ); + + await expect( + controller.changeGlobalRole(request, mock(), mock({ newRoleName: 'global:admin' }), '456'), + ).rejects.toThrow(ForbiddenError); + + expect(userService.changeUserRole).not.toHaveBeenCalled(); + }); }); describe('generateInviteLink', () => { diff --git a/packages/cli/src/controllers/role.controller.ts b/packages/cli/src/controllers/role.controller.ts index caef31c3a3e..4d299b5acf1 100644 --- a/packages/cli/src/controllers/role.controller.ts +++ b/packages/cli/src/controllers/role.controller.ts @@ -3,10 +3,15 @@ import { RoleAssignmentsResponseDto, RoleGetQueryDto, RoleListQueryDto, + RoleMembersResponseDto, RoleProjectMembersResponseDto, UpdateRoleDto, } from '@n8n/api-types'; -import type { RoleAssignmentsResponse, RoleProjectMembersResponse } from '@n8n/api-types'; +import type { + RoleAssignmentsResponse, + RoleMembersResponse, + RoleProjectMembersResponse, +} from '@n8n/api-types'; import { LICENSE_FEATURES } from '@n8n/constants'; import { AuthenticatedRequest } from '@n8n/db'; import { @@ -71,6 +76,17 @@ export class RoleController { return RoleAssignmentsResponseDto.parse(result); } + @Get('/:slug/members') + @GlobalScope('role:read') + async getRoleMembers( + _req: AuthenticatedRequest, + _res: Response, + @Param('slug') slug: string, + ): Promise { + const result = await this.roleService.getRoleMembers(slug); + return RoleMembersResponseDto.parse(result); + } + @Get('/:slug') async getRoleBySlug( _req: AuthenticatedRequest, diff --git a/packages/cli/src/services/__tests__/user.service.test.ts b/packages/cli/src/services/__tests__/user.service.test.ts index 5254e210ca0..fe90298aa15 100644 --- a/packages/cli/src/services/__tests__/user.service.test.ts +++ b/packages/cli/src/services/__tests__/user.service.test.ts @@ -17,6 +17,7 @@ import { mock } from 'jest-mock-extended'; import { v4 as uuid } from 'uuid'; 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 { UrlService } from '@/services/url.service'; import { UserService } from '@/services/user.service'; @@ -296,6 +297,9 @@ describe('UserService', () => { describe('changeUserRole', () => { beforeEach(() => { jest.clearAllMocks(); + // The new license guard calls isRoleLicensed; default it to licensed so the + // existing branch tests below exercise the role-change logic, not the guard. + roleService.isRoleLicensed.mockReturnValue(true); manager.transaction.mockImplementation(async (arg1: unknown, arg2?: unknown) => { const runInTransaction = (arg2 ?? arg1) as ( entityManager: EntityManager, @@ -498,6 +502,38 @@ describe('UserService', () => { { role: { slug: PROJECT_OWNER_ROLE_SLUG } }, ); }); + + it('assigns a custom global role when it is licensed', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:member'; + roleService.checkRolesExist.mockResolvedValueOnce(); + + await userService.changeUserRole(user, { newRoleName: 'global:custom-role-abc' }); + + expect(roleService.isRoleLicensed).toHaveBeenCalledWith('global:custom-role-abc'); + expect(manager.update).toHaveBeenCalledWith( + User, + { id: user.id }, + { role: { slug: 'global:custom-role-abc' } }, + ); + }); + + it('rejects assigning a role that is not covered by the license', async () => { + const user = new User(); + user.id = uuid(); + user.role = new Role(); + user.role.slug = 'global:member'; + roleService.checkRolesExist.mockResolvedValueOnce(); + roleService.isRoleLicensed.mockReturnValueOnce(false); + + await expect( + userService.changeUserRole(user, { newRoleName: 'global:custom-role-abc' }), + ).rejects.toThrow(ForbiddenError); + + expect(manager.update).not.toHaveBeenCalled(); + }); }); describe('getInvitationIdsFromPayload', () => { diff --git a/packages/cli/src/services/role.service.ts b/packages/cli/src/services/role.service.ts index df1efa94f17..817d9df51a3 100644 --- a/packages/cli/src/services/role.service.ts +++ b/packages/cli/src/services/role.service.ts @@ -1,4 +1,8 @@ -import type { RoleAssignmentsResponse, RoleProjectMembersResponse } from '@n8n/api-types'; +import type { + RoleAssignmentsResponse, + RoleMembersResponse, + RoleProjectMembersResponse, +} from '@n8n/api-types'; import { CreateRoleDto, UpdateRoleDto } from '@n8n/api-types'; import { LicenseState, Logger } from '@n8n/backend-common'; import { @@ -61,6 +65,14 @@ export class RoleService { }; } + async getRoleMembers(slug: string): Promise { + const role = await this.roleRepository.findBySlug(slug); + if (!role) throw new NotFoundError('Role not found'); // 404 + if (role.roleType !== 'global') throw new BadRequestError('Role is not a global role'); // 400 + const members = await this.roleRepository.findUsersWithGlobalRole(role.slug); + return { members, total: members.length }; + } + async getAllRoles(withCount: boolean = false): Promise { const roles = await this.roleRepository.findAll(); diff --git a/packages/cli/src/services/user.service.ts b/packages/cli/src/services/user.service.ts index e4daae21099..2b631671ba5 100644 --- a/packages/cli/src/services/user.service.ts +++ b/packages/cli/src/services/user.service.ts @@ -14,6 +14,7 @@ import { import { Service } from '@n8n/di'; import { getGlobalScopes, + isBuiltInRole, PROJECT_ADMIN_ROLE_SLUG, PROJECT_OWNER_ROLE_SLUG, PROJECT_VIEWER_ROLE_SLUG, @@ -23,6 +24,7 @@ import type { IUserSettings } from 'n8n-workflow'; import { UserError } from 'n8n-workflow'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; +import { ForbiddenError } from '@/errors/response-errors/forbidden.error'; import { InternalServerError } from '@/errors/response-errors/internal-server.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { EventService } from '@/events/event.service'; @@ -321,6 +323,18 @@ export class UserService { // Check that new role exists await this.roleService.checkRolesExist([newRole.newRoleName], 'global'); + // Only custom roles are license-gated here; built-in roles are assignable on every + // entry point (SSO/SCIM provisioning, token exchange, REST). The REST endpoint + // separately gates advanced permissions for built-in admin. + if ( + !isBuiltInRole(newRole.newRoleName) && + !this.roleService.isRoleLicensed(newRole.newRoleName) + ) { + throw new ForbiddenError( + `The role "${newRole.newRoleName}" is not available in your current license.`, + ); + } + await this.userRepository.manager.transaction(async (trx) => { await trx.update(User, { id: user.id }, { role: { slug: newRole.newRoleName } }); diff --git a/packages/cli/test/integration/controllers/role.controller.test.ts b/packages/cli/test/integration/controllers/role.controller.test.ts index 4734b89b92d..d9222d7db5d 100644 --- a/packages/cli/test/integration/controllers/role.controller.test.ts +++ b/packages/cli/test/integration/controllers/role.controller.test.ts @@ -744,6 +744,52 @@ describe('RoleController', () => { }); }); + describe('GET /roles/:slug/members', () => { + it('should require authentication', async () => { + // + // ACT & ASSERT + // + await testServer.authlessAgent.get('/roles/global:admin/members').expect(401); + }); + + it('should require role:read permission', async () => { + // + // ACT & ASSERT + // + await memberAgent.get('/roles/global:admin/members').expect(403); + }); + + it('should return the role members for a holder of role:read', async () => { + // + // ARRANGE + // + const mockResponse = { + members: [ + { + userId: 'user-1', + firstName: 'Ada', + lastName: 'Lovelace', + email: 'ada@example.com', + role: 'global:admin', + }, + ], + total: 1, + }; + roleService.getRoleMembers.mockResolvedValue(mockResponse); + + // + // ACT + // + const response = await ownerAgent.get('/roles/global:admin/members').expect(200); + + // + // ASSERT + // + expect(response.body).toEqual({ data: mockResponse }); + expect(roleService.getRoleMembers).toHaveBeenCalledTimes(1); + }); + }); + describe('POST /roles', () => { it('should require authentication', async () => { // diff --git a/packages/cli/test/integration/database/repositories/role.repository.test.ts b/packages/cli/test/integration/database/repositories/role.repository.test.ts index 31dc1987ef3..3b969e24134 100644 --- a/packages/cli/test/integration/database/repositories/role.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/role.repository.test.ts @@ -599,6 +599,72 @@ describe('RoleRepository', () => { }); }); + describe('findUsersWithGlobalRole()', () => { + beforeEach(async () => { + // default roles are needed for user creation (personal project owner role) + await Container.get(AuthRolesService).init(); + }); + + it('returns only the users that hold the given global role, with public fields', async () => { + // + // ARRANGE + // + const globalRole = await createRole({ + slug: 'global-members-role', + displayName: 'Global Members Role', + roleType: 'global', + }); + const otherRole = await createRole({ + slug: 'other-members-role', + displayName: 'Other Global Role', + roleType: 'global', + }); + + const user1 = await createUser({ role: globalRole }); + const user2 = await createUser({ role: globalRole }); + await createUser({ role: otherRole }); + + // + // ACT + // + const members = await roleRepository.findUsersWithGlobalRole(globalRole.slug); + + // + // ASSERT + // + expect(members).toHaveLength(2); + expect(members).toEqual( + expect.arrayContaining( + [user1, user2].map((u) => + expect.objectContaining({ + userId: u.id, + firstName: u.firstName, + lastName: u.lastName, + email: u.email, + role: globalRole.slug, + }), + ), + ), + ); + }); + + it('returns an empty array when no users hold the global role', async () => { + // + // ARRANGE + // + const globalRole = await createRole({ + slug: 'global-empty-members-role', + displayName: 'Global Empty Members Role', + roleType: 'global', + }); + + // + // ACT & ASSERT + // + expect(await roleRepository.findUsersWithGlobalRole(globalRole.slug)).toEqual([]); + }); + }); + describe('countUsersWithRole()', () => { beforeEach(async () => { // make sure to initalize the default roles for user creation diff --git a/packages/cli/test/integration/public-api/users.test.ts b/packages/cli/test/integration/public-api/users.test.ts index ac8365fe5b0..a9b7a21ce16 100644 --- a/packages/cli/test/integration/public-api/users.test.ts +++ b/packages/cli/test/integration/public-api/users.test.ts @@ -490,6 +490,7 @@ describe('Users in Public API', () => { * Arrange */ testServer.license.enable('feat:advancedPermissions'); + testServer.license.enable('feat:customRoles'); const owner = await createOwnerWithApiKey(); const member = await createMember(); const customRole = 'custom:role'; diff --git a/packages/cli/test/integration/services/role.service.test.ts b/packages/cli/test/integration/services/role.service.test.ts index 6f578d6ebf1..f27516bd013 100644 --- a/packages/cli/test/integration/services/role.service.test.ts +++ b/packages/cli/test/integration/services/role.service.test.ts @@ -19,7 +19,7 @@ import { createTestScopes, cleanupRolesAndScopes, } from '../shared/db/roles'; -import { createMember } from '../shared/db/users'; +import { createMember, createUser } from '../shared/db/users'; let roleService: RoleService; let roleRepository: RoleRepository; @@ -490,6 +490,56 @@ describe('RoleService', () => { }); }); + describe('getRoleMembers', () => { + it('should return members and total for a global role', async () => { + // + // ARRANGE + // + const globalRole = await createRole({ + roleType: 'global', + displayName: 'Global Members Role', + }); + const user1 = await createUser({ role: globalRole }); + const user2 = await createUser({ role: globalRole }); + + // + // ACT + // + const result = await roleService.getRoleMembers(globalRole.slug); + + // + // ASSERT + // + expect(result.total).toBe(2); + expect(result.members).toEqual( + expect.arrayContaining( + [user1, user2].map((u) => + expect.objectContaining({ userId: u.id, email: u.email, role: globalRole.slug }), + ), + ), + ); + }); + + it('should throw NotFoundError when the role does not exist', async () => { + // + // ACT & ASSERT + // + await expect(roleService.getRoleMembers('non-existent-role')).rejects.toThrow(NotFoundError); + }); + + it('should throw BadRequestError when the role is not a global role', async () => { + // + // ARRANGE + // + const projectRole = await createRole({ roleType: 'project' }); + + // + // ACT & ASSERT + // + await expect(roleService.getRoleMembers(projectRole.slug)).rejects.toThrow(BadRequestError); + }); + }); + describe('getRole with usage counting', () => { it('should return role without usage count when withCount=false', async () => { // diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 93b9533d7d1..ca0ba498798 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -1716,6 +1716,7 @@ describe('PATCH /users/:id/role', () => { }); test('should change to existing custom role', async () => { + testServer.license.enable('feat:customRoles'); const customRole = 'custom:role'; await createRole({ slug: customRole, displayName: 'Custom Role 1', roleType: 'global' }); const response = await ownerAgent.patch(`/users/${member.id}/role`).send({