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

234 lines
7.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 分層**
```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 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 的驗收**
```sql
-- 驗收測試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 層加第二道:
```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 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 PoC`GET /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 新增)