feat(gateway): add special account access

This commit is contained in:
2026-08-08 16:33:54 +00:00
parent 6a333cdf05
commit 77b1051ac2
22 changed files with 1060 additions and 15 deletions
@@ -916,6 +916,70 @@ describe('Gateway administrator account controls', () => {
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-kakao-grace-updated' });
});
it('grants and revokes profile-scoped recovery access with an audit trail', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'recovery-target',
password: 'secretpass',
displayName: 'Recovery Target',
});
target.oauthType = 'KAKAO';
target.oauthId = 'lost-phone-kakao-id';
target.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
const expiresAt = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString();
const grant = await harness.caller.admin.users.grantSpecialAccess({
userId: target.id,
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt,
reason: '휴대폰 분실 본인 확인 완료',
});
expect(grant).toMatchObject({
userId: target.id,
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt,
grantedByUserId: harness.admin.id,
});
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-granted' });
await expect(
harness.caller.admin.users.revokeSpecialAccess({
userId: target.id,
grantId: grant.id,
reason: 'Kakao 인증 수단 복구 완료',
})
).resolves.toMatchObject({ id: grant.id, revokedReason: 'Kakao 인증 수단 복구 완료' });
expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-special-access-revoked' });
expect(harness.auditEvents.filter((event) => event.outcome === 'SUCCEEDED').map((event) => event.action)).toEqual([
'admin.users.grantSpecialAccess',
'admin.users.revokeSpecialAccess',
]);
});
it('requires recovery access to expire within 90 days', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
username: 'unsafe-recovery-target',
password: 'secretpass',
displayName: 'Unsafe Recovery Target',
});
await expect(
harness.caller.admin.users.grantSpecialAccess({
userId: target.id,
kind: 'RECOVERY',
profiles: [],
allowsGeneralCreation: true,
expiresAt: null,
reason: '무기한 복구 예외 거부',
})
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
});
it('schedules deletion with retention and prevents administrator self-deletion', async () => {
const harness = await buildCaller(unusedCreateOperation);
const target = await harness.users.createUser({
@@ -249,6 +249,29 @@ describe('admin security over HTTP transport', () => {
expect((await harness.users.findById(harness.target.id))?.roles).toEqual(['user']);
});
it('rejects an unauthenticated special-access grant at the HTTP header boundary', async () => {
const harness = await createHarness();
const rejected = await postTrpc(harness.baseUrl, 'admin.users.grantSpecialAccess', {
userId: harness.target.id,
kind: 'TESTER',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: null,
reason: '미인증 특수 접근 부여 거부 테스트',
});
expect(rejected.response.status).toBe(401);
expect(rejected.body).toMatchObject({
error: {
data: {
code: 'UNAUTHORIZED',
},
},
});
expect(await harness.users.listSpecialAccessGrants(harness.target.id)).toEqual([]);
});
it('rejects self-escalation and set-mode removal outside a scoped administrator role', async () => {
const harness = await createHarness();
+133
View File
@@ -434,6 +434,139 @@ describe('gateway auth flow', () => {
});
});
it('issues a CHE game token to an expired tester with an active special access grant', async () => {
const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 0 });
const register = await caller.auth.registerLocal({
username: 'special-tester',
credential: sealPassword('tester-password'),
displayName: '특수테스터',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
const user = await users.findByUsername('special-tester');
expect(user).not.toBeNull();
if (!user) throw new Error('Expected local tester.');
await users.createSpecialAccessGrant(user.id, {
kind: 'TESTER',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: null,
reason: 'CHE 회귀 검증',
grantedByUserId: 'admin-id',
});
const issued = await caller.auth.issueGameSession({
sessionToken: register.sessionToken,
profile: 'che:default',
});
const payload = decryptGameSessionToken(issued.gameToken, 'test-secret');
expect(payload?.identity).toMatchObject({
kakaoVerified: false,
canCreateGeneral: true,
requiresKakaoVerification: false,
specialAccess: { kind: 'TESTER', expiresAt: null },
});
});
it('lets a Kakao-linked recovery account log in with its password while the grant is active', async () => {
const { caller, users, sealPassword } = buildCaller();
await caller.auth.registerLocal({
username: 'lost-phone-user',
credential: sealPassword('recovery-password'),
displayName: '분실복구유저',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
const user = await users.findByUsername('lost-phone-user');
expect(user).not.toBeNull();
if (!user) throw new Error('Expected recovery user.');
user.oauthType = 'KAKAO';
user.oauthId = 'lost-phone-kakao-id';
user.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
await users.createSpecialAccessGrant(user.id, {
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
reason: '휴대폰 분실 본인 확인 완료',
grantedByUserId: 'admin-id',
});
await expect(
caller.auth.login({
username: 'lost-phone-user',
credential: sealPassword('recovery-password'),
})
).resolves.toMatchObject({ status: 'login', user: { username: 'lost-phone-user' } });
});
it('lets a Kakao-linked operator log in with its password without a grant', async () => {
const { caller, users, sealPassword } = buildCaller();
await caller.auth.registerLocal({
username: 'oauth-free-operator',
credential: sealPassword('operator-password'),
displayName: '복구운영자',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
const user = await users.findByUsername('oauth-free-operator');
expect(user).not.toBeNull();
if (!user) throw new Error('Expected operator user.');
user.oauthType = 'KAKAO';
user.oauthId = 'operator-kakao-id';
user.kakaoVerifiedAt = '2026-08-01T00:00:00.000Z';
await users.updateRoles(user.id, ['user', 'admin.users.manage']);
await expect(
caller.auth.login({
username: 'oauth-free-operator',
credential: sealPassword('operator-password'),
})
).resolves.toMatchObject({ status: 'login', user: { username: 'oauth-free-operator' } });
});
it('keeps an active server sanction authoritative over special access', async () => {
const { caller, users, sealPassword } = buildCaller({ localAccountGraceDays: 0 });
const register = await caller.auth.registerLocal({
username: 'sanctioned-special-tester',
credential: sealPassword('tester-password'),
displayName: '제재특수테스터',
termsAgreed: true,
privacyAgreed: true,
thirdPartyUse: false,
});
const user = await users.findByUsername('sanctioned-special-tester');
expect(user).not.toBeNull();
if (!user) throw new Error('Expected sanctioned local tester.');
await users.createSpecialAccessGrant(user.id, {
kind: 'TESTER',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: null,
reason: 'CHE 회귀 검증',
grantedByUserId: 'admin-id',
});
await users.updateSanctions(user.id, {
serverRestrictions: {
che: {
blockedFeatures: ['login'],
until: '2099-01-01T00:00:00.000Z',
},
},
});
await expect(
caller.auth.issueGameSession({
sessionToken: register.sessionToken,
profile: 'che:default',
})
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('links Kakao to the logged-in local account instead of creating a second user', async () => {
const { caller, users, sealPassword, setSessionHeader, sentTalkMessages } = buildCaller();
const register = await caller.auth.registerLocal({
@@ -107,4 +107,90 @@ describe('local account profile policy', () => {
generalCreationGraceDays: 0,
});
});
it('treats every administrator role as permanent operator access', () => {
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
user.roles = ['user', 'admin.users.manage'];
const policy = resolveLocalAccountProfilePolicy({
profile: 'che',
profileName: 'che:2',
defaultGraceDays: 0,
user,
now: new Date('2026-08-08T00:00:00.000Z'),
});
expect(policy).toMatchObject({
accessAllowed: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
specialAccess: {
kind: 'OPERATOR',
grantId: null,
expiresAt: null,
allowsGeneralCreation: true,
},
});
});
it('applies a profile-scoped tester grant to CHE including general creation', () => {
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
const policy = resolveLocalAccountProfilePolicy({
profile: 'che',
profileName: 'che:2',
defaultGraceDays: 0,
user,
specialAccessGrants: [
{
id: 'grant-1',
userId: user.id,
kind: 'TESTER',
profiles: ['che'],
allowsGeneralCreation: true,
reason: '고정 시나리오 검증',
grantedByUserId: 'admin-id',
createdAt: '2026-08-01T00:00:00.000Z',
},
],
now: new Date('2026-08-08T00:00:00.000Z'),
});
expect(policy).toMatchObject({
accessAllowed: true,
canCreateGeneral: true,
requiresKakaoVerification: false,
specialAccess: { kind: 'TESTER', grantId: 'grant-1', allowsGeneralCreation: true },
});
});
it('ignores expired, revoked, and different-profile grants', () => {
const user = buildLocalUser(new Date('2020-01-01T00:00:00.000Z'));
const baseGrant = {
userId: user.id,
kind: 'RECOVERY' as const,
profiles: ['che'],
allowsGeneralCreation: true,
reason: '단말 분실 복구',
grantedByUserId: 'admin-id',
createdAt: '2026-08-01T00:00:00.000Z',
};
const policy = resolveLocalAccountProfilePolicy({
profile: 'che',
profileName: 'che:2',
defaultGraceDays: 0,
user,
specialAccessGrants: [
{ ...baseGrant, id: 'expired', expiresAt: '2026-08-07T00:00:00.000Z' },
{ ...baseGrant, id: 'revoked', revokedAt: '2026-08-07T00:00:00.000Z' },
{ ...baseGrant, id: 'other-profile', profiles: ['hwe'], expiresAt: '2026-09-01T00:00:00.000Z' },
],
now: new Date('2026-08-08T00:00:00.000Z'),
});
expect(policy).toMatchObject({
accessAllowed: false,
canCreateGeneral: false,
requiresKakaoVerification: true,
specialAccess: null,
});
});
});
+1 -1
View File
@@ -37,7 +37,7 @@ describe('readReleaseManifest', () => {
const workspaceRoot = path.resolve(import.meta.dirname, '../../..');
await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({
gatewaySchemaHead: '20260808000000_add_kakao_talk_verification',
gatewaySchemaHead: '20260808001000_add_special_account_access_grants',
gameSchemaHead: '20260803000000_add_logical_game_clock',
});
});
@@ -0,0 +1,90 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
const integration = describe.skipIf(!databaseUrl);
const userId = '14a4d550-e92c-4aec-81e1-e6235dc17ded';
const adminId = 'b6b327d8-e95e-4858-9b66-4fd22a286145';
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('special account access PostgreSQL boundary', () => {
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();
await db.appUser.deleteMany({ where: { id: userId } });
await db.appUser.create({
data: {
id: userId,
loginId: 'special-access-integration',
displayName: '특수 접근 통합',
passwordHash: 'not-used',
passwordSalt: 'not-used',
roles: ['user'],
sanctions: {},
},
});
});
afterAll(async () => {
await db?.appUser.deleteMany({ where: { id: userId } });
await closeDb?.();
});
it('persists profile scope and preserves revocation provenance', async () => {
const users = createPostgresUserRepository(db);
const grant = await users.createSpecialAccessGrant(userId, {
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: new Date('2026-09-01T00:00:00.000Z'),
reason: '분실 단말 복구 기간',
grantedByUserId: adminId,
});
await expect(users.listSpecialAccessGrants(userId)).resolves.toEqual([
expect.objectContaining({
id: grant.id,
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: '2026-09-01T00:00:00.000Z',
grantedByUserId: adminId,
}),
]);
const revoked = await users.revokeSpecialAccessGrant(userId, grant.id, {
revokedAt: new Date('2026-08-20T00:00:00.000Z'),
revokedByUserId: adminId,
reason: 'Kakao 인증 복구 완료',
});
expect(revoked).toMatchObject({
id: grant.id,
revokedAt: '2026-08-20T00:00:00.000Z',
revokedByUserId: adminId,
revokedReason: 'Kakao 인증 복구 완료',
});
await expect(
users.revokeSpecialAccessGrant(userId, grant.id, {
revokedAt: new Date('2026-08-21T00:00:00.000Z'),
revokedByUserId: adminId,
reason: '중복 해제',
})
).resolves.toBeNull();
});
});