fix sanctions and admin role escalation
This commit is contained in:
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createGatewayApiContext } from '../src/context.js';
|
||||
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
|
||||
import { appRouter } from '../src/router.js';
|
||||
import type { GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
||||
import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
|
||||
|
||||
const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: number } = {}) => {
|
||||
@@ -223,6 +223,90 @@ describe('gateway auth flow', () => {
|
||||
expect(login.user.username).toBe('local-user');
|
||||
});
|
||||
|
||||
it('blocks password login while a ban is active and allows it after expiry', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller();
|
||||
await caller.auth.registerLocal({
|
||||
username: 'banned-user',
|
||||
credential: sealPassword('banned-password'),
|
||||
displayName: '차단유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
const user = await users.findByUsername('banned-user');
|
||||
expect(user).not.toBeNull();
|
||||
await users.updateSanctions(user!.id, {
|
||||
bannedUntil: '2099-01-01T00:00:00.000Z',
|
||||
});
|
||||
|
||||
await expect(
|
||||
caller.auth.login({
|
||||
username: 'banned-user',
|
||||
credential: sealPassword('banned-password'),
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
|
||||
await users.updateSanctions(user!.id, {
|
||||
bannedUntil: '2000-01-01T00:00:00.000Z',
|
||||
});
|
||||
await expect(
|
||||
caller.auth.login({
|
||||
username: 'banned-user',
|
||||
credential: sealPassword('banned-password'),
|
||||
})
|
||||
).resolves.toMatchObject({ user: { username: 'banned-user' } });
|
||||
});
|
||||
|
||||
const gameSessionRestrictionCases: Array<{ label: string; sanctions: UserSanctions }> = [
|
||||
{
|
||||
label: 'global suspension',
|
||||
sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' },
|
||||
},
|
||||
{
|
||||
label: 'profile login restriction',
|
||||
sanctions: {
|
||||
serverRestrictions: {
|
||||
'che:default': {
|
||||
blockedFeatures: ['login'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'base profile gameplay restriction',
|
||||
sanctions: {
|
||||
serverRestrictions: {
|
||||
che: {
|
||||
blockedFeatures: ['gameplay'],
|
||||
until: '2099-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
it.each(gameSessionRestrictionCases)('blocks game-session issuance for $label', async ({ sanctions }) => {
|
||||
const { caller, users, sealPassword } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: `restricted-${Object.keys(sanctions)[0]}`,
|
||||
credential: sealPassword('restricted-password'),
|
||||
displayName: '제한유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
const user = await users.findByUsername(register.user.username);
|
||||
expect(user).not.toBeNull();
|
||||
await users.updateSanctions(user!.id, sanctions);
|
||||
|
||||
await expect(
|
||||
caller.auth.issueGameSession({
|
||||
sessionToken: register.sessionToken,
|
||||
profile: 'che:default',
|
||||
})
|
||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||
});
|
||||
|
||||
it('blocks pre-verification general creation on che but grants the hwe grace period', async () => {
|
||||
const { caller, sealPassword, setSessionHeader } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
@@ -324,6 +408,43 @@ describe('gateway auth flow', () => {
|
||||
expect(stored?.kakaoVerifiedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('blocks Kakao login while a ban is active', async () => {
|
||||
const { caller, users, sealPassword, setSessionHeader } = buildCaller();
|
||||
const register = await caller.auth.registerLocal({
|
||||
username: 'kakao-banned-user',
|
||||
credential: sealPassword('kakao-banned-password'),
|
||||
displayName: '카카오제재유저',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
setSessionHeader(register.sessionToken);
|
||||
const verifyStart = await caller.auth.kakaoStart({ mode: 'verify' });
|
||||
await caller.auth.kakaoExchange({
|
||||
code: 'oauth-code',
|
||||
state: verifyStart.state,
|
||||
});
|
||||
|
||||
const stored = await users.findByUsername('kakao-banned-user');
|
||||
expect(stored).not.toBeNull();
|
||||
if (stored) {
|
||||
await users.updateSanctions(stored.id, {
|
||||
bannedUntil: new Date(Date.now() + 60_000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const loginStart = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
await expect(
|
||||
caller.auth.kakaoExchange({
|
||||
code: 'oauth-code',
|
||||
state: loginStart.state,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Account login is blocked.',
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the bootstrap superuser role into game sessions', async () => {
|
||||
const previousToken = process.env.GATEWAY_BOOTSTRAP_TOKEN;
|
||||
process.env.GATEWAY_BOOTSTRAP_TOKEN = 'bootstrap-test-token';
|
||||
|
||||
Reference in New Issue
Block a user