Admin 쓰기 관문 강화 — 원장 · 지표 · 감사 기록과 서버 산출 필드 차단 · 마스터 전용 문서 · 거부 기록 · 권한 경계 시험
This commit is contained in:
@@ -12,25 +12,85 @@ import { ERR, fail } from '../core/errors.js';
|
|||||||
* 여기서만 남길 수 있고, rbac-summary 가 요구하는 작업 로그가 그것이다.
|
* 여기서만 남길 수 있고, rbac-summary 가 요구하는 작업 로그가 그것이다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 원장·지갑은 이 경로로 못 쓴다. 재화는 반드시 원장 함수를 지나야 한다. */
|
/**
|
||||||
|
* 원장 · 지표 · 감사 기록은 이 경로로 못 쓴다 — **master 도 불가**.
|
||||||
|
* 반드시 원장 · 집계 함수를 지나야 하고, 감사 로그는 고칠 수 없어야 감사 로그다.
|
||||||
|
*/
|
||||||
const FORBIDDEN = [
|
const FORBIDDEN = [
|
||||||
/popTransactions/, /popLots/, /\/wallet\//, /fpiSnapshots/,
|
/popTransactions/, /popLots/, /\/wallet\//, /fpiSnapshots/, /growthEvents/,
|
||||||
|
/\/fpiEvents\//, /\/eclipseRounds\//, /\/fandomDailyStats\//, /^\/?adminActionLogs\//,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 서버 산출 필드 — 문서 자체는 쓸 수 있어도 이 필드는 master 도 못 바꾼다. */
|
||||||
|
const DERIVED_FIELDS: Readonly<Record<string, readonly string[]>> = {
|
||||||
|
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<Record<string, readonly string[]>> = {
|
||||||
|
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) => {
|
export const fnAdminWrite = onCall(async (req) => {
|
||||||
const actorUid = callerUid(req);
|
const actorUid = callerUid(req);
|
||||||
const role = requireRole(req, ['master', 'ops']);
|
const rawPath = (req.data as Record<string, unknown> | 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 targetPath = str(req.data, 'targetPath');
|
||||||
const reason = str(req.data, 'reason');
|
const reason = str(req.data, 'reason');
|
||||||
const patch = (req.data as Record<string, unknown>)?.patch;
|
const patch = (req.data as Record<string, unknown>)?.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)) {
|
if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) {
|
||||||
fail(ERR.permissionDenied, '변경 내용이 올바르지 않습니다.');
|
fail(ERR.permissionDenied, '변경 내용이 올바르지 않습니다.');
|
||||||
}
|
}
|
||||||
if (FORBIDDEN.some((re) => re.test(targetPath))) {
|
|
||||||
// 관리자라도 잔액을 직접 고칠 수 없다. 별첨1 ② 「원장 정합성」의 마지막 방벽이다.
|
|
||||||
fail(ERR.permissionDenied, '재화 관련 문서는 원장 함수로만 변경할 수 있습니다.');
|
|
||||||
}
|
|
||||||
// 경로 깊이가 짝수여야 문서다. 홀수면 컬렉션이라 통째로 덮어쓸 위험이 있다.
|
// 경로 깊이가 짝수여야 문서다. 홀수면 컬렉션이라 통째로 덮어쓸 위험이 있다.
|
||||||
const segments = targetPath.split('/').filter(Boolean);
|
const segments = targetPath.split('/').filter(Boolean);
|
||||||
if (segments.length === 0 || segments.length % 2 !== 0) {
|
if (segments.length === 0 || segments.length % 2 !== 0) {
|
||||||
|
|||||||
@@ -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<string, unknown> = { attendPop: 999 }) =>
|
||||||
|
fnAdminWrite.run({
|
||||||
|
data: { targetPath, reason: '시험', patch },
|
||||||
|
auth: { uid: `admin-${role ?? 'none'}`, token: role ? { role } : {} },
|
||||||
|
} as Parameters<typeof fnAdminWrite.run>[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<string, unknown>) =>
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
for (const app of getApps()) await deleteApp(app);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user