## 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>
234 lines
7.7 KiB
Markdown
234 lines
7.7 KiB
Markdown
# 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 新增)
|