feat(API): List members of a global instance role (#32836)

This commit is contained in:
Andreas Fitzek
2026-06-24 15:11:53 +02:00
committed by GitHub
parent a5ed0fe17c
commit cb8dd25946
18 changed files with 338 additions and 15 deletions
+5
View File
@@ -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';
@@ -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(),
});
@@ -0,0 +1,11 @@
import { z } from 'zod';
import { roleMemberSchema } from './role-member.schema';
import { Z } from '../../zod-class';
export type RoleMember = z.infer<typeof roleMemberSchema>;
export class RoleMembersResponseDto extends Z.class({
members: z.array(roleMemberSchema),
total: z.number(),
}) {}
export type RoleMembersResponse = InstanceType<typeof RoleMembersResponseDto>;
@@ -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<typeof roleProjectMemberSchema>;
export type RoleProjectMember = z.infer<typeof roleMemberSchema>;
export class RoleProjectMembersResponseDto extends Z.class({
members: z.array(roleProjectMemberSchema),
members: z.array(roleMemberSchema),
}) {}
export type RoleProjectMembersResponse = InstanceType<typeof RoleProjectMembersResponseDto>;
@@ -126,6 +126,26 @@ export class RoleRepository extends Repository<Role> {
.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,
@@ -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",
@@ -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,
@@ -123,6 +123,7 @@ export const GLOBAL_OWNER_SCOPES: Scope[] = [
'dataTable:readColumn',
'dataTable:writeColumn',
'role:manage',
'role:read',
'mcp:manage',
'mcp:oauth',
'mcpApiKey:create',
@@ -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<EventService>();
const userRepository = mock<UserRepository>();
const userService = mock<UserService>();
const jwtService = mock<JwtService>();
const urlService = mock<UrlService>();
const provisioningService = mock<ProvisioningService>();
@@ -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<AuthenticatedRequest>({
user: { id: '123', role: { slug: GLOBAL_OWNER_ROLE.slug } },
});
provisioningService.isInstanceRoleManaged.mockResolvedValue(false);
userRepository.findOne.mockResolvedValue(
mock<User>({ 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<AuthenticatedRequest>({
user: { id: '123', role: { slug: GLOBAL_ADMIN_ROLE.slug } },
});
provisioningService.isInstanceRoleManaged.mockResolvedValue(false);
userRepository.findOne.mockResolvedValue(
mock<User>({ 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', () => {
@@ -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<RoleMembersResponse> {
const result = await this.roleService.getRoleMembers(slug);
return RoleMembersResponseDto.parse(result);
}
@Get('/:slug')
async getRoleBySlug(
_req: AuthenticatedRequest,
@@ -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', () => {
+13 -1
View File
@@ -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<RoleMembersResponse> {
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<RoleDTO[]> {
const roles = await this.roleRepository.findAll();
+14
View File
@@ -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 } });
@@ -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 () => {
//
@@ -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
@@ -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';
@@ -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 () => {
//
@@ -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({