Files
awoooi/docs/adr/ADR-118-row-level-security-tenant-isolation.md
Your Name 13e51802fe feat(awooop): Phase 0 全 ADR + Phase 1 control plane schema(含 critic 四項修正)
## Phase 0(文件層,全部 Accepted)
- ADR-106/107:AwoooP 平台架構 + 儲存策略
- ADR-111~118:Bootstrap → RLS 七項核心 ADR
- ADR-119~124:SAGA → Singleton Decomposition 六項 ADR
- ADR-UI-01~04:Operator Console 四個 UI ADR

## Phase 1(DB schema + migration)
- awooop_phase1_control_plane_2026-05-04.sql:7 張新表 + trigger + RLS
  - Step 1:三角色(platform_admin/migration BYPASSRLS,awooop_app 受 RLS)
  - Step 13:GRANT awooop_app 最小權限(7 條)
  - Step 14:RLS fail-closed,移除 __platform__ 後門
- awooop_phase1_batch1_rls_2026-05-04.sql:高流量四表三步式 ADD COLUMN
- awooop_phase1_batch1_backfill.py:SKIP LOCKED 分批回填腳本
- awooop_models.py:7 個 SQLAlchemy 2.x models

## Critic 修正(4 Critical + 3 Major)
- C-1:ADD CONSTRAINT IF NOT EXISTS → DO 塊 + pg_constraint 查詢
- C-2:__mapper_args__ 字串 list → primary_key=True on mapped_column
- C-3:__platform__ RLS 後門 → 全移除,改用 BYPASSRLS role
- C-4:awooop_app role 從未建立 → Step 1 + 7 條 GRANT
- M-1:active_pointer_guard SECURITY DEFINER(FORCE RLS 跨租戶保護)
- M-2:pg_partman create_parent 加冪等防護
- M-3:immutability trigger 新增身份欄位保護(project_id/family/contract_id)

## Task 1.2 修補
- agent_loader.py:硬編碼 Mac 路徑 → AGENTS_DIR 環境變數
- Dockerfile:補 COPY .claude/agents/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 13:37:11 +08:00

7.7 KiB
Raw Blame History

ADR-118: Row-Level Security & Tenant DB Isolation

狀態Accepted 日期2026-05-03台北 決策者:統帥 範圍PostgreSQL RLS 設計、bypass role、application-layer RLS 補充、migration 策略 關聯ADR-107儲存、ADR-115tenant onboarding、ADR-116security


背景

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 分層

-- 應用程式角色(最小權限)
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_adminbypass RLS避免 loop 中斷)
  • Alembic migration 使用 awooop_migration

D2 — project_id Context Variable 注入

應用程式在每次 DB 操作前,透過 SET LOCAL 設置當前 project_id

# 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 層注入

# 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 模板:

-- 以 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 1Phase 1 migration高風險表

  • incidents
  • knowledge_entries
  • playbooks
  • audit_logs

Batch 2Phase 2

  • approval_records
  • mcp_audit_log
  • auto_repair_executions
  • alert_operation_log

Batch 3Phase 3其餘表

  • 其餘 12 張表(見 INV-2

每個 batch 的驗收

-- 驗收測試ewoooc project 無法看到 awoooi 資料
SET LOCAL app.project_id = 'ewoooc';
SELECT count(*) FROM incidents;  -- 必須為 0awoooi 的 incident 不可見)

SET LOCAL app.project_id = 'awoooi';
SELECT count(*) FROM incidents;  -- 必須為 awoooi 的 incident 數量

D5 — Application-Layer RLS 補充

PostgreSQL RLS 是第一道防線。應用層在 Repository 層加第二道:

# 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

-- 在 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 12 允許(空字串 = 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 建立,應用程式連線改用此 rolePhase 1
  • Batch 1 四張高風險表 RLS 啟用Phase 1 migration
  • SET LOCAL app.project_id = 'ewoooc' → incidents 查詢返回 0 筆(整合測試)
  • EwoooC onboarding 前:全部 20 張表 RLS 啟用Phase 3 完成)
  • vuln-verifier PoCGET /v1/platform/runs/{awoooi_run_id} 帶 ewoooc token → 403ADR-115 D5

關聯

  • ADR-107儲存策略DB schema
  • ADR-115tenant onboardingcross-tenant 隔離驗收)
  • ADR-116security hardeningapproval token
  • INV-220 張缺 project_id 的表)
  • PR-08/PR-09Repository project_id batch 新增)