멀티테넌트 격리 — collectionGroup 가드 레일
정책 근거: CLAUDE.md · "assertTenantFilter 3-layer defense (unit test + CI AST parser + runtime Sentry)"
소스: apps/web/lib/firebase/guarded-query.ts
왜 필요한가
collectionGroup("X") 쿼리는 Firestore 전체에서 컬렉션명 "X" 인 모든 문서를 가로지른다. tenants/{tid}/X/... 구조의 멀티테넌트 데이터에서 tenantId 필터가 누락되면 다른 테넌트의 데이터가 그대로 응답에 섞인다. 단일 실수로 비식별화 없이 교차-테넌트 리크 발생.
해결은 호출 지점에서의 강제 — raw collectionGroup() 호출 금지, guardedCollectionGroup* 만 허용.
3-Layer 방어
| Layer | 구현 | 상태 |
|---|---|---|
| 1. 런타임 가드 유틸 | guardedAdminCollectionGroup · guardedClientCollectionGroup | ✅ 2026-04-20 |
| 2. CI AST 파서 (ESLint no-restricted-syntax) | eslint.config.mjs — collectionGroup( 직접 호출 시 build fail | ✅ 2026-04-20 |
| 3. 런타임 Sentry | guardedQuery 래퍼에서 예외 발생 시 자동 알림 | ⏳ Phase 1 |
사용법
Server Action · Cloud Functions (Admin SDK)
import { guardedAdminCollectionGroup } from "@/lib/firebase/guarded-query";
import { adminDb } from "@/lib/firebase/admin";
// ✅ 일반 (tenant 격리 필수)
const snap = await guardedAdminCollectionGroup(adminDb, "hearings", { tenantId })
.where("date", ">=", today)
.orderBy("date", "asc")
.limit(500)
.get();
// ✅ 면제 (tenant 역조회가 본질적으로 필요한 플로우)
const snap = await guardedAdminCollectionGroup(adminDb, "portalTokens", {
exempt: "portal-token-lookup",
})
.where("__name__", "==", token)
.limit(1)
.get();
브라우저 onSnapshot 구독 (Client SDK)
import { guardedClientCollectionGroup } from "@/lib/firebase/guarded-query";
import { db } from "@/lib/firebase/client";
import { onSnapshot, orderBy, query, where, limit } from "firebase/firestore";
const q = query(
guardedClientCollectionGroup(db, "hearings", { tenantId }),
orderBy("date", "asc"),
limit(500),
);
const unsub = onSnapshot(q, (snap) => { /* ... */ });
면제 (Exemption) 규칙
면제는 tenantId 를 모르는 상태에서 tenant 자체를 역조회 해야 하는 보안 플로우에만 허용.
ExemptionReason | 플로우 | 호출부 |
|---|---|---|
"portal-token-lookup" | 의뢰인 포털 접속: 4자리 코드 또는 URL 토큰으로 어느 tenant 의 토큰인지 조회 | portal-token-actions.ts · portal-access-code-actions.ts |
"invite-code-lookup" | 사무소 초대 코드: 신규 사용자가 가입 시 코드로 tenant 소속 조회 | (setup)/actions/invite.ts · settings/_actions/members-actions.ts |
신규 면제 추가 원칙:
ExemptionReasonunion 확장은 PR 리뷰 필수- 본 문서의 면제 표에 플로우·호출부 동시 기재
- 면제 플로우는 limit(1) 필수 (브루트포스 방어) + rate-limit 적용 여부 명시
기존 호출부 인벤토리 (2026-04-20 마이그레이션 완료)
| # | 파일 | 컬렉션 | 처리 | 상태 |
|---|---|---|---|---|
| 1 | (workspace)/revenue/page.tsx | recoveries | { tenantId } | ✅ |
| 2 | (setup)/actions/invite.ts | members | { exempt: "invite-code-lookup" } | ✅ |
| 3 | (portal)/portal/_actions/portal-token-actions.ts | portalTokens | { exempt: "portal-token-lookup" } | ✅ |
| 4 | (portal)/portal/_actions/portal-access-code-actions.ts | portalTokens | { exempt: "portal-token-lookup" } | ✅ |
| 5 | (workspace)/calendar/page.tsx | hearings | { tenantId } | ✅ |
| 6 | (workspace)/settings/_actions/members-actions.ts | members | { exempt: "invite-code-lookup" } | ✅ |
| 7 | (workspace)/calendar/_lib/useCalendar.ts | hearings (client) | { tenantId } | ✅ |
현 상태: 7건 모두 guardedCollectionGroup 으로 마이그레이션 완료. Layer 2 (ESLint no-restricted-syntax) 가 신규 raw 호출을 PR build fail 로 차단.
잘못된 사용 (반례)
// ❌ collectionGroup 직접 호출 — Layer 2 AST 파서가 차단
const snap = await adminDb.collectionGroup("hearings").where(...).get();
// ❌ tenantId 없이 guarded 호출 — 런타임 throw
guardedAdminCollectionGroup(adminDb, "hearings", {});
// ❌ 임의 문자열 exempt — TypeScript 에서 차단 (union type)
guardedAdminCollectionGroup(adminDb, "foo", { exempt: "my-custom-reason" as never });
// ❌ exempt 와 tenantId 동시 — 런타임 throw
guardedAdminCollectionGroup(adminDb, "hearings", {
tenantId: "t1",
exempt: "portal-token-lookup",
});
관련 정책
CLAUDE.md· 멀티테넌트 격리 원칙apps/web/lib/firebase/paths.ts· tenant 경로 상수apps/web/lib/firebase/require-ownership.ts· mutating Server Action 오너십 검증