feat(core): Allow user role modification (#7797)

https://linear.app/n8n/issue/PAY-985

```
PATCH /users/:id/role
  unauthenticated user
    ✓ should receive 401 (349 ms)
  member
    ✓ should fail to demote owner to member (349 ms)
    ✓ should fail to demote owner to admin (359 ms)
    ✓ should fail to demote admin to member (381 ms)
    ✓ should fail to promote other member to owner (353 ms)
    ✓ should fail to promote other member to admin (377 ms)
    ✓ should fail to promote self to admin (354 ms)
    ✓ should fail to promote self to owner (371 ms)
  admin
    ✓ should receive 400 on invalid payload (351 ms)
    ✓ should receive 404 on unknown target user (351 ms)
    ✓ should fail to demote owner to admin (349 ms)
    ✓ should fail to demote owner to member (347 ms)
    ✓ should fail to promote member to owner (384 ms)
    ✓ should fail to promote admin to owner (350 ms)
    ✓ should be able to demote admin to member (354 ms)
    ✓ should be able to demote self to member (350 ms)
    ✓ should be able to promote member to admin (349 ms)
  owner
    ✓ should be able to promote member to admin (349 ms)
    ✓ should be able to demote admin to member (349 ms)
    ✓ should fail to demote self to admin (348 ms)
    ✓ should fail to demote self to member (354 ms)
```
This commit is contained in:
Iván Ovejero
2023-11-24 11:40:08 +01:00
committed by GitHub
parent 87fa3c2985
commit 7a86d36068
7 changed files with 384 additions and 19 deletions

View File

@@ -4,7 +4,7 @@ import { User } from '@db/entities/User';
import { SharedCredentials } from '@db/entities/SharedCredentials';
import { SharedWorkflow } from '@db/entities/SharedWorkflow';
import { Authorized, Delete, Get, RestController, Patch } from '@/decorators';
import { BadRequestError, NotFoundError } from '@/ResponseHelper';
import { BadRequestError, NotFoundError, UnauthorizedError } from '@/ResponseHelper';
import { ListQuery, UserRequest, UserSettingsUpdatePayload } from '@/requests';
import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner';
import { IExternalHooksClass, IInternalHooksClass } from '@/Interfaces';
@@ -18,7 +18,7 @@ import { UserService } from '@/services/user.service';
import { listQueryMiddleware } from '@/middlewares';
import { Logger } from '@/Logger';
@Authorized(['global', 'owner'])
@Authorized()
@RestController('/users')
export class UsersController {
constructor(
@@ -32,6 +32,18 @@ export class UsersController {
private readonly userService: UserService,
) {}
static ERROR_MESSAGES = {
CHANGE_ROLE: {
NO_MEMBER: 'Member cannot change role for any user',
MISSING_NEW_ROLE_KEY: 'Expected `newRole` to exist',
MISSING_NEW_ROLE_VALUE: 'Expected `newRole` to have `name` and `scope`',
NO_USER: 'Target user not found',
NO_ADMIN_ON_OWNER: 'Admin cannot change role on global owner',
NO_OWNER_ON_OWNER: 'Owner cannot change role on global owner',
NO_ADMIN_TO_OWNER: 'Admin cannot promote user to global owner',
},
} as const;
private async toFindManyOptions(listQueryOptions?: ListQuery.Options) {
const findManyOptions: FindManyOptions<User> = {};
@@ -70,7 +82,7 @@ export class UsersController {
return findManyOptions;
}
removeSupplementaryFields(
private removeSupplementaryFields(
publicUsers: Array<Partial<PublicUser>>,
listQueryOptions: ListQuery.Options,
) {
@@ -152,6 +164,7 @@ export class UsersController {
/**
* Delete a user. Optionally, designate a transferee for their workflows and credentials.
*/
@Authorized(['global', 'owner'])
@Delete('/:id')
async deleteUser(req: UserRequest.Delete) {
const { id: idToDelete } = req.params;
@@ -306,4 +319,75 @@ export class UsersController {
await this.externalHooks.run('user.deleted', [await this.userService.toPublic(userToDelete)]);
return { success: true };
}
// @TODO: Add scope check `@RequireGlobalScope('user:changeRole')`
// once this has been merged: https://github.com/n8n-io/n8n/pull/7737
@Authorized('any')
@Patch('/:id/role')
async changeRole(req: UserRequest.ChangeRole) {
const {
NO_MEMBER,
MISSING_NEW_ROLE_KEY,
MISSING_NEW_ROLE_VALUE,
NO_ADMIN_ON_OWNER,
NO_ADMIN_TO_OWNER,
NO_USER,
NO_OWNER_ON_OWNER,
} = UsersController.ERROR_MESSAGES.CHANGE_ROLE;
if (req.user.globalRole.scope === 'global' && req.user.globalRole.name === 'member') {
throw new UnauthorizedError(NO_MEMBER);
}
const { newRole } = req.body;
if (!newRole) {
throw new BadRequestError(MISSING_NEW_ROLE_KEY);
}
if (!newRole.name || !newRole.scope) {
throw new BadRequestError(MISSING_NEW_ROLE_VALUE);
}
if (
req.user.globalRole.scope === 'global' &&
req.user.globalRole.name === 'admin' &&
newRole.scope === 'global' &&
newRole.name === 'owner'
) {
throw new UnauthorizedError(NO_ADMIN_TO_OWNER);
}
const targetUser = await this.userService.findOne({
where: { id: req.params.id },
});
if (targetUser === null) {
throw new NotFoundError(NO_USER);
}
if (
req.user.globalRole.scope === 'global' &&
req.user.globalRole.name === 'admin' &&
targetUser.globalRole.scope === 'global' &&
targetUser.globalRole.name === 'owner'
) {
throw new UnauthorizedError(NO_ADMIN_ON_OWNER);
}
if (
req.user.globalRole.scope === 'global' &&
req.user.globalRole.name === 'owner' &&
targetUser.globalRole.scope === 'global' &&
targetUser.globalRole.name === 'owner'
) {
throw new UnauthorizedError(NO_OWNER_ON_OWNER);
}
const roleToSet = await this.roleService.findCached(newRole.scope, newRole.name);
await this.userService.update(targetUser.id, { globalRole: roleToSet });
return { success: true };
}
}