feat: 프로필별 플레이 감사 조회와 권한 검사 연결
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { playAuditRouter } from './router/playAudit/index.js';
|
||||||
import { router } from './trpc.js';
|
import { router } from './trpc.js';
|
||||||
|
|
||||||
import { battleRouter } from './router/battle/index.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';
|
import { dashboardRouter } from './router/dashboard/index.js';
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
|
playAudit: playAuditRouter,
|
||||||
health: healthRouter,
|
health: healthRouter,
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
lobby: lobbyRouter,
|
lobby: lobbyRouter,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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<typeof zAuditGeneralData> => {
|
||||||
|
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<typeof zAuditCityData> =>
|
||||||
|
zAuditCityData.parse({ ...row, state: Math.floor(asNumber(asRecord(row.meta).state, 0)) });
|
||||||
@@ -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 <T>(
|
||||||
|
ctx: GameApiContext,
|
||||||
|
read: (tx: GamePrisma.TransactionClient) => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
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<ReturnType<typeof readAuditWorld>>;
|
||||||
|
|
||||||
|
export const findAuditMonth = async (
|
||||||
|
tx: GamePrisma.TransactionClient,
|
||||||
|
world: AuditWorld,
|
||||||
|
at: z.infer<typeof zAuditMonth>
|
||||||
|
) => {
|
||||||
|
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 = <T>(
|
||||||
|
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 };
|
||||||
|
};
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { projectCurrentGeneral } from '../src/router/playAudit/projection.js';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { createServer, type Server as HttpServer } from 'node:http';
|
import { createServer, type Server as HttpServer } from 'node:http';
|
||||||
import os from 'node:os';
|
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 { encryptGameSessionToken, type GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import {
|
import {
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
|
GamePrisma,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
enqueueWebPushOutboxEvents,
|
enqueueWebPushOutboxEvents,
|
||||||
resolveRedisConfigFromEnv,
|
resolveRedisConfigFromEnv,
|
||||||
@@ -2147,6 +2149,135 @@ integration('game API security over HTTP transport', () => {
|
|||||||
});
|
});
|
||||||
}, 10_000);
|
}, 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
|
// Flush invalidates every token issued before the user watermark. Keep it
|
||||||
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
// last so this lifecycle assertion cannot invalidate the actor tokens used
|
||||||
// by the transport authorization matrix above.
|
// by the transport authorization matrix above.
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ export interface AdminCapabilityDefinition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ADMIN_CAPABILITIES: readonly 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',
|
permission: 'admin.notice.manage',
|
||||||
label: 'Gateway 공지 관리',
|
label: 'Gateway 공지 관리',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken';
|
||||||
import fastify, { type FastifyRequest } from 'fastify';
|
import fastify, { type FastifyRequest } from 'fastify';
|
||||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
@@ -248,6 +250,46 @@ describe('admin security over HTTP transport', () => {
|
|||||||
expect(harness.flushes).toEqual([]);
|
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 () => {
|
it('accepts an equal scoped role and rejects wildcard escalation without mutating roles', async () => {
|
||||||
const harness = await createHarness();
|
const harness = await createHarness();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 플레이 감사 구현 기록과 수집 inventory
|
# 플레이 감사 구현 기록과 수집 inventory
|
||||||
|
|
||||||
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
|
[확정 설계](play-audit.md)의 P1~P6를 구현하는 작업 기록이다. 전체 기능은 진행 중이며,
|
||||||
월별 projection과 runtime 수집·DB transaction 연결을 구현했다. API·화면은 아직 미구현이다.
|
월별 projection과 runtime 수집·DB transaction 연결을 구현했다. 프로필 권한과 장수·도시 현재/월말 조회 API를 연결했다. 화면과 나머지 조회는 미구현이다.
|
||||||
Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다.
|
Push 요청 이후 `feat/play-audit` 전용 worktree에서 계속 구현하며 전체 완료 후 main 통합·push한다.
|
||||||
|
|
||||||
## 현재 구현
|
## 현재 구현
|
||||||
@@ -44,6 +44,39 @@ payload hash가 같은 재시도는 중복 저장하지 않고, 내용이 다르
|
|||||||
world meta reload도 확인했다. 이전 기수 차단/정리, 최종 표본 전체 종료 경로,
|
world meta reload도 확인했다. 이전 기수 차단/정리, 최종 표본 전체 종료 경로,
|
||||||
정산 전후값의 별도 사건 원장은 아직 남아 있다.
|
정산 전후값의 별도 사건 원장은 아직 남아 있다.
|
||||||
|
|
||||||
|
## 프로필 조회·권한 구현
|
||||||
|
|
||||||
|
`game-api/router/playAudit`에 `capabilities`, `coverage`, `generals`, `cities`를
|
||||||
|
추가했다. Gateway는 capability catalog만 제공하며 게임 자료를 대신 조회하지 않는다.
|
||||||
|
`admin.playAudit.read:<profileName>`는 `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`이다.
|
기준 Core commit은 `5ac961dfd17738dc4c39e6401f975296f39403a5`이다.
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -32,3 +32,4 @@ export * from './gateway/runtimeDiagnostics.js';
|
|||||||
export * from './game/accessPenalty.js';
|
export * from './game/accessPenalty.js';
|
||||||
export * from './http/trpcTransport.js';
|
export * from './http/trpcTransport.js';
|
||||||
export * from './webPush/types.js';
|
export * from './webPush/types.js';
|
||||||
|
export { canReadPlayAudit, canReadPlayAuditAccounts } from './auth/playAudit.js';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,10 @@ export interface DatabaseClient {
|
|||||||
diplomacy: GamePrisma.DiplomacyDelegate;
|
diplomacy: GamePrisma.DiplomacyDelegate;
|
||||||
diplomacyLetter: GamePrisma.DiplomacyLetterDelegate;
|
diplomacyLetter: GamePrisma.DiplomacyLetterDelegate;
|
||||||
yearbookHistory: GamePrisma.YearbookHistoryDelegate;
|
yearbookHistory: GamePrisma.YearbookHistoryDelegate;
|
||||||
|
playAuditMonth: GamePrisma.PlayAuditMonthDelegate;
|
||||||
|
playAuditNation: GamePrisma.PlayAuditNationDelegate;
|
||||||
|
playAuditCity: GamePrisma.PlayAuditCityDelegate;
|
||||||
|
playAuditGeneral: GamePrisma.PlayAuditGeneralDelegate;
|
||||||
rankData: GamePrisma.RankDataDelegate;
|
rankData: GamePrisma.RankDataDelegate;
|
||||||
hallOfFame: GamePrisma.HallOfFameDelegate;
|
hallOfFame: GamePrisma.HallOfFameDelegate;
|
||||||
gameHistory: GamePrisma.GameHistoryDelegate;
|
gameHistory: GamePrisma.GameHistoryDelegate;
|
||||||
|
|||||||
Reference in New Issue
Block a user