fix sanctions and admin role escalation

This commit is contained in:
2026-07-26 18:11:01 +00:00
parent 3fb75ccf03
commit ab6ed3553a
13 changed files with 591 additions and 51 deletions
+154 -6
View File
@@ -10,20 +10,25 @@ import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.
import { appRouter } from '../src/router.js';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
const buildCaller = async (createOperation: GatewayProfileRepository['createOperation']) => {
const buildCaller = async (
createOperation: GatewayProfileRepository['createOperation'],
options: { adminRoles?: string[]; firstUserIsAdmin?: boolean } = {}
) => {
const users = createInMemoryUserRepository();
const admin = await users.createUser({
username: 'admin',
password: 'secretpass',
displayName: 'Admin',
});
await users.updateRoles(admin.id, ['superuser']);
const adminRoles = options.adminRoles ?? ['superuser'];
await users.updateRoles(admin.id, adminRoles);
const sessions = new InMemoryGatewaySessionService({
sessionTtlSeconds: 600,
gameSessionTtlSeconds: 600,
});
const session = await sessions.createSession({ ...admin, roles: ['superuser'] });
const session = await sessions.createSession({ ...admin, roles: adminRoles });
const createdInputs: GatewayOperationCreateInput[] = [];
const flushes: Array<{ userId: string; reason?: string }> = [];
const profile = {
profileName: 'che:2',
profile: 'che',
@@ -68,7 +73,11 @@ const buildCaller = async (createOperation: GatewayProfileRepository['createOper
createGatewayApiContext({
users,
sessions,
flushPublisher: { publishUserFlush: async () => {} },
flushPublisher: {
publishUserFlush: async (userId, reason) => {
flushes.push({ userId, reason });
},
},
gameTokenSecret: 'test-secret',
gameSessionTtlSeconds: 600,
kakaoClient: {} as never,
@@ -93,12 +102,12 @@ const buildCaller = async (createOperation: GatewayProfileRepository['createOper
requestHeaders: { 'x-session-token': session.sessionToken },
prisma: {
appUser: {
findFirst: async () => ({ id: admin.id }),
findFirst: async () => ({ id: options.firstUserIsAdmin === false ? 'bootstrap-user' : admin.id }),
},
} as unknown as GatewayPrismaClient,
})
);
return { caller, createdInputs };
return { caller, createdInputs, users, admin, flushes };
};
describe('admin operation API', () => {
@@ -142,3 +151,142 @@ describe('admin operation API', () => {
).rejects.toMatchObject({ code: 'CONFLICT' });
});
});
describe('admin role non-escalation', () => {
const unusedCreateOperation: GatewayProfileRepository['createOperation'] = async () => {
throw new Error('not used');
};
it('allows a scoped administrator to grant only the same scoped role', async () => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.users.manage', 'admin.survey.open:che:default'],
firstUserIsAdmin: false,
});
const target = await harness.users.createUser({
username: 'target-user',
password: 'secretpass',
displayName: 'Target',
});
await expect(
harness.caller.admin.users.updateRoles({
userId: target.id,
roles: ['admin.survey.open:che:default'],
mode: 'grant',
})
).resolves.toMatchObject({
roles: ['user', 'admin.survey.open:che:default'],
});
});
it.each(['admin.survey.open:*', 'admin.survey.open:hwe:default', 'superuser', 'admin'])(
'rejects granting a broader or root role: %s',
async (role) => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.users.manage', 'admin.survey.open:che:default'],
firstUserIsAdmin: false,
});
const target = await harness.users.createUser({
username: `target-${role.replaceAll(/[^a-z]/g, '-')}`,
password: 'secretpass',
displayName: 'Target',
});
await expect(
harness.caller.admin.users.updateRoles({
userId: target.id,
roles: [role],
mode: 'grant',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
}
);
it('rejects set mode when it would remove a role outside the caller scope', async () => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.users.manage'],
firstUserIsAdmin: false,
});
const target = await harness.users.createUser({
username: 'privileged-target',
password: 'secretpass',
displayName: 'Privileged Target',
});
await harness.users.updateRoles(target.id, ['user', 'admin.survey.open:*']);
await expect(
harness.caller.admin.users.updateRoles({
userId: target.id,
roles: ['user'],
mode: 'set',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('rejects self-escalation to a broader wildcard scope', async () => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.users.manage', 'admin.survey.open:che:default'],
firstUserIsAdmin: false,
});
await expect(
harness.caller.admin.users.updateRoles({
userId: harness.admin.id,
roles: ['admin.survey.open:*'],
mode: 'grant',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect((await harness.users.findById(harness.admin.id))?.roles).toEqual([
'user',
'admin.users.manage',
'admin.survey.open:che:default',
]);
});
it('allows a superuser to change root roles', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'admin-target',
password: 'secretpass',
displayName: 'Admin Target',
});
await expect(
harness.caller.admin.users.updateRoles({
userId: target.id,
roles: ['superuser'],
mode: 'grant',
})
).resolves.toMatchObject({ roles: ['user', 'superuser'] });
});
it('flushes active sessions after role and sanction changes', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'flush-target',
password: 'secretpass',
displayName: 'Flush Target',
});
await harness.caller.admin.users.updateRoles({
userId: target.id,
roles: ['admin.survey.open:che:default'],
mode: 'grant',
});
await harness.caller.admin.users.updateSanctions({
userId: target.id,
patch: { suspendedUntil: '2099-01-01T00:00:00.000Z' },
});
await harness.caller.admin.users.setServerRestriction({
userId: target.id,
profile: 'che:default',
restriction: { blockedFeatures: ['login'] },
});
expect(harness.flushes).toEqual([
{ userId: target.id, reason: 'admin-roles-updated' },
{ userId: target.id, reason: 'admin-sanctions-updated' },
{ userId: target.id, reason: 'admin-server-restriction' },
]);
});
});