## 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>
7.7 KiB
7.7 KiB
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 隔離驗收無法通過
設計目標:
- PostgreSQL 原生 RLS(不依賴 application-layer 單點防護)
- 不影響現有的 legacy_awoooi_default 背景 loop(非破壞式上線)
- 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_admin(bypass 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 1(Phase 1 migration,高風險表):
incidentsknowledge_entriesplaybooksaudit_logs
Batch 2(Phase 2):
approval_recordsmcp_audit_logauto_repair_executionsalert_operation_log
Batch 3(Phase 3,其餘表):
- 其餘 12 張表(見 INV-2)
每個 batch 的驗收:
-- 驗收測試: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 層加第二道:
# 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 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_approle 建立,應用程式連線改用此 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 新增)