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

@@ -0,0 +1,148 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
/**
* A limited "user manager" may look after ordinary accounts but must never
* take over - or hand out - an account that can do more than they can.
*/
describe('User management cannot escalate (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let managerToken: string;
let adminId: string;
let beekeeperId: string;
let adminRoleId: string;
const server = () => app.getHttpServer();
const asManager = (req: request.Test) =>
req.set('Authorization', `Bearer ${managerToken}`);
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const managerRole = await prisma.role.create({
data: {
key: `USER_MANAGER_${suffix}`,
name: 'Benutzerpflege',
permissions: {
create: [
'users:read',
'users:create',
'users:update',
'users:deactivate',
'users:assign-role',
].map((key) => ({ permission: { connect: { key } } })),
},
},
});
await prisma.user.create({
data: {
email: `manager-${suffix}@example.com`,
passwordHash,
name: 'Manager',
roleId: managerRole.id,
},
});
const admin = await prisma.user.create({
data: {
email: `victim-admin-${suffix}@example.com`,
passwordHash,
name: 'Admin',
role: { connect: { key: 'ADMIN' } },
},
});
adminId = admin.id;
adminRoleId = admin.roleId;
// A second admin, so "last admin" protection is not what blocks us.
await prisma.user.create({
data: {
email: `other-admin-${suffix}@example.com`,
passwordHash,
name: 'Admin 2',
role: { connect: { key: 'ADMIN' } },
},
});
const narrowRole = await prisma.role.create({
data: {
key: `USER_READER_${suffix}`,
name: 'Benutzer ansehen',
permissions: {
create: [{ permission: { connect: { key: 'users:read' } } }],
},
},
});
const narrow = await prisma.user.create({
data: {
email: `narrow-${suffix}@example.com`,
passwordHash,
name: 'Leser',
roleId: narrowRole.id,
},
});
beekeeperId = narrow.id;
managerToken = (await login(app, `manager-${suffix}@example.com`, password))
.accessToken;
});
afterAll(async () => {
await app.close();
});
it('cannot reset the password of a more privileged user', async () => {
await asManager(
request(server()).post(`/api/v1/users/${adminId}/reset-password`),
)
.send({ password: 'Hijacked-Password-1!' })
.expect(403);
});
it('cannot reset the MFA of a more privileged user', async () => {
await asManager(
request(server()).post(`/api/v1/users/${adminId}/mfa/reset`),
).expect(403);
});
it('cannot change the email of a more privileged user', async () => {
await asManager(request(server()).patch(`/api/v1/users/${adminId}`))
.send({ email: `attacker-${suffix}@example.com` })
.expect(403);
});
it('cannot deactivate a more privileged user', async () => {
await asManager(
request(server()).post(`/api/v1/users/${adminId}/deactivate`),
).expect(403);
});
it('cannot hand out a role with more permissions than they hold', async () => {
await asManager(request(server()).patch(`/api/v1/users/${beekeeperId}`))
.send({ roleId: adminRoleId })
.expect(403);
await asManager(request(server()).post('/api/v1/users'))
.send({
email: `new-admin-${suffix}@example.com`,
password: 'AnotherSecret123!',
name: 'Neuer Admin',
roleId: adminRoleId,
})
.expect(403);
});
it('can still look after a less privileged user', async () => {
await asManager(
request(server()).post(`/api/v1/users/${beekeeperId}/reset-password`),
)
.send({ password: 'Fresh-Password-12!' })
.expect(201);
});
});