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
@@ -0,0 +1,68 @@
import { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createAdminAuditStore } from '../src/adminAudit.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const assertDedicatedSchema = (): void => {
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
}
};
integration('administrator audit PostgreSQL store', () => {
let db: GatewayPrismaClient;
let closeDb: (() => Promise<void>) | undefined;
beforeAll(async () => {
assertDedicatedSchema();
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
await connector.connect();
db = connector.prisma;
closeDb = () => connector.disconnect();
});
afterAll(async () => {
await closeDb?.();
});
it('appends and filters immutable target history', async () => {
const audit = createAdminAuditStore(db);
const correlationId = randomUUID();
const targetId = randomUUID();
await audit.append({
correlationId,
actorUserId: randomUUID(),
actorUsername: 'integration-admin',
capability: 'admin.users.manage',
action: 'admin.users.updateSanctions',
targetType: 'USER',
targetId,
reason: 'integration verification',
outcome: 'SUCCEEDED',
summary: { patch: { warningCount: 1 } },
});
await expect(audit.list({ targetType: 'USER', targetId })).resolves.toEqual([
expect.objectContaining({
correlationId,
actorUsername: 'integration-admin',
outcome: 'SUCCEEDED',
summary: { patch: { warningCount: 1 } },
}),
]);
await expect(
db.adminAuditEvent.update({
where: { id: (await audit.list({ targetId }))[0]!.id },
data: { action: 'tampered' },
})
).rejects.toThrow(/append-only/);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { buildAdminAuditTarget, sanitizeAdminAuditValue } from '../src/adminAudit.js';
describe('administrator audit sanitization', () => {
it('redacts credentials recursively while preserving operational context', () => {
expect(
sanitizeAdminAuditValue({
username: 'target',
password: 'secret',
nested: { authorization: 'Bearer token', notes: 'approved' },
})
).toEqual({
username: 'target',
password: '[REDACTED]',
nested: { authorization: '[REDACTED]', notes: 'approved' },
});
});
it('extracts a user target and reason without retaining the supplied password', () => {
expect(
buildAdminAuditTarget({
userId: 'user-1',
reason: 'account recovery',
newPassword: 'replacement-secret',
})
).toMatchObject({
targetType: 'USER',
targetId: 'user-1',
reason: 'account recovery',
summary: { newPassword: '[REDACTED]' },
});
});
});
+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' });
});
});
@@ -188,6 +188,7 @@ describe('admin security over HTTP transport', () => {
userId: harness.target.id,
roles: ['admin.survey.open:che:default'],
mode: 'grant',
reason: 'HTTP 권한 부여 테스트',
},
harness.adminSessionToken
);
@@ -209,6 +210,7 @@ describe('admin security over HTTP transport', () => {
userId: harness.target.id,
roles: ['admin.survey.open:*'],
mode: 'grant',
reason: 'HTTP 권한 상승 거부 테스트',
},
harness.adminSessionToken
);
@@ -233,6 +235,7 @@ describe('admin security over HTTP transport', () => {
userId: harness.target.id,
roles: ['admin.survey.open:che:default'],
mode: 'grant',
reason: '미인증 권한 변경 거부 테스트',
});
expect(rejected.response.status).toBe(401);
@@ -256,6 +259,7 @@ describe('admin security over HTTP transport', () => {
userId: harness.admin.id,
roles: ['admin.survey.open:*'],
mode: 'grant',
reason: '자기 권한 상승 거부 테스트',
},
harness.adminSessionToken
);
@@ -274,6 +278,7 @@ describe('admin security over HTTP transport', () => {
userId: harness.target.id,
roles: ['user'],
mode: 'set',
reason: '범위 밖 권한 제거 거부 테스트',
},
harness.adminSessionToken
);
@@ -291,6 +296,7 @@ describe('admin security over HTTP transport', () => {
userId: harness.target.id,
roles: ['superuser'],
mode: 'grant',
reason: '최고 관리자 권한 부여 테스트',
},
harness.adminSessionToken
);
@@ -310,6 +316,7 @@ describe('admin security over HTTP transport', () => {
patch: {
suspendedUntil: '2099-01-01T00:00:00.000Z',
},
reason: 'HTTP 제재 적용 테스트',
},
harness.adminSessionToken
);
@@ -89,4 +89,22 @@ describe('local account profile policy', () => {
graceEndsAt: null,
});
});
it('extends account access with an administrator override without widening general creation grace', () => {
const user = buildLocalUser(new Date('2026-07-20T00:00:00.000Z'));
user.kakaoGraceUntil = '2026-08-20T00:00:00.000Z';
const policy = resolveLocalAccountProfilePolicy({
profile: 'che',
defaultGraceDays: 7,
user,
now: new Date('2026-08-01T00:00:00.000Z'),
});
expect(policy).toMatchObject({
accessAllowed: true,
canCreateGeneral: false,
graceEndsAt: '2026-08-20T00:00:00.000Z',
generalCreationGraceDays: 0,
});
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ describe('readReleaseManifest', () => {
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
gatewaySchemaHead: '20260801000000_add_user_icon_library',
gatewaySchemaHead: '20260806000000_add_admin_audit_and_kakao_grace',
gameSchemaHead: '20260803000000_add_logical_game_clock',
});
});