From 65ba44a1d173914d3040469c97ee43fd4379fd6e Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sat, 26 Sep 2026 20:46:41 +0900 Subject: [PATCH] =?UTF-8?q?Admin=20=EC=93=B0=EA=B8=B0=20=EA=B4=80=EB=AC=B8?= =?UTF-8?q?=20=EA=B0=95=ED=99=94=20=E2=80=94=20=EC=9B=90=EC=9E=A5=20=C2=B7?= =?UTF-8?q?=20=EC=A7=80=ED=91=9C=20=C2=B7=20=EA=B0=90=EC=82=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EA=B3=BC=20=EC=84=9C=EB=B2=84=20=EC=82=B0=EC=B6=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B0=A8=EB=8B=A8=20=C2=B7=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EC=A0=84=EC=9A=A9=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=C2=B7=20=EA=B1=B0=EB=B6=80=20=EA=B8=B0=EB=A1=9D=20=C2=B7=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EA=B2=BD=EA=B3=84=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/src/admin/admin.ts | 74 +++++++++++++++++-- .../functions/test/admin_write.test.ts | 69 +++++++++++++++++ app/firebase/functions/test/support.ts | 25 +++++++ 3 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 app/firebase/functions/test/admin_write.test.ts create mode 100644 app/firebase/functions/test/support.ts diff --git a/app/firebase/functions/src/admin/admin.ts b/app/firebase/functions/src/admin/admin.ts index d2a5589..d6aff75 100644 --- a/app/firebase/functions/src/admin/admin.ts +++ b/app/firebase/functions/src/admin/admin.ts @@ -12,25 +12,85 @@ import { ERR, fail } from '../core/errors.js'; * 여기서만 남길 수 있고, rbac-summary 가 요구하는 작업 로그가 그것이다. */ -/** 원장·지갑은 이 경로로 못 쓴다. 재화는 반드시 원장 함수를 지나야 한다. */ +/** + * 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**. + * 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다. + */ const FORBIDDEN = [ - /popTransactions/, /popLots/, /\/wallet\//, /fpiSnapshots/, + /popTransactions/, /popLots/, /\/wallet\//, /fpiSnapshots/, /growthEvents/, + /\/fpiEvents\//, /\/eclipseRounds\//, /\/fandomDailyStats\//, /^\/?adminActionLogs\//, ]; +/** 서버 산출 필드 — 문서 자체는 쓸 수 있어도 이 필드는 master 도 못 바꾼다. */ +const DERIVED_FIELDS: Readonly> = { + fandoms: ['fpiScore', 'fpiAxes', 'fpiRanks', 'fpiRanksPrev', 'sfpi', 'growthPop', + 'rankingEligible', 'eclipsePenaltyRevision'], + users: ['userExp', 'userLevel', 'passportTier', 'totalContributedPop'], +}; + +/** + * 팬덤 문서의 Eclipse 운영값(팬덤별 일정 · 설정, C-24)은 rules 의 `fandoms` 쓰기처럼 + * master 만 바꾼다. 원장 · 지표는 아니므로 master 에게는 열어 둔다. + */ +const MASTER_ONLY_FIELDS: Readonly> = { + fandoms: ['eclipseSettings', 'eclipseSchedule'], +}; + +/** + * `firestore.rules` 에서 **master 전용 쓰기**인 최상위 컬렉션(rules 「allow write: if isMaster()」). + * + * 이 함수는 Admin SDK 라 규칙을 거치지 않는다. 여기서 같은 선을 긋지 않으면 ops 가 + * 재화 정책(`config`) · 퀘스트 · 행성 단계 같은 마스터 전용 문서를 우회해 쓴다. + * `configPrivate` 는 규칙상 서버 전용이라 함께 막는다. + */ +const MASTER_ONLY = new Set([ + 'config', 'configPrivate', 'appConfig', 'i18n', 'planetStages', 'quests', 'badges', +]); + +/** 거부도 남긴다(FR-ADM-01). 로그 실패가 원래 거부 사유를 가리지 않게 삼킨다. */ +async function logDenied(actorUid: string, role: unknown, targetPath: unknown, why: string) { + try { + await getFirestore().collection('adminActionLogs').add({ + actorUid, role: typeof role === 'string' ? role : null, + targetPath: typeof targetPath === 'string' ? targetPath : null, + denied: true, deniedReason: why, createdAt: FieldValue.serverTimestamp(), + }); + } catch { /* 거부 응답이 우선이다 */ } +} + export const fnAdminWrite = onCall(async (req) => { const actorUid = callerUid(req); - const role = requireRole(req, ['master', 'ops']); + const rawPath = (req.data as Record | null)?.targetPath; + let role: 'master' | 'ops'; + try { + role = requireRole(req, ['master', 'ops']) as 'master' | 'ops'; + } catch (e) { + await logDenied(actorUid, req.auth?.token?.role, rawPath, 'ROLE'); + throw e; + } const targetPath = str(req.data, 'targetPath'); const reason = str(req.data, 'reason'); const patch = (req.data as Record)?.patch; + const segments0 = targetPath.split('/').filter(Boolean); + // 최상위 문서(`fandoms/{id}` · `users/{uid}`)에만 산출 필드가 있다. + const docKind = segments0.length === 2 ? segments0[0]! : ''; + const keys = typeof patch === 'object' && patch !== null ? Object.keys(patch) : []; + if (FORBIDDEN.some((re) => re.test(targetPath)) || + keys.some((k) => DERIVED_FIELDS[docKind]?.includes(k))) { + await logDenied(actorUid, role, targetPath, 'LEDGER_OR_DERIVED'); + // 관리자라도 잔액 · 지표를 직접 고칠 수 없다. 별첨1 ② 「원장 정합성」의 마지막 방벽이다. + fail(ERR.permissionDenied, '원장 · 지표 · 감사 기록은 전용 함수로만 변경할 수 있습니다.'); + } + if (role !== 'master' && (MASTER_ONLY.has(segments0[0] ?? '') || + keys.some((k) => MASTER_ONLY_FIELDS[docKind]?.includes(k)))) { + await logDenied(actorUid, role, targetPath, 'MASTER_ONLY'); + fail(ERR.permissionDenied, '마스터 권한이 필요한 문서입니다.'); + } + if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) { fail(ERR.permissionDenied, '변경 내용이 올바르지 않습니다.'); } - if (FORBIDDEN.some((re) => re.test(targetPath))) { - // 관리자라도 잔액을 직접 고칠 수 없다. 별첨1 ② 「원장 정합성」의 마지막 방벽이다. - fail(ERR.permissionDenied, '재화 관련 문서는 원장 함수로만 변경할 수 있습니다.'); - } // 경로 깊이가 짝수여야 문서다. 홀수면 컬렉션이라 통째로 덮어쓸 위험이 있다. const segments = targetPath.split('/').filter(Boolean); if (segments.length === 0 || segments.length % 2 !== 0) { diff --git a/app/firebase/functions/test/admin_write.test.ts b/app/firebase/functions/test/admin_write.test.ts new file mode 100644 index 0000000..2f6e432 --- /dev/null +++ b/app/firebase/functions/test/admin_write.test.ts @@ -0,0 +1,69 @@ +import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest'; +import { getFirestore } from 'firebase-admin/firestore'; +import { bootAdmin, shutdownAdmin } from './support.js'; +import { fnAdminWrite } from '../src/admin/admin.js'; + +/** + * `fn-admin-write` 권한 경계 — firestore.rules 에서 master 전용인 문서를 ops 가 이 함수로 + * 우회해 쓰지 못한다. 거부도 `adminActionLogs` 에 남는다(FR-ADM-01). + */ +const run = (role: string | undefined, targetPath: string, + patch: Record = { attendPop: 999 }) => + fnAdminWrite.run({ + data: { targetPath, reason: '시험', patch }, + auth: { uid: `admin-${role ?? 'none'}`, token: role ? { role } : {} }, + } as Parameters[0]); + +const denials = async (actorUid: string) => + (await getFirestore().collection('adminActionLogs') + .where('actorUid', '==', actorUid).where('denied', '==', true).get()).size; + +beforeAll(() => { bootAdmin(); }); +beforeEach(async () => { + const db = getFirestore(); + await db.recursiveDelete(db.collection('adminActionLogs')); + await db.doc('config/admin-write-test').delete(); + await db.doc('notices/admin-write-test').delete(); +}); +afterAll(async () => { await shutdownAdmin(); }); + +it('ops 는 master 전용 문서(config)를 못 쓰고 거부가 기록된다 · master 는 쓴다', async () => { + await expect(run('ops', 'config/admin-write-test')) + .rejects.toMatchObject({ details: { code: 'E-PERM-01' } }); + expect((await getFirestore().doc('config/admin-write-test').get()).exists).toBe(false); + expect(await denials('admin-ops')).toBe(1); + + await run('master', 'config/admin-write-test'); + expect((await getFirestore().doc('config/admin-write-test').get()).get('attendPop')).toBe(999); +}); + +it('ops 는 master 전용이 아닌 문서는 그대로 쓴다 · 역할 없는 호출은 거부가 기록된다', async () => { + await run('ops', 'notices/admin-write-test'); + expect((await getFirestore().doc('notices/admin-write-test').get()).exists).toBe(true); + + await expect(run(undefined, 'notices/admin-write-test')) + .rejects.toMatchObject({ details: { code: 'E-PERM-01' } }); + expect(await denials('admin-none')).toBe(1); +}); + +it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못 쓴다 · Eclipse 운영값은 master 만', async () => { + const denied = (role: string, path: string, patch?: Record) => + expect(run(role, path, patch)).rejects.toMatchObject({ details: { code: 'E-PERM-01' } }); + await denied('master', 'fandoms/f1/fpiEvents/e1'); + await denied('master', 'fandoms/f1/eclipseRounds/r1'); + await denied('master', 'fandoms/f1/fandomDailyStats/2026-09-20'); + await denied('master', 'adminActionLogs/l1'); + await denied('master', 'fandoms/admin-write-test', { fpiScore: 1 }); + await denied('master', 'fandoms/admin-write-test', { growthPop: 1 }); + await denied('master', 'users/u1', { userExp: 1 }); + await denied('master', 'users/u1', { passportTier: 'DIAMOND' }); + await denied('ops', 'fandoms/admin-write-test', { eclipseSchedule: [] }); + expect(await denials('admin-master')).toBe(8); + + await run('master', 'fandoms/admin-write-test', { eclipseSchedule: [{ hhmm: '09:00' }] }); + await run('ops', 'fandoms/admin-write-test', { description: '운영 문구' }); + const doc = await getFirestore().doc('fandoms/admin-write-test').get(); + expect(doc.get('eclipseSchedule')).toEqual([{ hhmm: '09:00' }]); + expect(doc.get('fpiScore')).toBeUndefined(); + await getFirestore().doc('fandoms/admin-write-test').delete(); +}); diff --git a/app/firebase/functions/test/support.ts b/app/firebase/functions/test/support.ts new file mode 100644 index 0000000..6b05e89 --- /dev/null +++ b/app/firebase/functions/test/support.ts @@ -0,0 +1,25 @@ +import { initializeApp, deleteApp, getApps, type App } from 'firebase-admin/app'; +import { getFirestore } from 'firebase-admin/firestore'; + +/** + * 에뮬레이터 전용 테스트 환경. + * + * `FIRESTORE_EMULATOR_HOST` 가 없으면 **실 프로젝트로 붙을 위험**이 있으므로 + * 바로 실패시킨다. 테스트가 운영 데이터를 건드리는 사고는 한 번이면 치명적이다. + */ +export const PROJECT_ID = 'demo-fanuzu'; + +export function bootAdmin(): App { + if (!process.env.FIRESTORE_EMULATOR_HOST) { + throw new Error( + 'FIRESTORE_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.', + ); + } + const existing = getApps()[0]; + if (existing) return existing; + return initializeApp({ projectId: PROJECT_ID }); +} + +export async function shutdownAdmin(): Promise { + for (const app of getApps()) await deleteApp(app); +}