feat(gateway): add accountable admin controls

This commit is contained in:
2026-08-06 12:52:40 +00:00
parent 228fa62f67
commit 3be3e3b307
22 changed files with 1617 additions and 20 deletions
+168 -1
View File
@@ -18,6 +18,7 @@ import { createGatewayApiContext } from '../src/context.js';
import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js';
import { appRouter } from '../src/router.js';
import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js';
import type { AdminAuditEventRecord, AdminAuditWrite } from '../src/adminAudit.js';
const buildCaller = async (
createOperation: GatewayProfileRepository['createOperation'],
@@ -50,6 +51,7 @@ const buildCaller = async (
const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = [];
const updatedStatuses: GatewayProfileRecord['status'][] = [];
const updatedMetas: Record<string, unknown>[] = [];
const auditEvents: AdminAuditEventRecord[] = [];
let reconcileCount = 0;
let storedNotice = options.initialNotice ?? '';
const profile = {
@@ -162,6 +164,29 @@ const buildCaller = async (
localRegistrationEnabled: true,
localAccountGraceDays: 7,
passwordEnvelope: createPasswordEnvelopeService(),
adminAudit: {
append: async (event: AdminAuditWrite) => {
auditEvents.push({
id: `audit-${auditEvents.length + 1}`,
credentialKind: 'SESSION',
createdAt: new Date(1_700_000_000_000 + auditEvents.length).toISOString(),
summary: {},
...event,
});
},
list: async (input = {}) =>
auditEvents
.filter(
(event) =>
(!input.actorUserId || event.actorUserId === input.actorUserId) &&
(!input.targetType || event.targetType === input.targetType) &&
(!input.targetId || event.targetId === input.targetId) &&
(!input.profileName || event.profileName === input.profileName)
)
.slice()
.reverse()
.slice(0, input.limit ?? 100),
},
profiles,
releases,
orchestrator: {
@@ -225,6 +250,7 @@ const buildCaller = async (
flushes,
updatedStatuses,
updatedMetas,
auditEvents,
getReconcileCount: () => reconcileCount,
getStoredNotice: () => storedNotice,
setStoredNotice: (notice: string) => {
@@ -653,6 +679,7 @@ describe('admin role non-escalation', () => {
userId: target.id,
roles: ['admin.survey.open:che:default'],
mode: 'grant',
reason: '권한 부여 테스트',
})
).resolves.toMatchObject({
roles: ['user', 'admin.survey.open:che:default'],
@@ -677,6 +704,7 @@ describe('admin role non-escalation', () => {
userId: target.id,
roles: [role],
mode: 'grant',
reason: '권한 범위 거부 테스트',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
}
@@ -699,6 +727,7 @@ describe('admin role non-escalation', () => {
userId: target.id,
roles: ['user'],
mode: 'set',
reason: '권한 제거 거부 테스트',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
@@ -714,6 +743,7 @@ describe('admin role non-escalation', () => {
userId: harness.admin.id,
roles: ['admin.survey.open:*'],
mode: 'grant',
reason: '자기 권한 상승 거부 테스트',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect((await harness.users.findById(harness.admin.id))?.roles).toEqual([
@@ -736,6 +766,7 @@ describe('admin role non-escalation', () => {
userId: target.id,
roles: ['superuser'],
mode: 'grant',
reason: '최고 관리자 권한 부여 테스트',
})
).resolves.toMatchObject({ roles: ['user', 'superuser'] });
});
@@ -752,15 +783,18 @@ describe('admin role non-escalation', () => {
userId: target.id,
roles: ['admin.survey.open:che:default'],
mode: 'grant',
reason: '세션 무효화 권한 테스트',
});
await harness.caller.admin.users.updateSanctions({
userId: target.id,
patch: { suspendedUntil: '2099-01-01T00:00:00.000Z' },
reason: '세션 무효화 제재 테스트',
});
await harness.caller.admin.users.setServerRestriction({
userId: target.id,
profile: 'che:default',
restriction: { blockedFeatures: ['login'] },
reason: '세션 무효화 서버 제한 테스트',
});
expect(harness.flushes).toEqual([
@@ -793,7 +827,10 @@ describe('admin role non-escalation', () => {
} as never)
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
const result = await harness.caller.admin.users.resetProfileIcon({ userId: target.id });
const result = await harness.caller.admin.users.resetProfileIcon({
userId: target.id,
reason: '프로필 아이콘 초기화 테스트',
});
expect(new Date(result.profileIconResetAt).getTime()).toBeGreaterThan(new Date(second!).getTime());
expect((await harness.users.findById(target.id))?.profileIconResetAt).toBe(result.profileIconResetAt);
expect(harness.flushes.at(-1)).toEqual({
@@ -803,3 +840,133 @@ describe('admin role non-escalation', () => {
});
});
});
describe('Gateway administrator account controls', () => {
const unusedCreateOperation: GatewayProfileRepository['createOperation'] = async () => {
throw new Error('not used');
};
it('records sanitized STARTED and SUCCEEDED events and exposes target history', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'audit-target',
password: 'secretpass',
displayName: 'Audit Target',
});
await harness.caller.admin.users.resetPassword({
userId: target.id,
newPassword: 'replacement-secret',
reason: '사용자 요청에 따른 복구',
});
expect(harness.auditEvents).toHaveLength(2);
expect(harness.auditEvents.map((event) => event.outcome)).toEqual(['STARTED', 'SUCCEEDED']);
expect(harness.auditEvents[0]).toMatchObject({
actorUserId: harness.admin.id,
capability: 'admin.users.manage',
targetType: 'USER',
targetId: target.id,
reason: '사용자 요청에 따른 복구',
summary: { newPassword: '[REDACTED]' },
});
const history = await harness.caller.admin.users.listHistory({ userId: target.id });
expect(history.map((event) => event.outcome)).toEqual(['SUCCEEDED', 'STARTED']);
await expect(harness.caller.admin.audit.list({ targetId: target.id })).resolves.toHaveLength(2);
});
it('records a FAILED terminal event when validation rejects an unknown capability', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'failed-audit-target',
password: 'secretpass',
displayName: 'Failed Audit Target',
});
await expect(
harness.caller.admin.users.updateRoles({
userId: target.id,
roles: ['admin.unknown.manage'],
mode: 'grant',
reason: '알 수 없는 권한 거부 확인',
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
expect(harness.auditEvents.map((event) => event.outcome)).toEqual(['STARTED', 'FAILED']);
expect(harness.auditEvents.at(-1)).toMatchObject({ errorCode: 'BAD_REQUEST' });
expect((await harness.users.findById(target.id))?.roles).toEqual(['user']);
});
it('extends an unverified local account grace period and flushes active sessions', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'grace-target',
password: 'secretpass',
displayName: 'Grace Target',
});
const until = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
await expect(
harness.caller.admin.users.updateKakaoGrace({
userId: target.id,
until,
reason: '고객센터 본인 확인 처리 중',
})
).resolves.toEqual({ kakaoGraceUntil: until });
expect((await harness.users.findById(target.id))?.kakaoGraceUntil).toBe(until);
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-kakao-grace-updated' });
});
it('schedules deletion with retention and prevents administrator self-deletion', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'deletion-target',
password: 'secretpass',
displayName: 'Deletion Target',
});
const result = await harness.caller.admin.users.scheduleDeletion({
userId: target.id,
retentionDays: 30,
reason: '탈퇴 요청 증빙 확인 완료',
});
expect((await harness.users.findById(target.id))?.deleteAfter).toBe(result.deleteAfter);
await expect(
harness.caller.admin.users.scheduleDeletion({
userId: harness.admin.id,
retentionDays: 30,
reason: '관리자 자기 삭제 차단 확인',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('prevents a delegated administrator from changing a root administrator', async () => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.users.manage'],
firstUserIsAdmin: false,
});
const target = await harness.users.createUser({
username: 'root-target',
password: 'secretpass',
displayName: 'Root Target',
});
await harness.users.updateRoles(target.id, ['user', 'admin']);
await expect(
harness.caller.admin.users.updateSanctions({
userId: target.id,
patch: { suspendedUntil: '2099-01-01T00:00:00.000Z' },
reason: '루트 계정 보호 확인',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
expect((await harness.users.findById(target.id))?.sanctions).toEqual({});
});
it('keeps the global audit feed behind its dedicated capability', async () => {
const harness = await buildCaller(unusedCreateOperation, {
adminRoles: ['user', 'admin.users.manage'],
firstUserIsAdmin: false,
});
await expect(harness.caller.admin.audit.list()).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
});