# 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 新增）
