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
+55 -2
View File
@@ -130,6 +130,56 @@ const hasScopedPermission = (adminAuth: AdminAuthContext, permission: string, pr
return adminAuth.roles.some((role: string) => roleMatchesScope(role, permission, profileName));
};
const splitRoleScope = (role: string): { permission: string; scope?: string } => {
const separator = role.indexOf(':');
if (separator < 0) {
return { permission: role };
}
return {
permission: role.slice(0, separator),
scope: role.slice(separator + 1),
};
};
const isRootAdminRole = (role: string): boolean =>
role === ROLE_SUPERUSER || role === 'admin' || role === ADMIN_ROLE_SUPERUSER;
const canManageRole = (adminAuth: AdminAuthContext, role: string): boolean => {
if (adminAuth.isSuperuser) {
return true;
}
if (isRootAdminRole(role)) {
return false;
}
const target = splitRoleScope(role);
return adminAuth.roles.some((callerRole) => {
const caller = splitRoleScope(callerRole);
if (caller.permission !== target.permission) {
return false;
}
return caller.scope === undefined || caller.scope === '*' || caller.scope === target.scope;
});
};
const assertRoleChangesAllowed = (
adminAuth: AdminAuthContext,
currentRoles: ReadonlySet<string>,
nextRoles: ReadonlySet<string>
): void => {
const changedRoles = new Set([...currentRoles, ...nextRoles]);
for (const role of changedRoles) {
if (currentRoles.has(role) === nextRoles.has(role)) {
continue;
}
if (!canManageRole(adminAuth, role)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: `Role change exceeds caller scope: ${role}`,
});
}
}
};
const assertPermission = (adminAuth: AdminAuthContext, permission: string, profileName?: string): void => {
if (hasScopedPermission(adminAuth, permission, profileName)) {
return;
@@ -465,7 +515,7 @@ export const adminRouter = router({
.input(
z.object({
userId: z.string().min(1),
roles: z.array(z.string().min(1)).min(1),
roles: z.array(z.string().trim().min(1).max(128)).min(1),
mode: zUserRoleMode.optional(),
})
)
@@ -478,7 +528,8 @@ export const adminRouter = router({
});
}
const mode = input.mode ?? 'set';
const roles = new Set(user.roles);
const currentRoles = new Set(user.roles);
const roles = new Set(currentRoles);
if (mode === 'set') {
roles.clear();
for (const role of input.roles) {
@@ -493,6 +544,8 @@ export const adminRouter = router({
roles.delete(role);
}
}
const adminAuth = requireAdminAuth(ctx);
assertRoleChangesAllowed(adminAuth, currentRoles, roles);
const nextRoles = Array.from(roles);
await ctx.users.updateRoles(input.userId, nextRoles);
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-roles-updated');
+25
View File
@@ -5,6 +5,7 @@ import { addHours, addSeconds, isAfter, isValid, parseISO } from 'date-fns';
import { z } from 'zod';
import { decryptGameSessionToken, encryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
import { isGameAccessBlocked, isLoginBanned } from '@sammo-ts/common/auth/sanctions';
import { procedure, router } from './trpc.js';
import { toPublicUser } from './auth/userRepository.js';
@@ -250,6 +251,12 @@ export const appRouter = router({
message: '연결할 로컬 계정을 찾지 못했습니다.',
});
}
if (isLoginBanned(localUser.sanctions)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account login is blocked.',
});
}
if (existing && existing.id !== localUser.id) {
throw new TRPCError({
code: 'CONFLICT',
@@ -323,6 +330,12 @@ export const appRouter = router({
}
if (existing) {
if (isLoginBanned(existing.sanctions)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account login is blocked.',
});
}
await ctx.users.updateOAuthInfo(existing.id, oauthInfo);
const session = await ctx.sessions.createSession(existing);
return {
@@ -550,6 +563,12 @@ export const appRouter = router({
message: 'Invalid username or password.',
});
}
if (isLoginBanned(user.sanctions)) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Account login is blocked.',
});
}
const session = await ctx.sessions.createSession(user);
return {
user: toPublicUser(user),
@@ -615,6 +634,12 @@ export const appRouter = router({
}
const profileRecord = await ctx.profiles.getProfile(input.profile);
const profile = profileRecord?.profile ?? input.profile.split(':', 1)[0] ?? input.profile;
if (isGameAccessBlocked(user.sanctions, [input.profile, profile])) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'Game access is restricted for this account.',
});
}
const localAccountPolicy = resolveLocalAccountProfilePolicy({
profile,
profileMeta: profileRecord?.meta,
+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' },
]);
});
});
+122 -1
View File
@@ -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';