fix(users): never manage or hand out more than the caller holds

A role with users:update could reset an administrator's password or MFA,
change the admin's email to one it controls, or give a colluding account
the ADMIN role (with users:assign-role) - and so become administrator.

Every management action (update, deactivate, activate, reset password,
reset MFA) and every role assignment now requires the target role's grants
to be a subset of the caller's own, like role editing already did. The
target role is read from its grants, so a deactivated admin still counts
as an admin. Changing another user's email revokes their sessions.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 01:16:37 +02:00
1 parent e0bd7b2095
commit c4a11b4704
3 files changed
+222 -3

No files matched your search

@@ -22,12 +22,14 @@ describe('UsersService', () => {
findUnique: jest.Mock;
};
refreshToken: { updateMany: jest.Mock };
rolePermission: { findMany: jest.Mock };
};
let auditLog: { record: jest.Mock };
let permissionResolver: {
invalidateUser: jest.Mock;
invalidateAll: jest.Mock;
hasAny: jest.Mock;
getPermissions: jest.Mock;
};
const staffRole = {
@@ -79,12 +81,14 @@ describe('UsersService', () => {
findUnique: jest.fn().mockResolvedValue(staffRole),
},
refreshToken: { updateMany: jest.fn() },
rolePermission: { findMany: jest.fn().mockResolvedValue([]) },
};
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
permissionResolver = {
invalidateUser: jest.fn(),
invalidateAll: jest.fn(),
hasAny: jest.fn().mockResolvedValue(true),
getPermissions: jest.fn().mockResolvedValue(new Set<string>()),
};
usersService = new UsersService(
prisma as unknown as PrismaService,
@@ -430,4 +434,23 @@ describe('UsersService', () => {
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
it('refuses to reset the password of an account with more permissions than the caller', async () => {
prisma.user.findUnique.mockResolvedValue(adminUser);
prisma.rolePermission.findMany.mockResolvedValue([
{ permission: { key: 'backup:import' } },
]);
permissionResolver.getPermissions.mockResolvedValue(
new Set(['users:update']),
);
await expect(
usersService.resetPassword(
adminUser.id,
{ password: 'Hijacked-Password-1!' },
existingUser.id,
),
).rejects.toBeInstanceOf(ForbiddenException);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
+51 -3
View File
@@ -78,6 +78,38 @@ export class UsersService {
}
}
/**
* Nobody may act on - or hand out - more than they can do themselves: a
* limited "user manager" holding users:update could otherwise reset an
* administrator's password or MFA, change their email to one they control,
* or give a colluding account the ADMIN role, and so become admin. Same
* rule as RolesService.assertCanGrantPermissions. Reads the role's grants
* directly (not the cached effective set), so a deactivated admin counts
* as an admin too.
*/
private async assertWithinCallerPermissions(
roleId: string,
actingUserId?: string,
): Promise<void> {
if (!actingUserId) {
return;
}
const grants = await this.prisma.rolePermission.findMany({
where: { roleId },
select: { permission: { select: { key: true } } },
});
const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId);
const exceeds = grants.some(
(grant) => !callerPermissions.has(grant.permission.key),
);
if (exceeds) {
throw new ForbiddenException(
'Dieses Konto hat mehr Rechte als Sie selbst und kann von Ihnen nicht verwaltet werden',
);
}
}
private async assertActiveRole(roleId: string): Promise<void> {
const role = await this.prisma.role.findUnique({ where: { id: roleId } });
if (!role) {
@@ -151,6 +183,7 @@ export class UsersService {
}
await this.assertActiveRole(dto.roleId);
await this.assertCanAssignRole(actingUserId);
await this.assertWithinCallerPermissions(dto.roleId, actingUserId);
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
const user = await this.prisma.user.create({
data: {
@@ -177,7 +210,9 @@ export class UsersService {
actingUserId?: string,
): Promise<UserSummary> {
const before = await this.findById(id);
await this.assertWithinCallerPermissions(before.roleId, actingUserId);
const roleChanged = Boolean(dto.roleId) && dto.roleId !== before.roleId;
const emailChanged = dto.email !== undefined && dto.email !== before.email;
if (dto.roleId) {
await this.assertActiveRole(dto.roleId);
}
@@ -189,6 +224,7 @@ export class UsersService {
throw new ForbiddenException('Cannot change your own role');
}
await this.assertCanAssignRole(actingUserId);
await this.assertWithinCallerPermissions(dto.roleId!, actingUserId);
await this.assertNotLastSystemRoleUser(id);
}
const user = await this.prisma.user.update({
@@ -200,7 +236,15 @@ export class UsersService {
},
...USER_WITH_ROLE,
});
if (roleChanged) {
if (emailChanged) {
// The email is the login name and the SSO link: sessions opened under
// the old one end here.
await this.prisma.refreshToken.updateMany({
where: { userId: id, revokedAt: null },
data: { revokedAt: new Date() },
});
}
if (roleChanged || emailChanged) {
this.permissionResolver.invalidateUser(id);
}
await this.auditLog.record({
@@ -216,6 +260,7 @@ export class UsersService {
async deactivate(id: string, actingUserId?: string): Promise<UserSummary> {
const before = await this.findById(id);
await this.assertWithinCallerPermissions(before.roleId, actingUserId);
await this.assertNotLastSystemRoleUser(id);
const user = await this.prisma.user.update({
where: { id },
@@ -262,7 +307,8 @@ export class UsersService {
'Use /profile/password to change your own password',
);
}
await this.findById(id);
const target = await this.findById(id);
await this.assertWithinCallerPermissions(target.roleId, actingUserId);
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_SALT_ROUNDS);
const user = await this.prisma.user.update({
where: { id },
@@ -291,7 +337,8 @@ export class UsersService {
'Use /profile/mfa/disable to disable your own MFA',
);
}
await this.findById(id);
const target = await this.findById(id);
await this.assertWithinCallerPermissions(target.roleId, actingUserId);
const user = await this.prisma.user.update({
where: { id },
data: { mfaEnabled: false, mfaSecret: null },
@@ -308,6 +355,7 @@ export class UsersService {
async activate(id: string, actingUserId?: string): Promise<UserSummary> {
const before = await this.findById(id);
await this.assertWithinCallerPermissions(before.roleId, actingUserId);
if (before.isActive) {
throw new ConflictException('User is already active');
}