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
+10
View File
@@ -50,6 +50,16 @@ Kakao가 `already registered`를 반환했지만 보존 이메일 계정도 없
확인 뒤 신규 가입 form을 엽니다. 이미 로컬에 연결된 stable ID의 변경 이메일이
다른 계정에 있으면 기존처럼 충돌을 거부합니다.
Kakao 인증을 사용할 수 없는 운영·검증·복구 계정은 Gateway의 특수 접근 자격으로
게임 서버에 들어갈 수 있습니다. `superuser`, `admin`, `admin.*` role은 운영자
자격으로 모든 profile과 장수 생성에 자동 허용됩니다. 그 밖의 계정은 관리자
콘솔에서 `TESTER`, `RECOVERY`, `OTHER` grant를 profile 범위, 만료, 장수 생성
허용 여부와 사유를 지정해 부여합니다. `RECOVERY`는 최대 90일의 만료가 필수이며,
부여·해제는 감사 원장과 별도 DB 이력에 모두 남습니다. 계정 제재는 이 자격보다
먼저 검사됩니다. 기존 Kakao 연결 계정이 인증 수단을 잃은 경우에도 비밀번호
인증은 유지하면서 유효한 특수 자격 기간에는 Kakao 공급자 호출 없이 로그인할 수
있습니다.
각 game profile은 별도 PostgreSQL schema를 사용합니다. `game-api`는 인증된
요청을 검증하고 직접 처리할 mutation 또는 daemon 입력을
`InputEvent`에 기록합니다. `game-engine`은 DB lease와 fencing token을 확보한
+90
View File
@@ -25,6 +25,13 @@ import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES);
const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES);
const zUserRoleMode = z.enum(['set', 'grant', 'revoke']);
const zSpecialAccountAccessKind = z.enum(['TESTER', 'RECOVERY', 'OTHER']);
const zSpecialAccessProfile = z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[a-z0-9_-]+(?::[a-zA-Z0-9._-]+)?$/);
const zJoinMode = z.enum(['full', 'onlyRandom']);
const zServerAction = z.enum([
'RESUME',
@@ -637,21 +644,104 @@ export const adminRouter = router({
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
const profiles = await ctx.profiles.listProfiles();
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
return {
kakaoVerified: user.oauthType === 'KAKAO' && Boolean(user.kakaoVerifiedAt),
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
kakaoGraceUntil: user.kakaoGraceUntil ?? null,
specialAccessGrants,
profiles: profiles.map((profile) => ({
profileName: profile.profileName,
...resolveLocalAccountProfilePolicy({
profile: profile.profile,
profileName: profile.profileName,
profileMeta: readMetaObject(profile.meta),
defaultGraceDays: (ctx as GatewayApiContext).localAccountGraceDays,
user,
specialAccessGrants,
}),
})),
};
}),
grantSpecialAccess: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
kind: zSpecialAccountAccessKind,
profiles: z.array(zSpecialAccessProfile).max(20).default([]),
allowsGeneralCreation: z.boolean().default(true),
expiresAt: z.string().datetime().nullable(),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.users.findById(input.userId);
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
const adminAuth = requireAdminAuth(ctx);
assertTargetUserManageable(adminAuth, user);
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
const now = new Date();
if (expiresAt && expiresAt.getTime() <= now.getTime()) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Special access must end in the future.' });
}
if (input.kind === 'RECOVERY') {
if (!expiresAt) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access must expire.' });
}
if (expiresAt.getTime() > now.getTime() + 90 * 24 * 60 * 60 * 1000) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Recovery access may last at most 90 days.' });
}
}
const profiles = [...new Set(input.profiles.map((profile) => profile.toLowerCase()))];
if (profiles.length > 0) {
const knownProfiles = await ctx.profiles.listProfiles();
const knownNames = new Set(
knownProfiles.flatMap((profile) => [profile.profile.toLowerCase(), profile.profileName.toLowerCase()])
);
const unknown = profiles.find((profile) => !knownNames.has(profile));
if (unknown) {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Unknown profile scope: ${unknown}` });
}
}
const grant = await ctx.users.createSpecialAccessGrant(input.userId, {
kind: input.kind,
profiles,
allowsGeneralCreation: input.allowsGeneralCreation,
expiresAt,
reason: input.reason,
grantedByUserId: adminAuth.user.id,
});
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-special-access-granted');
return grant;
}),
revokeSpecialAccess: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
grantId: z.string().uuid(),
reason: z.string().trim().min(3).max(200),
})
)
.mutation(async ({ ctx, input }) => {
const user = await ctx.users.findById(input.userId);
if (!user) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' });
}
const adminAuth = requireAdminAuth(ctx);
assertTargetUserManageable(adminAuth, user);
const grant = await ctx.users.revokeSpecialAccessGrant(input.userId, input.grantId, {
revokedAt: new Date(),
revokedByUserId: adminAuth.user.id,
reason: input.reason,
});
if (!grant) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Active special access grant not found.' });
}
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-special-access-revoked');
return grant;
}),
updateKakaoGrace: userAdminProcedure
.input(
z.object({
@@ -1,7 +1,13 @@
import { randomUUID } from 'node:crypto';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type { CreateUserInput, UserIconRecord, UserRecord, UserRepository } from './userRepository.js';
import type {
CreateUserInput,
SpecialAccountAccessGrantRecord,
UserIconRecord,
UserRecord,
UserRepository,
} from './userRepository.js';
// 유저 데이터 저장소를 메모리로 대체한 임시 구현.
export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimplePasswordHasher()): UserRepository => {
@@ -9,6 +15,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
const usersByOauthId = new Map<string, UserRecord>();
const usersByEmail = new Map<string, UserRecord>();
const iconsById = new Map<string, UserIconRecord>();
const specialAccessGrantsById = new Map<string, SpecialAccountAccessGrantRecord>();
const nextRevision = (user: UserRecord, now: Date): string =>
new Date(
@@ -240,6 +247,40 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
}
throw new Error('User not found.');
},
async listSpecialAccessGrants(userId: string): Promise<SpecialAccountAccessGrantRecord[]> {
return [...specialAccessGrantsById.values()]
.filter((grant) => grant.userId === userId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id));
},
async createSpecialAccessGrant(userId, input): Promise<SpecialAccountAccessGrantRecord> {
if (![...usersByName.values()].some((user) => user.id === userId)) {
throw new Error('User not found.');
}
const now = new Date().toISOString();
const grant: SpecialAccountAccessGrantRecord = {
id: randomUUID(),
userId,
kind: input.kind,
profiles: [...input.profiles],
allowsGeneralCreation: input.allowsGeneralCreation,
expiresAt: input.expiresAt?.toISOString(),
reason: input.reason,
grantedByUserId: input.grantedByUserId,
createdAt: now,
};
specialAccessGrantsById.set(grant.id, grant);
return grant;
},
async revokeSpecialAccessGrant(userId, grantId, input): Promise<SpecialAccountAccessGrantRecord | null> {
const grant = specialAccessGrantsById.get(grantId);
if (!grant || grant.userId !== userId || grant.revokedAt) {
return null;
}
grant.revokedAt = input.revokedAt.toISOString();
grant.revokedByUserId = input.revokedByUserId;
grant.revokedReason = input.reason;
return grant;
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
@@ -389,6 +430,11 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
for (const [username, user] of usersByName.entries()) {
if (user.id === userId) {
usersByName.delete(username);
for (const [grantId, grant] of specialAccessGrantsById) {
if (grant.userId === userId) {
specialAccessGrantsById.delete(grantId);
}
}
if (user.oauthType === 'KAKAO' && user.oauthId) {
usersByOauthId.delete(`${user.oauthType}:${user.oauthId}`);
}
+71 -7
View File
@@ -1,4 +1,4 @@
import type { UserRecord } from './userRepository.js';
import type { SpecialAccountAccessGrantRecord, UserRecord } from './userRepository.js';
const GENERAL_CREATION_GRACE_PROFILES = new Set(['nya', 'pya', 'hwe']);
const ADMIN_ROLES = new Set(['superuser', 'admin', 'admin.superuser']);
@@ -12,6 +12,12 @@ export interface LocalAccountProfilePolicy {
graceEndsAt: string | null;
generalCreationGraceDays: number;
accessGraceDays: number;
specialAccess: {
kind: 'OPERATOR' | SpecialAccountAccessGrantRecord['kind'];
grantId: string | null;
expiresAt: string | null;
allowsGeneralCreation: boolean;
} | null;
}
const readGraceDays = (meta: Record<string, unknown>, key: string, fallback: number): number => {
@@ -22,17 +28,67 @@ const readGraceDays = (meta: Record<string, unknown>, key: string, fallback: num
return Math.min(Math.max(Math.floor(value), 0), 365);
};
const hasAdminBypass = (user: UserRecord): boolean =>
export const hasOperatorSpecialAccess = (user: UserRecord): boolean =>
user.roles.some((role) => ADMIN_ROLES.has(role) || role.startsWith('admin.'));
export const hasActiveSpecialAccountGrant = (
grants: readonly SpecialAccountAccessGrantRecord[],
now: Date = new Date()
): boolean =>
grants.some((grant) => !grant.revokedAt && (!grant.expiresAt || new Date(grant.expiresAt).getTime() > now.getTime()));
const appliesToProfile = (grant: SpecialAccountAccessGrantRecord, profile: string, profileName: string): boolean =>
grant.profiles.length === 0 || grant.profiles.includes(profile) || grant.profiles.includes(profileName);
const resolveSpecialAccess = (options: {
user: UserRecord;
grants: readonly SpecialAccountAccessGrantRecord[];
profile: string;
profileName: string;
now: Date;
}): LocalAccountProfilePolicy['specialAccess'] => {
if (hasOperatorSpecialAccess(options.user)) {
return {
kind: 'OPERATOR',
grantId: null,
expiresAt: null,
allowsGeneralCreation: true,
};
}
const active = options.grants.filter((grant) => {
if (grant.revokedAt || !appliesToProfile(grant, options.profile, options.profileName)) {
return false;
}
return !grant.expiresAt || new Date(grant.expiresAt).getTime() > options.now.getTime();
});
if (active.length === 0) {
return null;
}
const selected = active.find((grant) => grant.allowsGeneralCreation) ?? active[0]!;
const expiresAt = active.some((grant) => !grant.expiresAt)
? null
: active
.map((grant) => grant.expiresAt!)
.sort((left, right) => right.localeCompare(left))[0] ?? null;
return {
kind: selected.kind,
grantId: selected.id,
expiresAt,
allowsGeneralCreation: active.some((grant) => grant.allowsGeneralCreation),
};
};
export const resolveLocalAccountProfilePolicy = (options: {
profile: string;
profileName?: string;
profileMeta?: Record<string, unknown>;
defaultGraceDays: number;
user: UserRecord;
specialAccessGrants?: readonly SpecialAccountAccessGrantRecord[];
now?: Date;
}): LocalAccountProfilePolicy => {
const profile = options.profile.toLowerCase();
const profileName = (options.profileName ?? options.profile).toLowerCase();
const meta = options.profileMeta ?? {};
const defaultGraceDays = Math.min(Math.max(Math.floor(options.defaultGraceDays), 0), 365);
const accessGraceDays = readGraceDays(meta, 'localAccountAccessGraceDays', defaultGraceDays);
@@ -43,25 +99,33 @@ export const resolveLocalAccountProfilePolicy = (options: {
generalCreationDefault
);
const kakaoVerified = options.user.oauthType === 'KAKAO' && Boolean(options.user.kakaoVerifiedAt);
const bypass = hasAdminBypass(options.user);
const graceStartedAt = new Date(options.user.kakaoGraceStartedAt);
const now = options.now ?? new Date();
const specialAccess = resolveSpecialAccess({
user: options.user,
grants: options.specialAccessGrants ?? [],
profile,
profileName,
now,
});
const accessEndsAt = new Date(graceStartedAt.getTime() + accessGraceDays * DAY_MS);
const adminGraceUntil = options.user.kakaoGraceUntil ? new Date(options.user.kakaoGraceUntil) : null;
if (adminGraceUntil && Number.isFinite(adminGraceUntil.getTime()) && adminGraceUntil > accessEndsAt) {
accessEndsAt.setTime(adminGraceUntil.getTime());
}
const generalCreationEndsAt = new Date(graceStartedAt.getTime() + generalCreationGraceDays * DAY_MS);
const accessAllowed = kakaoVerified || bypass || now < accessEndsAt;
const canCreateGeneral = kakaoVerified || bypass || (accessAllowed && now < generalCreationEndsAt);
const accessAllowed = kakaoVerified || specialAccess !== null || now < accessEndsAt;
const canCreateGeneral =
kakaoVerified || specialAccess?.allowsGeneralCreation === true || (accessAllowed && now < generalCreationEndsAt);
return {
requiresKakaoVerification: !kakaoVerified && !bypass,
requiresKakaoVerification: !kakaoVerified && specialAccess === null,
kakaoVerified,
accessAllowed,
canCreateGeneral,
graceEndsAt: kakaoVerified || bypass ? null : accessEndsAt.toISOString(),
graceEndsAt: kakaoVerified ? null : specialAccess ? specialAccess.expiresAt : accessEndsAt.toISOString(),
generalCreationGraceDays,
accessGraceDays,
specialAccess,
};
};
@@ -3,6 +3,7 @@ import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra';
import { createSimplePasswordHasher, type PasswordHasher } from './passwordHasher.js';
import type {
CreateUserInput,
SpecialAccountAccessGrantRecord,
UserIconRecord,
UserOAuthInfo,
UserRecord,
@@ -111,6 +112,34 @@ const mapIcon = (row: {
retiredAt: row.retiredAt?.toISOString(),
});
const mapSpecialAccessGrant = (row: {
id: string;
userId: string;
kind: 'TESTER' | 'RECOVERY' | 'OTHER';
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt: Date | null;
reason: string;
grantedByUserId: string;
revokedAt: Date | null;
revokedByUserId: string | null;
revokedReason: string | null;
createdAt: Date;
}): SpecialAccountAccessGrantRecord => ({
id: row.id,
userId: row.userId,
kind: row.kind,
profiles: row.profiles,
allowsGeneralCreation: row.allowsGeneralCreation,
expiresAt: row.expiresAt?.toISOString(),
reason: row.reason,
grantedByUserId: row.grantedByUserId,
revokedAt: row.revokedAt?.toISOString(),
revokedByUserId: row.revokedByUserId ?? undefined,
revokedReason: row.revokedReason ?? undefined,
createdAt: row.createdAt.toISOString(),
});
export const createPostgresUserRepository = (
prisma: GatewayPrismaClient,
hasher: PasswordHasher = createSimplePasswordHasher()
@@ -299,6 +328,41 @@ export const createPostgresUserRepository = (
data: { kakaoGraceUntil: until },
});
},
async listSpecialAccessGrants(userId: string): Promise<SpecialAccountAccessGrantRecord[]> {
const rows = await prisma.specialAccountAccessGrant.findMany({
where: { userId },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
return rows.map(mapSpecialAccessGrant);
},
async createSpecialAccessGrant(userId, input): Promise<SpecialAccountAccessGrantRecord> {
const row = await prisma.specialAccountAccessGrant.create({
data: {
userId,
kind: input.kind,
profiles: input.profiles,
allowsGeneralCreation: input.allowsGeneralCreation,
expiresAt: input.expiresAt,
reason: input.reason,
grantedByUserId: input.grantedByUserId,
},
});
return mapSpecialAccessGrant(row);
},
async revokeSpecialAccessGrant(userId, grantId, input): Promise<SpecialAccountAccessGrantRecord | null> {
const result = await prisma.specialAccountAccessGrant.updateMany({
where: { id: grantId, userId, revokedAt: null },
data: {
revokedAt: input.revokedAt,
revokedByUserId: input.revokedByUserId,
revokedReason: input.reason,
},
});
if (result.count !== 1) {
return null;
}
return mapSpecialAccessGrant(await prisma.specialAccountAccessGrant.findUniqueOrThrow({ where: { id: grantId } }));
},
async updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void> {
await prisma.appUser.update({
where: { id: userId },
@@ -38,6 +38,23 @@ export interface UserIconRecord {
retiredAt?: string;
}
export type SpecialAccountAccessKind = 'TESTER' | 'RECOVERY' | 'OTHER';
export interface SpecialAccountAccessGrantRecord {
id: string;
userId: string;
kind: SpecialAccountAccessKind;
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt?: string;
reason: string;
grantedByUserId: string;
revokedAt?: string;
revokedByUserId?: string;
revokedReason?: string;
createdAt: string;
}
export type AddUserIconResult =
{ ok: true; icon: UserIconRecord; revision: string } | { ok: false; reason: 'COOLDOWN' | 'LIMIT' | 'NOT_FOUND' };
@@ -134,6 +151,23 @@ export interface UserRepository {
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
listSpecialAccessGrants(userId: string): Promise<SpecialAccountAccessGrantRecord[]>;
createSpecialAccessGrant(
userId: string,
input: {
kind: SpecialAccountAccessKind;
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt: Date | null;
reason: string;
grantedByUserId: string;
}
): Promise<SpecialAccountAccessGrantRecord>;
revokeSpecialAccessGrant(
userId: string,
grantId: string,
input: { revokedAt: Date; revokedByUserId: string; reason: string }
): Promise<SpecialAccountAccessGrantRecord | null>;
updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise<void>;
updateIconForDay(
userId: string,
+28 -1
View File
@@ -12,7 +12,11 @@ import { toPublicUser } from './auth/userRepository.js';
import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js';
import { adminRouter } from './adminRouter.js';
import { accountRouter } from './account/router.js';
import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js';
import {
hasActiveSpecialAccountGrant,
hasOperatorSpecialAccess,
resolveLocalAccountProfilePolicy,
} from './auth/localAccountPolicy.js';
import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js';
import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js';
import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js';
@@ -131,14 +135,17 @@ export const appRouter = router({
localAccountPolicy: null,
}));
}
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
return Promise.all(
profileList.map(async (profile) => {
const record = await ctx.profiles.getProfile(profile.profileName);
const policy = resolveLocalAccountProfilePolicy({
profile: record?.profile ?? profile.profile,
profileName: profile.profileName,
profileMeta: record?.meta,
defaultGraceDays: ctx.localAccountGraceDays,
user,
specialAccessGrants,
});
return {
...profile,
@@ -765,6 +772,16 @@ export const appRouter = router({
});
}
if (user.oauthType === 'KAKAO') {
const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id);
if (hasOperatorSpecialAccess(user) || hasActiveSpecialAccountGrant(specialAccessGrants)) {
const session = await ctx.sessions.createSession(user);
return {
status: 'login' as const,
user: toPublicUser(user),
sessionToken: session.sessionToken,
issuedAt: session.issuedAt,
};
}
const ready = await verifyStoredKakaoIdentity({
user,
users: ctx.users,
@@ -881,9 +898,11 @@ export const appRouter = router({
}
const localAccountPolicy = resolveLocalAccountProfilePolicy({
profile,
profileName: input.profile,
profileMeta: profileRecord?.meta,
defaultGraceDays: ctx.localAccountGraceDays,
user,
specialAccessGrants: await ctx.users.listSpecialAccessGrants(user.id),
});
if (!localAccountPolicy.accessAllowed) {
throw new TRPCError({
@@ -932,6 +951,14 @@ export const appRouter = router({
canCreateGeneral: localAccountPolicy.canCreateGeneral,
requiresKakaoVerification: localAccountPolicy.requiresKakaoVerification,
graceEndsAt: localAccountPolicy.graceEndsAt,
...(localAccountPolicy.specialAccess
? {
specialAccess: {
kind: localAccountPolicy.specialAccess.kind,
expiresAt: localAccountPolicy.specialAccess.expiresAt,
},
}
: {}),
},
} as const;
const gameToken = encryptGameSessionToken(payload, ctx.gameTokenSecret);
@@ -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();
});
});
@@ -11,6 +11,7 @@ const installFixture = async (page: Page) => {
const mutations: Array<{ operation: string; body: unknown }> = [];
let deleteAfter: string | null = null;
let graceUntil: string | null = null;
let specialGrants: Array<Record<string, unknown>> = [];
const auditHistory = [
{
id: 'audit-1',
@@ -89,6 +90,7 @@ const installFixture = async (page: Page) => {
kakaoVerified: false,
kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z',
kakaoGraceUntil: graceUntil,
specialAccessGrants: specialGrants,
profiles: [
{
profileName: 'che:default',
@@ -99,6 +101,14 @@ const installFixture = async (page: Page) => {
graceEndsAt: graceUntil ?? '2026-08-10T00:00:00.000Z',
generalCreationGraceDays: 0,
accessGraceDays: 7,
specialAccess: specialGrants.length
? {
kind: 'RECOVERY',
grantId: '11111111-1111-4111-8111-111111111111',
expiresAt: '2026-08-20T00:00:00.000Z',
allowsGeneralCreation: true,
}
: null,
},
],
});
@@ -114,6 +124,28 @@ const installFixture = async (page: Page) => {
});
return response({ kakaoGraceUntil: graceUntil });
}
if (operation === 'admin.users.grantSpecialAccess') {
specialGrants = [
{
id: '11111111-1111-4111-8111-111111111111',
userId: 'target-user',
kind: 'RECOVERY',
profiles: ['che'],
allowsGeneralCreation: true,
expiresAt: '2026-08-20T00:00:00.000Z',
reason: '휴대폰 분실 임시 복구',
grantedByUserId: 'admin-user',
createdAt: '2026-08-08T00:00:00.000Z',
},
];
auditHistory.unshift({
...auditHistory[0],
id: 'audit-special',
action: 'admin.users.grantSpecialAccess',
reason: '휴대폰 분실 임시 복구',
});
return response(specialGrants[0]);
}
if (operation === 'admin.users.scheduleDeletion') {
deleteAfter = '2026-09-05T00:00:00.000Z';
auditHistory.unshift({
@@ -150,7 +182,17 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
.not.toBe(baseDeleteColor);
await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-hover.png'), fullPage: true });
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
await page.locator('input[type="datetime-local"]').nth(0).fill('2026-08-20T00:00');
await page.getByLabel('특수 접근 만료 시각').fill('2026-08-20T00:00');
await page.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che');
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구');
await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click();
await expect(page.getByText('특수 접근 자격을 부여했습니다.')).toBeVisible();
await expect(page.getByText(/RECOVERY · che/)).toBeVisible();
await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true });
const gracePanel = page.getByRole('heading', { name: 'Kakao 인증 유예' }).locator('..');
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중');
await gracePanel.locator('input[type="datetime-local"]').fill('2026-08-20T00:00');
await page.getByRole('button', { name: '유예 연장', exact: true }).click();
await expect(page.getByText('OAuth 유예 연장 완료')).toBeVisible();
await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible();
@@ -160,6 +202,7 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history',
await deletionButton.click();
await expect(page.getByText(/탈퇴 예약 완료/)).toBeVisible();
expect(mutations.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true);
expect(mutations.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true);
expect(mutations.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true);
await page.getByRole('link', { name: '감사 로그' }).click();
@@ -119,6 +119,26 @@ type KakaoGracePolicy = {
graceEndsAt: string | null;
generalCreationGraceDays: number;
accessGraceDays: number;
specialAccess: {
kind: 'OPERATOR' | SpecialAccountAccessGrant['kind'];
grantId: string | null;
expiresAt: string | null;
allowsGeneralCreation: boolean;
} | null;
};
type SpecialAccountAccessGrant = {
id: string;
userId: string;
kind: 'TESTER' | 'RECOVERY' | 'OTHER';
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt?: string;
reason: string;
grantedByUserId: string;
revokedAt?: string;
revokedReason?: string;
createdAt: string;
};
type AdminPublicUser = {
@@ -197,6 +217,7 @@ type AdminClient = {
kakaoVerified: boolean;
kakaoGraceStartedAt: string;
kakaoGraceUntil: string | null;
specialAccessGrants: SpecialAccountAccessGrant[];
profiles: KakaoGracePolicy[];
}>;
};
@@ -205,6 +226,19 @@ type AdminClient = {
kakaoGraceUntil: string | null;
}>;
};
grantSpecialAccess: {
mutate: (input: {
userId: string;
kind: SpecialAccountAccessGrant['kind'];
profiles: string[];
allowsGeneralCreation: boolean;
expiresAt: string | null;
reason: string;
}) => Promise<SpecialAccountAccessGrant>;
};
revokeSpecialAccess: {
mutate: (input: { userId: string; grantId: string; reason: string }) => Promise<SpecialAccountAccessGrant>;
};
listHistory: {
query: (input: { userId: string; limit?: number }) => Promise<AdminAuditEvent[]>;
};
@@ -407,6 +441,12 @@ const deletionRetentionDays = ref(30);
const kakaoGraceUntil = ref('');
const kakaoGraceStatus = ref('');
const kakaoPolicies = ref<KakaoGracePolicy[]>([]);
const specialAccessGrants = ref<SpecialAccountAccessGrant[]>([]);
const specialAccessKind = ref<SpecialAccountAccessGrant['kind']>('RECOVERY');
const specialAccessProfiles = ref('');
const specialAccessAllowsGeneralCreation = ref(true);
const specialAccessExpiresAt = ref('');
const specialAccessStatus = ref('');
const userHistory = ref<AdminAuditEvent[]>([]);
const globalAuditHistory = ref<AdminAuditEvent[]>([]);
const globalAuditStatus = ref('');
@@ -690,6 +730,7 @@ const lookupUser = async () => {
adminClient.users.listHistory.query({ userId: result.id, limit: 50 }),
]);
kakaoPolicies.value = grace.profiles;
specialAccessGrants.value = grace.specialAccessGrants;
kakaoGraceUntil.value = grace.kakaoGraceUntil ? toLocalInputValue(grace.kakaoGraceUntil) : '';
userHistory.value = history;
} catch (error) {
@@ -764,12 +805,56 @@ const updateKakaoGrace = async (clear = false) => {
kakaoGraceStatus.value = result.kakaoGraceUntil ? 'OAuth 유예 연장 완료' : '개별 유예 해제 완료';
const grace = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
kakaoPolicies.value = grace.profiles;
specialAccessGrants.value = grace.specialAccessGrants;
await refreshUserHistory();
} catch {
kakaoGraceStatus.value = 'OAuth 유예 변경 실패';
}
};
const grantSpecialAccess = async () => {
if (!userResult.value) return;
const reason = requireUserActionReason();
if (!reason) return;
specialAccessStatus.value = '';
try {
await adminClient.users.grantSpecialAccess.mutate({
userId: userResult.value.id,
kind: specialAccessKind.value,
profiles: specialAccessProfiles.value
.split(',')
.map((profile) => profile.trim())
.filter(Boolean),
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null,
reason,
});
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
kakaoPolicies.value = policy.profiles;
specialAccessGrants.value = policy.specialAccessGrants;
specialAccessStatus.value = '특수 접근 자격을 부여했습니다.';
await refreshUserHistory();
} catch {
specialAccessStatus.value = '특수 접근 자격 부여에 실패했습니다.';
}
};
const revokeSpecialAccess = async (grantId: string) => {
if (!userResult.value) return;
const reason = requireUserActionReason();
if (!reason) return;
try {
await adminClient.users.revokeSpecialAccess.mutate({ userId: userResult.value.id, grantId, reason });
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
kakaoPolicies.value = policy.profiles;
specialAccessGrants.value = policy.specialAccessGrants;
specialAccessStatus.value = '특수 접근 자격을 해제했습니다.';
await refreshUserHistory();
} catch {
specialAccessStatus.value = '특수 접근 자격 해제에 실패했습니다.';
}
};
const resetUserPassword = async () => {
if (!userResult.value) {
return;
@@ -1256,6 +1341,84 @@ onMounted(() => {
<div class="text-xs text-zinc-500">{{ rolesStatus }}</div>
</div>
<div class="bg-zinc-900 border border-amber-800/60 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
<div class="text-xs text-zinc-400">
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서
서버 범위와 만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<select
v-model="specialAccessKind"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
>
<option value="RECOVERY">휴대폰 분실·계정 복구</option>
<option value="TESTER">특수 테스트</option>
<option value="OTHER">기타 예외</option>
</select>
<input
v-model="specialAccessExpiresAt"
type="datetime-local"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
:disabled="!hasUser"
aria-label="특수 접근 만료 시각"
/>
<input
v-model="specialAccessProfiles"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
placeholder="che 또는 che:2 (쉼표 구분, 비우면 전체)"
:disabled="!hasUser"
/>
<label class="flex items-center gap-2 text-sm text-zinc-300 px-2">
<input
v-model="specialAccessAllowsGeneralCreation"
type="checkbox"
:disabled="!hasUser"
/>
장수 생성 허용
</label>
</div>
<button
class="bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
:disabled="!hasUser"
@click="grantSpecialAccess"
>
특수 접근 부여
</button>
<div class="text-xs text-zinc-500">{{ specialAccessStatus }}</div>
<div v-if="specialAccessGrants.length" class="space-y-2">
<div
v-for="grant in specialAccessGrants"
:key="grant.id"
class="bg-black/30 border border-zinc-800 rounded p-3 text-xs space-y-1"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-semibold text-amber-200">
{{ grant.kind }} · {{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }}
</span>
<button
v-if="!grant.revokedAt"
class="bg-red-900 hover:bg-red-800 text-red-100 px-3 py-1 rounded"
@click="revokeSpecialAccess(grant.id)"
>
해제
</button>
</div>
<div>
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }}
</div>
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
<div v-if="grant.revokedAt" class="text-red-300">
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} ·
{{ grant.revokedReason }}
</div>
</div>
</div>
</div>
<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4">
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
<div class="text-xs text-zinc-500">
@@ -1292,6 +1455,7 @@ onMounted(() => {
<th>접근</th>
<th>장수 생성</th>
<th>기본 접근 유예</th>
<th>특수 자격</th>
<th>종료</th>
</tr>
</thead>
@@ -1305,6 +1469,7 @@ onMounted(() => {
<td class="text-center">{{ policy.accessAllowed ? '허용' : '차단' }}</td>
<td class="text-center">{{ policy.canCreateGeneral ? '허용' : '차단' }}</td>
<td class="text-center">{{ policy.accessGraceDays }}</td>
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
<td class="text-center">
{{
policy.graceEndsAt
+7 -1
View File
@@ -291,7 +291,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
{{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
</div>
<div
v-if="
v-if="profile.localAccountPolicy?.specialAccess"
class="mt-2 text-xs text-emerald-300"
>
특수 접근 · {{ profile.localAccountPolicy.specialAccess.kind }}
</div>
<div
v-else-if="
profile.localAccountPolicy?.requiresKakaoVerification &&
!profile.localAccountPolicy.canCreateGeneral
"
+21 -1
View File
@@ -11,7 +11,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
| 메뉴 | 경로 | 책임 |
| ------------- | ------------------------- | ------------------------------------------------------------------------------ |
| 운영 개요 | `/gateway/admin` | 관리 영역 안내와 빠른 진입 |
| 사용자 관리 | `/gateway/admin/users` | 계정 조회·생성, 권한, OAuth 유예, 제재, 아이콘 복구, 탈퇴 예약과 사용자별 이력 |
| 사용자 관리 | `/gateway/admin/users` | 계정 조회·생성, 권한, 특수 접근·OAuth 유예, 제재, 아이콘 복구, 탈퇴 예약과 사용자별 이력 |
| 서버 관리 | `/gateway/admin/servers` | profile 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
| 버전 업데이트 | `/gateway/admin/releases` | profile DB 유지·초기화 배포, Gateway 릴리스·rollback과 작업 이력 |
| 공지 · 접속 | `/gateway/admin/system` | 로비 공지와 관리자 세션 연결 |
@@ -34,5 +34,25 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
- 브라우저의 메뉴 노출은 편의 기능입니다. 권한 판단의 기준은 서버가 인증
session에서 해석한 capability입니다.
## Kakao 없는 특수 계정 접근
운영자 role(`superuser`, `admin`, `admin.*`)은 별도 grant 없이 모든 game
profile에 접근하고 장수를 생성할 수 있습니다. 일반 계정의 예외는 사용자 관리의
`특수 접근 부여`에서 다음 항목을 명시합니다.
- 종류: 특수 테스트(`TESTER`), 인증 수단 복구(`RECOVERY`), 기타(`OTHER`)
- profile: 비우면 전체, `che`면 모든 CHE 기수, `che:2`면 해당 기수만
- 장수 생성: 단순 기존 장수 접속과 신규 장수 생성 권한을 분리
- 만료: `RECOVERY`는 필수이며 현재 시각부터 최대 90일
- 사유: 부여·해제 모두 3자 이상이며 감사 원장에 기록
여러 grant가 있으면 현재 profile에 적용되는 유효 grant 중 하나라도 장수 생성을
허용할 때 생성이 가능합니다. 영구 grant가 하나라도 있으면 접근 만료는 없습니다.
Kakao 인증이 완료되면 특수 접근이 없어도 정상 접근하며, 계정 제재와 profile별
server restriction은 특수 접근보다 먼저 적용됩니다. 변경 시 user flush가 발행되어
다음 game token 발급부터 새 정책이 반영됩니다. 기존 Kakao 연결 계정도 비밀번호를
확인한 뒤 유효한 grant 기간에는 Kakao 공급자 검증 없이 로그인할 수 있으므로,
휴대폰 분실 복구에는 반드시 짧은 만료와 확인 사유를 사용합니다.
상세 배포·복구 절차는 [릴리스 운영 매뉴얼](./release-operations.md)을
따릅니다.
+11
View File
@@ -26,6 +26,7 @@ TTL, game-token secret, OAuth, local-account 정책과 orchestrator 설정을
Gateway API는 다음 저장 경계를 사용합니다.
- `AppUser`, `SystemSetting`: 계정과 정책
- `SpecialAccountAccessGrant`: Kakao 없는 테스트·복구·기타 계정의 profile 범위, 만료, 장수 생성 및 해제 이력
- `GatewayProfile`: profile, scenario, port, 상태와 build 결과
- `GatewayOperation`: build/reset/open/close 등 실행 요청과 결과
- `GatewayReleaseOperation`, `GatewayReleaseState`: Gateway 전체 릴리스 queue와 현재·이전 commit
@@ -67,6 +68,16 @@ Redis script가 원자적으로 성공 소비 또는 실패 횟수 차감(최대
challenge와 OAuth pending state에는 TTL이 있으며 Redis 장애나 메시지 발송 실패는
로그인 실패로 끝납니다.
Kakao 없는 게임 접근은 Gateway에서만 판정합니다. 비밀번호와 제재를 먼저 검사한 뒤
운영자 또는 유효한 특수 grant가 있는 기존 Kakao 연결 계정은 공급자 호출 없이
Gateway session을 발급합니다. 게임 profile 진입에서는 다시 제재 검사를 수행하고
Kakao 확인, 운영자 role, 유효한 `SpecialAccountAccessGrant`, 기존 일반 계정 유예
순으로 접근과 장수 생성 가능 여부를 계산합니다. grant의 빈 `profiles`는 전체,
base profile(`che`)은 모든 기수, profile name(`che:2`)은 정확한 기수를 뜻합니다.
결과는 AES-256-GCM game token의 `identity.specialAccess`
`identity.canCreateGeneral`에 서명되어 game API가 장수 생성 mutation 전에 다시
검사합니다. grant 사유나 부여자 정보는 game token에 넣지 않습니다.
Orchestrator는 `GatewayOperation`을 claim하고 source ref를 commit으로
해결합니다. `WorkspaceManager`가 commit별 worktree를 준비하고 build runner가
artifact를 만들며 `Pm2ProcessManager`가 profile process를 조정합니다.
+11 -1
View File
@@ -55,6 +55,10 @@ export interface GameSessionTokenPayload {
canCreateGeneral: boolean;
requiresKakaoVerification: boolean;
graceEndsAt: string | null;
specialAccess?: {
kind: 'OPERATOR' | 'TESTER' | 'RECOVERY' | 'OTHER';
expiresAt: string | null;
};
};
}
@@ -136,7 +140,13 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa
typeof identity.kakaoVerified !== 'boolean' ||
typeof identity.canCreateGeneral !== 'boolean' ||
typeof identity.requiresKakaoVerification !== 'boolean' ||
(identity.graceEndsAt !== null && typeof identity.graceEndsAt !== 'string')
(identity.graceEndsAt !== null && typeof identity.graceEndsAt !== 'string') ||
(identity.specialAccess !== undefined &&
(!identity.specialAccess ||
typeof identity.specialAccess !== 'object' ||
!['OPERATOR', 'TESTER', 'RECOVERY', 'OTHER'].includes(identity.specialAccess.kind) ||
(identity.specialAccess.expiresAt !== null &&
typeof identity.specialAccess.expiresAt !== 'string')))
) {
return null;
}
@@ -0,0 +1,32 @@
DO $$
BEGIN
CREATE TYPE "SpecialAccountAccessKind" AS ENUM ('TESTER', 'RECOVERY', 'OTHER');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS "special_account_access_grant" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" TEXT NOT NULL,
"kind" "SpecialAccountAccessKind" NOT NULL,
"profiles" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
"allows_general_creation" BOOLEAN NOT NULL DEFAULT TRUE,
"expires_at" TIMESTAMP(3),
"reason" TEXT NOT NULL,
"granted_by_user_id" TEXT NOT NULL,
"revoked_at" TIMESTAMP(3),
"revoked_by_user_id" TEXT,
"revoked_reason" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "special_account_access_grant_pkey" PRIMARY KEY ("id"),
CONSTRAINT "special_account_access_grant_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "app_user"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX IF NOT EXISTS "special_account_access_grant_user_id_revoked_at_expires_at_idx"
ON "special_account_access_grant"("user_id", "revoked_at", "expires_at");
CREATE INDEX IF NOT EXISTS "special_account_access_grant_kind_created_at_idx"
ON "special_account_access_grant"("kind", "created_at");
COMMENT ON TABLE "special_account_access_grant" IS
'Explicit audited exception allowing selected local accounts to enter game profiles without Kakao verification.';
+27
View File
@@ -65,6 +65,12 @@ enum AdminAuditOutcome {
FAILED
}
enum SpecialAccountAccessKind {
TESTER
RECOVERY
OTHER
}
enum GatewaySourceMode {
BRANCH
COMMIT
@@ -101,10 +107,31 @@ model AppUser {
lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
icons UserIcon[]
specialAccessGrants SpecialAccountAccessGrant[]
@@map("app_user")
}
model SpecialAccountAccessGrant {
id String @id @default(uuid())
userId String @map("user_id")
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
kind SpecialAccountAccessKind
profiles String[] @default([])
allowsGeneralCreation Boolean @default(true) @map("allows_general_creation")
expiresAt DateTime? @map("expires_at")
reason String
grantedByUserId String @map("granted_by_user_id")
revokedAt DateTime? @map("revoked_at")
revokedByUserId String? @map("revoked_by_user_id")
revokedReason String? @map("revoked_reason")
createdAt DateTime @default(now()) @map("created_at")
@@index([userId, revokedAt, expiresAt])
@@index([kind, createdAt])
@@map("special_account_access_grant")
}
model AdminAuditEvent {
id String @id @default(uuid())
correlationId String @map("correlation_id")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 1,
"gatewaySchemaHead": "20260808000000_add_kakao_talk_verification",
"gatewaySchemaHead": "20260808001000_add_special_account_access_grants",
"gameSchemaHead": "20260803000000_add_logical_game_clock",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}