diff --git a/app/game-api/src/router.ts b/app/game-api/src/router.ts index c700e8fe..45fddc16 100644 --- a/app/game-api/src/router.ts +++ b/app/game-api/src/router.ts @@ -1,3 +1,4 @@ +import { playAuditRouter } from './router/playAudit/index.js'; import { router } from './trpc.js'; import { battleRouter } from './router/battle/index.js'; @@ -28,6 +29,7 @@ import { archiveRouter } from './router/archive/index.js'; import { dashboardRouter } from './router/dashboard/index.js'; export const appRouter = router({ + playAudit: playAuditRouter, health: healthRouter, auth: authRouter, lobby: lobbyRouter, diff --git a/app/game-api/src/router/playAudit/index.ts b/app/game-api/src/router/playAudit/index.ts new file mode 100644 index 00000000..ec7de5ce --- /dev/null +++ b/app/game-api/src/router/playAudit/index.ts @@ -0,0 +1,182 @@ +import { z } from 'zod'; +import { canReadPlayAuditAccounts } from '@sammo-ts/common'; +import { router } from '../../trpc.js'; +import { + auditProcedure, + findAuditMonth, + pageResult, + readAudit, + readAuditWorld, + zAuditPage, + zAuditMonth, +} from './shared.js'; +import { + citySelect, + generalSelect, + projectCurrentCity, + projectCurrentGeneral, + zAuditCityData, + zAuditGeneralData, +} from './projection.js'; + +export const playAuditRouter = router({ + capabilities: auditProcedure.query(({ ctx }) => ({ + profileName: ctx.profile.name, + read: true, + accounts: canReadPlayAuditAccounts(ctx.auth!.user.roles, ctx.profile.name), + })), + coverage: auditProcedure + .input( + z + .object({ cursor: zAuditMonth.optional(), limit: z.number().int().min(1).max(200).default(50) }) + .strict() + .default({ limit: 50 }) + ) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const samples = world.serverId + ? await tx.playAuditMonth.findMany({ + where: { + serverId: world.serverId, + ...(input.cursor + ? { + OR: [ + { year: { gt: input.cursor.year } }, + { year: input.cursor.year, month: { gt: input.cursor.month } }, + { + year: input.cursor.year, + month: input.cursor.month, + kind: { gt: input.cursor.kind }, + }, + ], + } + : {}), + }, + take: input.limit + 1, + orderBy: [{ year: 'asc' }, { month: 'asc' }, { kind: 'asc' }], + select: { year: true, month: true, kind: true, settlementsComplete: true, createdAt: true }, + }) + : []; + return { + ...world, + status: !world.serverId + ? ('IDENTITY_MISSING' as const) + : samples.length + ? ('COLLECTED' as const) + : input.cursor + ? ('PAGE_EMPTY' as const) + : ('NOT_COLLECTED' as const), + samples: samples.slice(0, input.limit), + nextCursor: + samples.length > input.limit + ? { + year: samples[input.limit - 1]!.year, + month: samples[input.limit - 1]!.month, + kind: z.enum(['MONTH_END', 'FINAL']).parse(samples[input.limit - 1]!.kind), + } + : null, + }; + }) + ), + generals: auditProcedure + .input( + zAuditPage.extend({ + cityId: z.number().int().nonnegative().optional(), + population: z.enum(['human', 'npc', 'troopNpc']).optional(), + }) + ) + .query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + const npcState = + input.population === 'human' + ? { lt: 2 } + : input.population === 'npc' + ? { gte: 2, not: 5 } + : input.population === 'troopNpc' + ? 5 + : undefined; + const filter = { nationId: input.nationId, cityId: input.cityId, npcState }; + if (input.at) { + const sample = await findAuditMonth(tx, world, input.at); + const rows = sample + ? await tx.playAuditGeneral.findMany({ + where: { + sampleId: sample.id, + ...filter, + generalId: input.cursor === undefined ? undefined : { gt: input.cursor }, + }, + orderBy: { generalId: 'asc' }, + take: input.limit + 1, + select: { data: true }, + }) + : []; + return { + ...world, + sample, + collected: Boolean(sample), + ...pageResult( + rows.map((row) => zAuditGeneralData.parse(row.data)), + input.limit, + (row) => row.id + ), + }; + } + const rows = await tx.general.findMany({ + where: { ...filter, id: input.cursor === undefined ? undefined : { gt: input.cursor } }, + orderBy: { id: 'asc' }, + take: input.limit + 1, + select: generalSelect, + }); + return { + ...world, + sample: null, + collected: true, + ...pageResult(rows.map(projectCurrentGeneral), input.limit, (row) => row.id), + }; + }) + ), + cities: auditProcedure.input(zAuditPage).query(({ ctx, input }) => + readAudit(ctx, async (tx) => { + const world = await readAuditWorld(tx); + if (input.at) { + const sample = await findAuditMonth(tx, world, input.at); + const rows = sample + ? await tx.playAuditCity.findMany({ + where: { + sampleId: sample.id, + nationId: input.nationId, + cityId: input.cursor === undefined ? undefined : { gt: input.cursor }, + }, + orderBy: { cityId: 'asc' }, + take: input.limit + 1, + select: { data: true }, + }) + : []; + return { + ...world, + sample, + collected: Boolean(sample), + ...pageResult( + rows.map((row) => zAuditCityData.parse(row.data)), + input.limit, + (row) => row.id + ), + }; + } + const rows = await tx.city.findMany({ + where: { nationId: input.nationId, id: input.cursor === undefined ? undefined : { gt: input.cursor } }, + orderBy: { id: 'asc' }, + take: input.limit + 1, + select: citySelect, + }); + return { + ...world, + sample: null, + collected: true, + ...pageResult(rows.map(projectCurrentCity), input.limit, (row) => row.id), + }; + }) + ), +}); diff --git a/app/game-api/src/router/playAudit/projection.ts b/app/game-api/src/router/playAudit/projection.ts new file mode 100644 index 00000000..dce05646 --- /dev/null +++ b/app/game-api/src/router/playAudit/projection.ts @@ -0,0 +1,140 @@ +import { z } from 'zod'; +import { asNumber, asRecord } from '@sammo-ts/common'; +import type { GamePrisma } from '@sammo-ts/infra'; + +const dex = z.object({ dex1: z.number(), dex2: z.number(), dex3: z.number(), dex4: z.number(), dex5: z.number() }); +export const zAuditGeneralData = z.object({ + id: z.number(), + name: z.string(), + userId: z.string().nullable(), + nationId: z.number(), + cityId: z.number(), + troopId: z.number(), + npcState: z.number(), + gold: z.number(), + rice: z.number(), + stats: z.object({ leadership: z.number(), strength: z.number(), intelligence: z.number() }), + experience: z.number(), + dedication: z.number(), + officerLevel: z.number(), + injury: z.number(), + age: z.number(), + crew: z.number(), + crewTypeId: z.number(), + train: z.number(), + atmos: z.number(), + dex, + role: z.object({ + personality: z.string().nullable(), + specialDomestic: z.string().nullable(), + specialWar: z.string().nullable(), + items: z.object({ + horse: z.string().nullable(), + weapon: z.string().nullable(), + book: z.string().nullable(), + item: z.string().nullable(), + }), + }), +}); +export const zAuditCityData = z.object({ + id: z.number(), + name: z.string(), + nationId: z.number(), + level: z.number(), + state: z.number(), + population: z.number(), + populationMax: z.number(), + agriculture: z.number(), + agricultureMax: z.number(), + commerce: z.number(), + commerceMax: z.number(), + security: z.number(), + securityMax: z.number(), + wall: z.number(), + wallMax: z.number(), + defence: z.number(), + defenceMax: z.number(), + supplyState: z.number(), + frontState: z.number(), + trust: z.number(), +}); + +export const generalSelect = { + id: true, + name: true, + userId: true, + nationId: true, + cityId: true, + troopId: true, + npcState: true, + gold: true, + rice: true, + leadership: true, + strength: true, + intel: true, + experience: true, + dedication: true, + officerLevel: true, + injury: true, + age: true, + crew: true, + crewTypeId: true, + train: true, + atmos: true, + personalCode: true, + specialCode: true, + special2Code: true, + horseCode: true, + weaponCode: true, + bookCode: true, + itemCode: true, + meta: true, +} satisfies GamePrisma.GeneralSelect; +export const citySelect = { + id: true, + name: true, + nationId: true, + level: true, + population: true, + populationMax: true, + agriculture: true, + agricultureMax: true, + commerce: true, + commerceMax: true, + security: true, + securityMax: true, + wall: true, + wallMax: true, + defence: true, + defenceMax: true, + supplyState: true, + frontState: true, + trust: true, + meta: true, +} satisfies GamePrisma.CitySelect; +const code = (value: string): string | null => (value === 'None' ? null : value); +export const projectCurrentGeneral = ( + row: GamePrisma.GeneralGetPayload<{ select: typeof generalSelect }> +): z.infer => { + const meta = asRecord(row.meta); + return zAuditGeneralData.parse({ + ...row, + stats: { leadership: row.leadership, strength: row.strength, intelligence: row.intel }, + dex: Object.fromEntries(['dex1', 'dex2', 'dex3', 'dex4', 'dex5'].map((key) => [key, asNumber(meta[key], 0)])), + role: { + personality: code(row.personalCode), + specialDomestic: code(row.specialCode), + specialWar: code(row.special2Code), + items: { + horse: code(row.horseCode), + weapon: code(row.weaponCode), + book: code(row.bookCode), + item: code(row.itemCode), + }, + }, + }); +}; +export const projectCurrentCity = ( + row: GamePrisma.CityGetPayload<{ select: typeof citySelect }> +): z.infer => + zAuditCityData.parse({ ...row, state: Math.floor(asNumber(asRecord(row.meta).state, 0)) }); diff --git a/app/game-api/src/router/playAudit/shared.ts b/app/game-api/src/router/playAudit/shared.ts new file mode 100644 index 00000000..fcf68e8e --- /dev/null +++ b/app/game-api/src/router/playAudit/shared.ts @@ -0,0 +1,124 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { asRecord, canReadPlayAudit } from '@sammo-ts/common'; +import type { GamePrisma } from '@sammo-ts/infra'; +import type { GameApiContext } from '../../context.js'; +import { readOnlyAuthedProcedure } from '../../trpc.js'; + +export const auditProcedure = readOnlyAuthedProcedure.use(({ ctx, next }) => { + if ( + !ctx.auth || + ctx.auth.profile !== ctx.profile.name || + !canReadPlayAudit(ctx.auth.user.roles, ctx.profile.name) + ) { + throw new TRPCError({ code: 'FORBIDDEN', message: '이 프로필의 플레이 감사 권한이 필요합니다.' }); + } + return next(); +}); + +export const zAuditMonth = z + .object({ + year: z.number().int().min(0).max(9999), + month: z.number().int().min(1).max(12), + kind: z.enum(['MONTH_END', 'FINAL']).default('MONTH_END'), + }) + .strict(); +export const zAuditPage = z + .object({ + at: zAuditMonth.optional(), + nationId: z.number().int().nonnegative().optional(), + cursor: z.number().int().nonnegative().optional(), + limit: z.number().int().min(1).max(200).default(50), + }) + .strict(); +export const monthOrdinal = (year: number, month: number): number => year * 12 + month - 1; + +export const readAudit = async ( + ctx: GameApiContext, + read: (tx: GamePrisma.TransactionClient) => Promise +): Promise => { + if (!ctx.db.$transaction) + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '감사 조회 transaction을 사용할 수 없습니다.' }); + try { + return await ctx.db.$transaction(read, { isolationLevel: 'RepeatableRead', maxWait: 2000, timeout: 5000 }); + } catch (error) { + if (error instanceof TRPCError) throw error; + if (error && typeof error === 'object' && 'code' in error && ['P2028', 'P2034'].includes(String(error.code))) { + throw new TRPCError({ code: 'TIMEOUT', message: '조회가 지연되었습니다. 기간을 줄여 다시 조회해 주세요.' }); + } + throw error; + } +}; + +export const readAuditWorld = async (tx: GamePrisma.TransactionClient) => { + const world = await tx.worldState.findFirst({ + orderBy: { id: 'asc' }, + select: { + currentYear: true, + currentMonth: true, + lastTurnTick: true, + meta: true, + config: true, + }, + }); + if (!world) throw new TRPCError({ code: 'NOT_FOUND', message: '게임 상태가 없습니다.' }); + const meta = asRecord(world.meta); + const serverId = typeof meta.serverId === 'string' && meta.serverId.trim() ? meta.serverId : null; + const scenario = asRecord(meta.scenarioMeta); + const config = asRecord(world.config); + const startYear = + typeof scenario.startYear === 'number' + ? scenario.startYear + : typeof asRecord(config.scenarioMeta).startYear === 'number' + ? Number(asRecord(config.scenarioMeta).startYear) + : world.currentYear; + return { + serverId, + year: world.currentYear, + month: world.currentMonth, + startYear, + tick: world.lastTurnTick?.toString() ?? null, + asOf: new Date().toISOString(), + }; +}; +export type AuditWorld = Awaited>; + +export const findAuditMonth = async ( + tx: GamePrisma.TransactionClient, + world: AuditWorld, + at: z.infer +) => { + const ordinal = monthOrdinal(at.year, at.month); + if (at.year < world.startYear || ordinal > monthOrdinal(world.year, world.month)) { + throw new TRPCError({ code: 'BAD_REQUEST', message: '현재 기수의 게임 연월을 선택해 주세요.' }); + } + if (!world.serverId) return null; + return tx.playAuditMonth.findUnique({ + where: { + serverId_year_month_kind: { + serverId: world.serverId, + year: at.year, + month: at.month, + kind: at.kind, + }, + }, + select: { + id: true, + year: true, + month: true, + kind: true, + tick: true, + settlementsComplete: true, + createdAt: true, + }, + }); +}; + +export const pageResult = ( + rows: T[], + limit: number, + getId: (row: T) => number +): { items: T[]; nextCursor: number | null } => { + const items = rows.slice(0, limit); + return { items, nextCursor: rows.length > limit ? getId(items[items.length - 1]!) : null }; +}; diff --git a/app/game-api/test/securityTransport.integration.test.ts b/app/game-api/test/securityTransport.integration.test.ts index b4ebe304..50bd8c78 100644 --- a/app/game-api/test/securityTransport.integration.test.ts +++ b/app/game-api/test/securityTransport.integration.test.ts @@ -1,3 +1,4 @@ +import { projectCurrentGeneral } from '../src/router/playAudit/projection.js'; import fs from 'node:fs/promises'; import { createServer, type Server as HttpServer } from 'node:http'; import os from 'node:os'; @@ -8,6 +9,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { createGamePostgresConnector, + GamePrisma, createRedisConnector, enqueueWebPushOutboxEvents, resolveRedisConfigFromEnv, @@ -2147,6 +2149,135 @@ integration('game API security over HTTP transport', () => { }); }, 10_000); + it('play audit HTTP scope, no-general access, bounded history and token revocation', async () => { + const auditUserId = `audit-http-${process.pid}`; + const seasonId = `audit-season-${process.pid}`; + const sampleId = `${seasonId}:190:1`; + const originalWorld = await db.worldState.findUniqueOrThrow({ where: { id: fixtureWorldId } }); + const token = async (roles: string[], sanctions: GameSessionTokenPayload['sanctions'] = {}) => { + const payload = buildPayload(`audit-${roles.join('-')}`, sanctions, auditUserId); + payload.user.roles = roles; + const issued = await accessTokenStore.create(payload); + if (!issued) throw new Error('audit token fixture failed'); + return issued.accessToken; + }; + const get = async (path: string, accessToken?: string, input?: unknown) => { + const response = await fetch( + `${baseUrl}/trpc/playAudit.${path}${input === undefined ? '' : `?input=${encodeURIComponent(JSON.stringify(input))}`}`, + { + headers: accessToken ? { authorization: `Bearer ${accessToken}` } : {}, + } + ); + return { status: response.status, body: (await response.json()) as unknown }; + }; + try { + await db.worldState.update({ + where: { id: fixtureWorldId }, + data: { + currentYear: 190, + currentMonth: 2, + meta: { serverId: seasonId, scenarioMeta: { startYear: 190 } }, + }, + }); + const current = await db.general.findUniqueOrThrow({ where: { id: generalId } }); + const past = projectCurrentGeneral(current); + await db.playAuditMonth.create({ + data: { + id: sampleId, + serverId: seasonId, + year: 190, + month: 1, + kind: 'MONTH_END', + settlementsComplete: true, + hash: 'http-fixture', + generals: { + create: { + generalId, + nationId: current.nationId, + cityId: current.cityId, + npcState: current.npcState, + data: { ...past, name: '과거이름', hiddenSecret: 'must-not-expose' }, + }, + }, + }, + }); + const beforeInputs = await db.inputEvent.count(); + expect((await get('capabilities')).status).toBe(401); + for (const roles of [['user'], ['admin'], ['admin.audit.read'], ['admin.playAudit.read:other:default']]) { + expect((await get('capabilities', await token(roles))).status).toBe(403); + } + const admin = await token([`admin.playAudit.read:${profileName}`]); + expect(await db.general.findUnique({ where: { userId: auditUserId } })).toBeNull(); + expect((await get('capabilities', admin)).body).toMatchObject({ + result: { data: { read: true, accounts: false } }, + }); + const first = await get('generals', admin, { limit: 1 }); + expect(first.status).toBe(200); + expect(first.body).toMatchObject({ + result: { data: { nextCursor: generalId, items: [{ id: generalId }] } }, + }); + expect((await get('generals', admin, { limit: 1, cursor: generalId })).body).toMatchObject({ + result: { data: { items: [{ id: sameNationGeneralId }] } }, + }); + expect((await get('generals', admin, { population: 'npc' })).body).toMatchObject({ + result: { data: { items: [{ id: npcGeneralId }] } }, + }); + expect((await get('coverage', admin, { limit: 1 })).body).toMatchObject({ + result: { data: { status: 'COLLECTED', samples: [{ year: 190, month: 1 }] } }, + }); + expect((await get('coverage', admin, { cursor: { year: 190, month: 1 } })).body).toMatchObject({ + result: { data: { status: 'PAGE_EMPTY', samples: [] } }, + }); + expect((await get('generals', admin, { limit: 201 })).status).toBe(400); + expect((await get('generals', admin, { at: { year: 191, month: 1 } })).status).toBe(400); + const history = await get('generals', admin, { at: { year: 190, month: 1 } }); + expect(history.status).toBe(200); + expect(history.body).toMatchObject({ + result: { data: { collected: true, items: [{ name: '과거이름' }] } }, + }); + expect(JSON.stringify(history.body)).not.toContain('must-not-expose'); + expect((await get('generals', admin, { at: { year: 190, month: 2 } })).body).toMatchObject({ + result: { data: { collected: false, items: [] } }, + }); + const blocked = await token([`admin.playAudit.read:${profileName}`], { + serverRestrictions: { [profileName]: { blockedFeatures: ['gameplay'] } }, + }); + expect((await get('capabilities', blocked)).status).toBe(403); + await db.worldState.update({ + where: { id: fixtureWorldId }, + data: { + meta: { + serverId: `${seasonId}:new`, + scenarioMeta: { startYear: 190 }, + }, + }, + }); + expect((await get('generals', admin, { at: { year: 190, month: 1 } })).body).toMatchObject({ + result: { data: { collected: false, items: [] } }, + }); + expect(await db.inputEvent.count()).toBe(beforeInputs); + await redis!.client.publish( + `${redisPrefix}:flush`, + JSON.stringify({ + userId: auditUserId, + flushedAt: new Date().toISOString(), + reason: 'audit-role-revoked', + }) + ); + await expect.poll(async () => (await get('capabilities', admin)).status).toBe(401); + } finally { + await db.playAuditMonth.deleteMany({ where: { serverId: seasonId } }); + await db.worldState.update({ + where: { id: fixtureWorldId }, + data: { + meta: originalWorld.meta ?? GamePrisma.JsonNull, + currentYear: originalWorld.currentYear, + currentMonth: originalWorld.currentMonth, + }, + }); + } + }); + // Flush invalidates every token issued before the user watermark. Keep it // last so this lifecycle assertion cannot invalidate the actor tokens used // by the transport authorization matrix above. diff --git a/app/gateway-api/src/adminCapabilities.ts b/app/gateway-api/src/adminCapabilities.ts index 9f276389..f04ef990 100644 --- a/app/gateway-api/src/adminCapabilities.ts +++ b/app/gateway-api/src/adminCapabilities.ts @@ -10,6 +10,20 @@ export interface AdminCapabilityDefinition { } export const ADMIN_CAPABILITIES: readonly AdminCapabilityDefinition[] = [ + { + permission: 'admin.playAudit.read', + label: '플레이 감사 조회', + description: '지정 profile의 장수·도시·재정과 플레이 이력을 조회합니다.', + risk: 'HIGH', + scope: 'PROFILE', + }, + { + permission: 'admin.playAudit.accounts', + label: '플레이 감사 계정 조사', + description: '플레이 감사 권한을 가진 profile에서 계정 시도·접속지 연관 자료를 조사합니다.', + risk: 'HIGH', + scope: 'GLOBAL', + }, { permission: 'admin.notice.manage', label: 'Gateway 공지 관리', diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index a675ec77..5ecd8c15 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; +import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; import fastify, { type FastifyRequest } from 'fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { afterEach, describe, expect, it } from 'vitest'; @@ -248,6 +250,46 @@ describe('admin security over HTTP transport', () => { expect(harness.flushes).toEqual([]); }); + it('grants scoped play audit roles, flushes old tokens and transports the exact scope', async () => { + const grant = 'admin.playAudit.read:che:default'; + const harness = await createHarness(['admin.users.manage', grant, 'admin.playAudit.accounts']); + const granted = await postTrpc( + harness.baseUrl, + 'admin.users.updateRoles', + { + userId: harness.target.id, + roles: [grant, 'admin.playAudit.accounts'], + mode: 'grant', + reason: '감사 조회 권한 부여', + }, + harness.adminSessionToken + ); + expect(granted.response.status).toBe(200); + expect(harness.flushes).toEqual([{ userId: harness.target.id, reason: 'admin-roles-updated' }]); + const issued = await postTrpc(harness.baseUrl, 'auth.issueGameSession', { + sessionToken: harness.targetSessionToken, + profile: 'che:default', + }); + expect(issued.response.status).toBe(200); + const body = z.object({ result: z.object({ data: z.object({ gameToken: z.string() }) }) }).parse(issued.body); + const payload = decryptGameSessionToken(body.result.data.gameToken, 'transport-e2e-secret'); + expect(payload?.profile).toBe('che:default'); + expect(payload?.user.roles).toEqual(['user', grant, 'admin.playAudit.accounts']); + const rejected = await postTrpc( + harness.baseUrl, + 'admin.users.updateRoles', + { + userId: harness.target.id, + roles: ['admin.playAudit.read:*'], + mode: 'grant', + reason: '범위 확대 거부 검증', + }, + harness.adminSessionToken + ); + expect(rejected.response.status).toBe(403); + expect(harness.flushes).toHaveLength(1); + }); + it('accepts an equal scoped role and rejects wildcard escalation without mutating roles', async () => { const harness = await createHarness(); diff --git a/docs/design/play-audit-implementation.md b/docs/design/play-audit-implementation.md index 1079c20f..d671d6fb 100644 --- a/docs/design/play-audit-implementation.md +++ b/docs/design/play-audit-implementation.md @@ -1,7 +1,7 @@ # 플레이 감사 구현 기록과 수집 inventory [확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며, -월별 projection과 runtime 수집·DB transaction 연결을 구현했다. API·화면은 아직 미구현이다. +월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API를 연결했다. 화면과 나머지 조회는 미구현이다. Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다. ## 현재 구현 @@ -44,6 +44,39 @@ payload hash가 같은 재시도는 중복 저장하지 않고, 내용이 다르 world meta reload도 확인했다. 이전 기수 차단/정리, 최종 표본 전체 종료 경로, 정산 전후값의 별도 사건 원장은 아직 남아 있다. +## 프로필 조회·권한 구현 + +`game-api/router/playAudit`에 `capabilities`, `coverage`, `generals`, `cities`를 +추가했다. Gateway는 capability catalog만 제공하며 게임 자료를 대신 조회하지 않는다. +`admin.playAudit.read:`는 `che:default`처럼 scenario까지 정확히 비교한다. +기존 resolver와 같은 명시적 전체 grant와 `superuser/admin.superuser`를 허용하고 +일반 `admin`, Gateway 조치 감사 권한, 인게임 직책으로 접근을 추론하지 않는다. +공통 계정 권한 판정은 추가 `admin.playAudit.accounts`를 요구한다. 계정 조사 자체는 아직 없다. + +정상 bootstrap 첫 계정은 기존 발급 경로의 명시적 `superuser` role을 사용한다. +역할 없는 레거시 첫 계정에 대한 Gateway의 DB 기반 관리자 fallback은 게임 token에 +전달되지 않는다. 감사 API는 기존 게임 token의 명시적 role만 신뢰하며 Gateway의 +첫 계정 판정을 game DB에서 재현하지 않는다. 해당 계정은 기존 Gateway 권한 관리로 +감사 role을 부여할 수 있다. 이 경계는 일반 `admin`의 권한 확대로 해결하지 않는다. + +모든 조회는 인증·기존 제재·token profile 검사 후 실행한다. 장수 보유, 접속 가중치, +input_event 쓰기를 요구하지 않는다. 현재 세계 identity와 자료를 같은 RepeatableRead +transaction으로 읽으며 대기는 2초, 실행은 5초로 제한한다. 응답에 `asOf`, tick과 +현재 기수 identity를 포함하고 오래된 기수 ID를 client에게 입력받지 않는다. + +장수/도시 목록은 기본 50·최대 200, ID cursor로 페이지를 읽는다. `at`이 있으면 +현재 기수의 해당 월말/FINAL 표본에서 조회하고 미수집이면 `collected:false`다. +현재 일반 장수와 NPC/부대장 NPC, 국가·도시 필터를 지원한다. 저장·현재 자료 모두 +DTO allowlist를 적용해 임의 meta를 응답하지 않는다. `coverage`도 월 header만 +cursor 조회하며 원문·장수 목록을 같이 싣지 않는다. 아직 마감 월 캐시는 없다. + +실제 Gateway HTTP에서 새 role 부여·범위 확대 거부·flush 호출·암호화 game token의 +정확한 scope 전달을 확인했다. 별도 PostgreSQL/Redis와 실제 game HTTP에서는 +no-general 허용, 무인증·일반 admin·다른 profile·제재 거부, 200 상한, +과거 이름/미수집, 새 기수 전환 후 이전 표본 차단과 flush 후 401을 확인했다. + +국가 시계열/6개월 집계 API, 장수·도시 상세와 독립 로그, UI와 모든 조사 기능은 남았다. + ## 수집 지점과 쓰기 재검토 기준 Core commit은 `5ac961dfd17738dc4c39e6401f975296f39403a5`이다. diff --git a/packages/common/src/auth/playAudit.ts b/packages/common/src/auth/playAudit.ts new file mode 100644 index 00000000..e050cf02 --- /dev/null +++ b/packages/common/src/auth/playAudit.ts @@ -0,0 +1,14 @@ +/** Gateway capability resolver와 같은 명시적 전권·범위 규칙. 인게임 직책은 사용하지 않는다. */ +export const canReadPlayAudit = (roles: readonly string[], profileName: string): boolean => + roles.some( + (role) => + role === 'superuser' || + role === 'admin.superuser' || + role === 'admin.playAudit.read' || + role === 'admin.playAudit.read:*' || + role === `admin.playAudit.read:${profileName}` + ); + +export const canReadPlayAuditAccounts = (roles: readonly string[], profileName: string): boolean => + canReadPlayAudit(roles, profileName) && + roles.some((role) => role === 'superuser' || role === 'admin.superuser' || role === 'admin.playAudit.accounts'); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 63c6f087..43c6812c 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -32,3 +32,4 @@ export * from './gateway/runtimeDiagnostics.js'; export * from './game/accessPenalty.js'; export * from './http/trpcTransport.js'; export * from './webPush/types.js'; +export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js'; diff --git a/packages/common/test/play-audit-permissions.test.ts b/packages/common/test/play-audit-permissions.test.ts new file mode 100644 index 00000000..f0f95029 --- /dev/null +++ b/packages/common/test/play-audit-permissions.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { canReadPlayAudit, canReadPlayAuditAccounts } from '../src/auth/playAudit.js'; + +describe('play audit capability scope', () => { + it.each([ + 'superuser', + 'admin.superuser', + 'admin.playAudit.read', + 'admin.playAudit.read:*', + 'admin.playAudit.read:che:default', + ])('accepts explicit capability %s', (role) => expect(canReadPlayAudit([role], 'che:default')).toBe(true)); + it.each([ + 'admin', + 'user', + 'admin.audit.read', + 'admin.profiles.runtime:che:default', + 'admin.playAudit.read:che', + 'admin.playAudit.read:hwe:default', + 'admin.playAudit.accounts', + ])('rejects unrelated role %s', (role) => expect(canReadPlayAudit([role], 'che:default')).toBe(false)); + it('requires both scoped game audit and global account audit', () => { + expect(canReadPlayAuditAccounts(['admin.playAudit.read:che:default'], 'che:default')).toBe(false); + expect( + canReadPlayAuditAccounts(['admin.playAudit.read:che:default', 'admin.playAudit.accounts'], 'che:default') + ).toBe(true); + expect( + canReadPlayAuditAccounts(['admin.playAudit.read:che:default', 'admin.playAudit.accounts'], 'hwe:default') + ).toBe(false); + }); +}); diff --git a/packages/infra/src/db.ts b/packages/infra/src/db.ts index ba894c51..5ad49282 100644 --- a/packages/infra/src/db.ts +++ b/packages/infra/src/db.ts @@ -19,6 +19,10 @@ export interface DatabaseClient { diplomacy: GamePrisma.DiplomacyDelegate; diplomacyLetter: GamePrisma.DiplomacyLetterDelegate; yearbookHistory: GamePrisma.YearbookHistoryDelegate; + playAuditMonth: GamePrisma.PlayAuditMonthDelegate; + playAuditNation: GamePrisma.PlayAuditNationDelegate; + playAuditCity: GamePrisma.PlayAuditCityDelegate; + playAuditGeneral: GamePrisma.PlayAuditGeneralDelegate; rankData: GamePrisma.RankDataDelegate; hallOfFame: GamePrisma.HallOfFameDelegate; gameHistory: GamePrisma.GameHistoryDelegate;