diff --git a/README.md b/README.md index 128d2f4c..39215b2e 100644 --- a/README.md +++ b/README.md @@ -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을 확보한 diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index a87b0554..563cbeea 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -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({ diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index 4ffba6ee..77f81e40 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -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(); const usersByEmail = new Map(); const iconsById = new Map(); + const specialAccessGrantsById = new Map(); 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 { + 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 { + 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 { + 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 { 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}`); } diff --git a/app/gateway-api/src/auth/localAccountPolicy.ts b/app/gateway-api/src/auth/localAccountPolicy.ts index cf19598d..d1f81476 100644 --- a/app/gateway-api/src/auth/localAccountPolicy.ts +++ b/app/gateway-api/src/auth/localAccountPolicy.ts @@ -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, key: string, fallback: number): number => { @@ -22,17 +28,67 @@ const readGraceDays = (meta: Record, 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; 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, }; }; diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 5f160b80..8e8228d1 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -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 { + const rows = await prisma.specialAccountAccessGrant.findMany({ + where: { userId }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + }); + return rows.map(mapSpecialAccessGrant); + }, + async createSpecialAccessGrant(userId, input): Promise { + 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 { + 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 { await prisma.appUser.update({ where: { id: userId }, diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index 85afec4c..fb8f8e96 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -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; updateSanctions(userId: string, sanctions: UserSanctions): Promise; updateKakaoGraceUntil(userId: string, until: Date | null): Promise; + listSpecialAccessGrants(userId: string): Promise; + createSpecialAccessGrant( + userId: string, + input: { + kind: SpecialAccountAccessKind; + profiles: string[]; + allowsGeneralCreation: boolean; + expiresAt: Date | null; + reason: string; + grantedByUserId: string; + } + ): Promise; + revokeSpecialAccessGrant( + userId: string, + grantId: string, + input: { revokedAt: Date; revokedByUserId: string; reason: string } + ): Promise; updateIcon(userId: string, picture: string, imageServer: number, updatedAt: Date): Promise; updateIconForDay( userId: string, diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 2aa18b19..80acea61 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -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); diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index dc92628a..f742cd7d 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -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({ diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index 76ee3132..eff98648 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -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(); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index b0cbc2aa..dc4780ed 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -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({ diff --git a/app/gateway-api/test/localAccountPolicy.test.ts b/app/gateway-api/test/localAccountPolicy.test.ts index 9b3df571..261c6dc0 100644 --- a/app/gateway-api/test/localAccountPolicy.test.ts +++ b/app/gateway-api/test/localAccountPolicy.test.ts @@ -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, + }); + }); }); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index e7d50cfb..62610a8b 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -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', }); }); diff --git a/app/gateway-api/test/specialAccountAccessGrant.integration.test.ts b/app/gateway-api/test/specialAccountAccessGrant.integration.test.ts new file mode 100644 index 00000000..3dc83e15 --- /dev/null +++ b/app/gateway-api/test/specialAccountAccessGrant.integration.test.ts @@ -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) | 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(); + }); +}); diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index 7b24b2ed..d2b05bd1 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -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> = []; 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(); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 002a5e3d..22f1f610 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -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; + }; + revokeSpecialAccess: { + mutate: (input: { userId: string; grantId: string; reason: string }) => Promise; + }; listHistory: { query: (input: { userId: string; limit?: number }) => Promise; }; @@ -407,6 +441,12 @@ const deletionRetentionDays = ref(30); const kakaoGraceUntil = ref(''); const kakaoGraceStatus = ref(''); const kakaoPolicies = ref([]); +const specialAccessGrants = ref([]); +const specialAccessKind = ref('RECOVERY'); +const specialAccessProfiles = ref(''); +const specialAccessAllowsGeneralCreation = ref(true); +const specialAccessExpiresAt = ref(''); +const specialAccessStatus = ref(''); const userHistory = ref([]); const globalAuditHistory = ref([]); 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(() => {
{{ rolesStatus }}
+
+

Kakao 없는 특수 계정 접근

+
+ 운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 + 서버 범위와 만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. +
+
+ + + + +
+ +
{{ specialAccessStatus }}
+
+
+
+ + {{ grant.kind }} · {{ grant.profiles.length ? grant.profiles.join(', ') : '전체 profile' }} + + +
+
+ 장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료 + {{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }} +
+
부여 사유: {{ grant.reason }}
+
+ 해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} · + {{ grant.revokedReason }} +
+
+
+
+

Kakao 인증 유예

@@ -1292,6 +1455,7 @@ onMounted(() => { 접근 장수 생성 기본 접근 유예 + 특수 자격 종료 @@ -1305,6 +1469,7 @@ onMounted(() => { {{ policy.accessAllowed ? '허용' : '차단' }} {{ policy.canCreateGeneral ? '허용' : '차단' }} {{ policy.accessGraceDays }}일 + {{ policy.specialAccess?.kind ?? '-' }} {{ policy.graceEndsAt diff --git a/app/gateway-frontend/src/views/LobbyView.vue b/app/gateway-frontend/src/views/LobbyView.vue index 0328a03b..72f20dde 100644 --- a/app/gateway-frontend/src/views/LobbyView.vue +++ b/app/gateway-frontend/src/views/LobbyView.vue @@ -291,7 +291,13 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => { {{ serverSeasonStatus(profileDetails[profile.profileName]!).label }}
+ 특수 접근 · {{ profile.localAccountPolicy.specialAccess.kind }} +
+