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]>
149 lines
4.4 KiB
TypeScript
149 lines
4.4 KiB
TypeScript
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);
|
|
});
|
|
});
|