mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
feat(API): List members of a global instance role (#32836)
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
Reference in New Issue
Block a user