본문으로 건너뛰기

멀티테넌트 격리 — 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.mjscollectionGroup( 직접 호출 시 build fail✅ 2026-04-20
3. 런타임 SentryguardedQuery 래퍼에서 예외 발생 시 자동 알림⏳ 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

신규 면제 추가 원칙:

  1. ExemptionReason union 확장은 PR 리뷰 필수
  2. 본 문서의 면제 표에 플로우·호출부 동시 기재
  3. 면제 플로우는 limit(1) 필수 (브루트포스 방어) + rate-limit 적용 여부 명시

기존 호출부 인벤토리 (2026-04-20 마이그레이션 완료)

#파일컬렉션처리상태
1(workspace)/revenue/page.tsxrecoveries{ tenantId }
2(setup)/actions/invite.tsmembers{ exempt: "invite-code-lookup" }
3(portal)/portal/_actions/portal-token-actions.tsportalTokens{ exempt: "portal-token-lookup" }
4(portal)/portal/_actions/portal-access-code-actions.tsportalTokens{ exempt: "portal-token-lookup" }
5(workspace)/calendar/page.tsxhearings{ tenantId }
6(workspace)/settings/_actions/members-actions.tsmembers{ exempt: "invite-code-lookup" }
7(workspace)/calendar/_lib/useCalendar.tshearings (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 오너십 검증