# ADR-118: Row-Level Security & Tenant DB Isolation **狀態**:Accepted **日期**:2026-05-03(台北) **決策者**:統帥 **範圍**:PostgreSQL RLS 設計、bypass role、application-layer RLS 補充、migration 策略 **關聯**:ADR-107(儲存)、ADR-115(tenant onboarding)、ADR-116(security) --- ## 背景 INV-2 確認:現有 20 張業務資料表全部缺少 `project_id` 欄位,沒有任何 Row-Level Security (RLS) 防護。 **風險**: - EwoooC 的 LLM run 可透過 SQL injection 讀取 AWOOOI 的 incidents / knowledge_entries - 跨 tenant audit_logs 洩漏 - RLS 缺失讓 ADR-115 D5 的跨 tenant 隔離驗收無法通過 **設計目標**: 1. PostgreSQL 原生 RLS(不依賴 application-layer 單點防護) 2. 不影響現有的 legacy_awoooi_default 背景 loop(非破壞式上線) 3. Phase 1 migration 可分批上線(不需要一次 lock 所有表) --- ## 決策 ### D1 — PostgreSQL RLS 架構 **DB Role 分層**: ```sql -- 應用程式角色(最小權限) CREATE ROLE awooop_app LOGIN; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO awooop_app; -- Platform 管理角色(可 bypass RLS) CREATE ROLE awooop_platform_admin LOGIN; GRANT awooop_app TO awooop_platform_admin; ALTER ROLE awooop_platform_admin BYPASSRLS; -- Migration 角色(可 bypass RLS,用於 Alembic) CREATE ROLE awooop_migration LOGIN; GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO awooop_migration; ALTER ROLE awooop_migration BYPASSRLS; ``` **注意**: - 應用程式連線一律使用 `awooop_app`(受 RLS 約束) - 背景 loop 在過渡期使用 `awooop_platform_admin`(bypass RLS,避免 loop 中斷) - Alembic migration 使用 `awooop_migration` ### D2 — project_id Context Variable 注入 應用程式在每次 DB 操作前,透過 `SET LOCAL` 設置當前 `project_id`: ```python # apps/api/src/db/session.py from contextvars import ContextVar _project_id_ctx: ContextVar[str] = ContextVar("project_id", default="") async def get_db_session() -> AsyncSession: async with AsyncSession(engine) as session: project_id = _project_id_ctx.get("") if project_id: # SET LOCAL 只在當前事務中有效 await session.execute( text("SET LOCAL app.project_id = :pid"), {"pid": project_id} ) yield session ``` **API 層注入**: ```python # FastAPI middleware / dependency async def inject_project_id( request: Request, project_id: str = Path(...), db: AsyncSession = Depends(get_db_session) ): _project_id_ctx.set(project_id) # SET LOCAL 已在 get_db_session 中執行 yield ``` ### D3 — RLS Policy 標準模板 每張有 `project_id` 的表套用統一 RLS 模板: ```sql -- 以 incidents 表為例 ALTER TABLE incidents ENABLE ROW LEVEL SECURITY; ALTER TABLE incidents FORCE ROW LEVEL SECURITY; -- 連 table owner 也受約束 -- 隔離 policy:只能看到自己 project 的資料 CREATE POLICY incidents_tenant_isolation ON incidents USING ( project_id = current_setting('app.project_id', TRUE) OR current_setting('app.project_id', TRUE) = '' -- 空字串 = 允許(過渡期) ); -- platform_internal 操作:project_id = '__platform__' 可讀全部 CREATE POLICY incidents_platform_admin ON incidents USING ( current_setting('app.project_id', TRUE) = '__platform__' ); ``` **`current_setting('app.project_id', TRUE)` 說明**: - 第二個參數 `TRUE` = missing_ok,若 GUC 未設置則返回空字串 - 空字串在過渡期視為「允許」(legacy_awoooi_default 背景 loop 可繼續運行) - Phase 3 後,空字串 policy 移除(強制要求 project_id) ### D4 — 分批 RLS 上線策略 **Batch 1(Phase 1 migration,高風險表)**: - `incidents` - `knowledge_entries` - `playbooks` - `audit_logs` **Batch 2(Phase 2)**: - `approval_records` - `mcp_audit_log` - `auto_repair_executions` - `alert_operation_log` **Batch 3(Phase 3,其餘表)**: - 其餘 12 張表(見 INV-2) **每個 batch 的驗收**: ```sql -- 驗收測試:ewoooc project 無法看到 awoooi 資料 SET LOCAL app.project_id = 'ewoooc'; SELECT count(*) FROM incidents; -- 必須為 0(awoooi 的 incident 不可見) SET LOCAL app.project_id = 'awoooi'; SELECT count(*) FROM incidents; -- 必須為 awoooi 的 incident 數量 ``` ### D5 — Application-Layer RLS 補充 PostgreSQL RLS 是第一道防線。應用層在 Repository 層加第二道: ```python # apps/api/src/repositories/incident_repository.py class IncidentRepository: def __init__(self, db: AsyncSession, project_id: str): self._db = db self._project_id = project_id async def get_incident(self, incident_id: str) -> Incident | None: # Application-layer RLS:明確 WHERE project_id result = await self._db.execute( select(Incident).where( Incident.incident_id == incident_id, Incident.project_id == self._project_id # 明確過濾 ) ) return result.scalar_one_or_none() ``` **雙層防禦原則**: - DB RLS = 最後防線(即使 application bug 也不洩漏) - Application WHERE = 第一道(明確意圖,可測試) ### D6 — `awooop_published_revisions` View 的 RLS Contract revision 的 view 必須加 RLS(確保 runtime 不跨 tenant 讀 contract): ```sql -- 在 awooop_contract_revisions 表啟用 RLS(由 ADR-107/ADR-112 建立的表) ALTER TABLE awooop_contract_revisions ENABLE ROW LEVEL SECURITY; CREATE POLICY contract_revisions_tenant ON awooop_contract_revisions USING ( project_id = current_setting('app.project_id', TRUE) OR current_setting('app.project_id', TRUE) IN ('', '__platform__') ); ``` --- ## 過渡期安全性評估 | 期間 | 空字串 policy | 等效行為 | 可接受性 | |------|--------------|---------|---------| | Phase 1~2 | 允許(空字串 = bypass)| 等同現在無 RLS | ✅ 比現在多一層(應用層)| | Phase 3 | 移除空字串允許 | 真正強制隔離 | ✅ 目標狀態 | | EwoooC onboarding 前 | 必須完成 Phase 3 | RLS 強制隔離 | ✅ 前置條件 | --- ## 後果 ### Benefits - PostgreSQL 原生 RLS:即使應用程式有 SQL injection 漏洞,跨 tenant 資料仍不可見 - 分批上線:不影響現有 31 個背景 loop 的運行 - `__platform__` project_id 作為 superuser pattern,保留 platform_internal 完整讀寫能力 ### Costs - 每個 DB session 需要 `SET LOCAL app.project_id`(約 0.1ms 額外開銷) - Alembic migration 需要使用 `awooop_migration` 角色(需要更新 K8s Secrets) - Application Repository 層需要全部加 `project_id` 參數(PR-08/PR-09) ### Risks - `current_setting('app.project_id', TRUE)` 返回空字串的情境: - 未注入的連線(如直接 psql 連線) - Phase 1~2 過渡期的 legacy 背景 loop - 緩解:空字串 policy 在 Phase 3 移除,Phase 1~2 可接受 - `FORCE ROW LEVEL SECURITY` 連 table owner 也受約束,需確認 migration 角色有 BYPASSRLS --- ## 驗收標準 - [ ] `awooop_app` role 建立,應用程式連線改用此 role(Phase 1) - [ ] Batch 1 四張高風險表 RLS 啟用(Phase 1 migration) - [ ] `SET LOCAL app.project_id = 'ewoooc'` → incidents 查詢返回 0 筆(整合測試) - [ ] EwoooC onboarding 前:全部 20 張表 RLS 啟用(Phase 3 完成) - [ ] vuln-verifier PoC:`GET /v1/platform/runs/{awoooi_run_id}` 帶 ewoooc token → 403(ADR-115 D5) ## 關聯 - ADR-107(儲存策略,DB schema) - ADR-115(tenant onboarding,cross-tenant 隔離驗收) - ADR-116(security hardening,approval token) - INV-2(20 張缺 project_id 的表) - PR-08/PR-09(Repository project_id batch 新增)