## 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>
272 lines
10 KiB
SQL
272 lines
10 KiB
SQL
-- AwoooP Phase 1 Batch 1: 現有四表加 project_id + RLS
|
||
-- 2026-05-04 ogt + Claude Sonnet 4.6(ADR-118 Batch 1,C-3/C-4 db-expert 修正版)
|
||
-- 2026-05-04 critic 修正版:ADD CONSTRAINT IF NOT EXISTS 不存在於 PG → 改用 DO 塊檢查 pg_constraint
|
||
--
|
||
-- 對象:incidents / knowledge_entries / playbooks / audit_logs
|
||
-- 這四張表是高頻寫入表,採「三步式 migration」避免長時間鎖表:
|
||
--
|
||
-- Step A: ADD COLUMN nullable(metadata-only,瞬間)
|
||
-- Step B: 分批回填(每批 5000 筆,外部腳本呼叫)
|
||
-- Step C: NOT VALID CHECK → VALIDATE(SHARE UPDATE EXCLUSIVE,不擋讀寫)
|
||
-- → SET NOT NULL(PG 12+ 利用已驗證 check,不掃表)
|
||
-- → SET DEFAULT 'awoooi'
|
||
--
|
||
-- ⚠️ 執行前必確認:
|
||
-- 1. awooop_phase1_control_plane_2026-05-04.sql 已執行(awooop_projects 表存在)
|
||
-- 2. apps/api 已 deploy 「SET LOCAL app.project_id」版本,rollout 100%
|
||
-- 3. 31 個 background loop 改用 awooop_platform_admin role(PR-10)
|
||
-- 4. 量測各表體量(見下方 pre-migration check query)
|
||
--
|
||
-- Pre-migration check:
|
||
-- SELECT relname, n_live_tup, pg_size_pretty(pg_total_relation_size(oid))
|
||
-- FROM pg_class
|
||
-- WHERE relname IN ('incidents','knowledge_entries','playbooks','audit_logs');
|
||
--
|
||
-- 分批回填腳本:
|
||
-- apps/api/scripts/awooop_phase1_batch1_backfill.py(另行提供)
|
||
--
|
||
-- ⚠️ RLS 是 fail-closed:
|
||
-- SET LOCAL app.project_id 未設 → 讀不到任何資料(C-4 修正)
|
||
-- WITH CHECK 防止 INSERT 寫入錯誤 tenant
|
||
--
|
||
-- 回滾路徑:
|
||
-- ALTER TABLE incidents DISABLE ROW LEVEL SECURITY;
|
||
-- DROP POLICY IF EXISTS incidents_tenant_isolation ON incidents;
|
||
-- DROP POLICY IF EXISTS knowledge_entries_tenant_isolation ON knowledge_entries;
|
||
-- DROP POLICY IF EXISTS playbooks_tenant_isolation ON playbooks;
|
||
-- DROP POLICY IF EXISTS audit_logs_tenant_isolation ON audit_logs;
|
||
-- ALTER TABLE incidents DISABLE ROW LEVEL SECURITY;
|
||
-- ALTER TABLE knowledge_entries DISABLE ROW LEVEL SECURITY;
|
||
-- ALTER TABLE playbooks DISABLE ROW LEVEL SECURITY;
|
||
-- ALTER TABLE audit_logs DISABLE ROW LEVEL SECURITY;
|
||
-- ALTER TABLE incidents DROP COLUMN IF EXISTS project_id;
|
||
-- ALTER TABLE knowledge_entries DROP COLUMN IF EXISTS project_id;
|
||
-- ALTER TABLE playbooks DROP COLUMN IF EXISTS project_id;
|
||
-- ALTER TABLE audit_logs DROP COLUMN IF EXISTS project_id;
|
||
-- ---------------------------------------------------------------------------
|
||
|
||
|
||
-- ===========================
|
||
-- STEP A: ADD COLUMN(nullable,瞬間取鎖,不重寫表)
|
||
-- ===========================
|
||
-- 一次只做 ADD COLUMN,讓 AccessExclusiveLock 最短
|
||
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM information_schema.columns
|
||
WHERE table_name = 'incidents' AND column_name = 'project_id'
|
||
) THEN
|
||
ALTER TABLE incidents ADD COLUMN project_id VARCHAR(64);
|
||
END IF;
|
||
END $$;
|
||
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM information_schema.columns
|
||
WHERE table_name = 'knowledge_entries' AND column_name = 'project_id'
|
||
) THEN
|
||
ALTER TABLE knowledge_entries ADD COLUMN project_id VARCHAR(64);
|
||
END IF;
|
||
END $$;
|
||
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM information_schema.columns
|
||
WHERE table_name = 'playbooks' AND column_name = 'project_id'
|
||
) THEN
|
||
ALTER TABLE playbooks ADD COLUMN project_id VARCHAR(64);
|
||
END IF;
|
||
END $$;
|
||
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM information_schema.columns
|
||
WHERE table_name = 'audit_logs' AND column_name = 'project_id'
|
||
) THEN
|
||
ALTER TABLE audit_logs ADD COLUMN project_id VARCHAR(64);
|
||
END IF;
|
||
END $$;
|
||
|
||
|
||
-- ===========================
|
||
-- STEP B: 分批回填(外部腳本)
|
||
-- ===========================
|
||
-- 此步驟由 apps/api/scripts/awooop_phase1_batch1_backfill.py 執行
|
||
-- 每批 UPDATE ... WHERE project_id IS NULL LIMIT 5000
|
||
-- 完成條件:SELECT count(*) FROM incidents WHERE project_id IS NULL; → 0
|
||
--
|
||
-- 快速驗證(執行此 SQL 前必須確認回填完成):
|
||
-- SELECT
|
||
-- 'incidents' as tbl, count(*) as null_count FROM incidents WHERE project_id IS NULL
|
||
-- UNION ALL SELECT 'knowledge_entries', count(*) FROM knowledge_entries WHERE project_id IS NULL
|
||
-- UNION ALL SELECT 'playbooks', count(*) FROM playbooks WHERE project_id IS NULL
|
||
-- UNION ALL SELECT 'audit_logs', count(*) FROM audit_logs WHERE project_id IS NULL;
|
||
-- 所有 null_count 必須為 0,否則停止。
|
||
--
|
||
-- ⚠️ 回填完成確認後才可繼續執行 Step C
|
||
|
||
|
||
-- ===========================
|
||
-- STEP C: NOT NULL 強制 + DEFAULT + Index + RLS
|
||
-- ===========================
|
||
-- PostgreSQL 12+:NOT VALID CHECK → VALIDATE → SET NOT NULL
|
||
-- VALIDATE 只取 SHARE UPDATE EXCLUSIVE,不擋讀寫
|
||
-- SET NOT NULL 在 VALIDATE 後不再掃表(利用 check constraint 証明)
|
||
|
||
-- --- incidents ---
|
||
|
||
-- PostgreSQL 無 ADD CONSTRAINT IF NOT EXISTS,改用 DO 塊檢查 pg_constraint
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM pg_constraint
|
||
WHERE conname = 'chk_incidents_project_id_not_null'
|
||
AND conrelid = 'incidents'::regclass
|
||
) THEN
|
||
ALTER TABLE incidents
|
||
ADD CONSTRAINT chk_incidents_project_id_not_null
|
||
CHECK (project_id IS NOT NULL) NOT VALID;
|
||
END IF;
|
||
END $$;
|
||
|
||
ALTER TABLE incidents
|
||
VALIDATE CONSTRAINT chk_incidents_project_id_not_null;
|
||
|
||
ALTER TABLE incidents ALTER COLUMN project_id SET NOT NULL;
|
||
ALTER TABLE incidents ALTER COLUMN project_id SET DEFAULT 'awoooi';
|
||
ALTER TABLE incidents DROP CONSTRAINT IF EXISTS chk_incidents_project_id_not_null;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_incidents_project_id ON incidents (project_id);
|
||
|
||
ALTER TABLE incidents ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE incidents FORCE ROW LEVEL SECURITY;
|
||
DROP POLICY IF EXISTS incidents_tenant_isolation ON incidents;
|
||
CREATE POLICY incidents_tenant_isolation ON incidents
|
||
FOR ALL TO awooop_app
|
||
USING (project_id = current_setting('app.project_id', TRUE))
|
||
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
|
||
|
||
|
||
-- --- knowledge_entries ---
|
||
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM pg_constraint
|
||
WHERE conname = 'chk_km_project_id_not_null'
|
||
AND conrelid = 'knowledge_entries'::regclass
|
||
) THEN
|
||
ALTER TABLE knowledge_entries
|
||
ADD CONSTRAINT chk_km_project_id_not_null
|
||
CHECK (project_id IS NOT NULL) NOT VALID;
|
||
END IF;
|
||
END $$;
|
||
|
||
ALTER TABLE knowledge_entries
|
||
VALIDATE CONSTRAINT chk_km_project_id_not_null;
|
||
|
||
ALTER TABLE knowledge_entries ALTER COLUMN project_id SET NOT NULL;
|
||
ALTER TABLE knowledge_entries ALTER COLUMN project_id SET DEFAULT 'awoooi';
|
||
ALTER TABLE knowledge_entries DROP CONSTRAINT IF EXISTS chk_km_project_id_not_null;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_project_id ON knowledge_entries (project_id);
|
||
|
||
ALTER TABLE knowledge_entries ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE knowledge_entries FORCE ROW LEVEL SECURITY;
|
||
DROP POLICY IF EXISTS knowledge_entries_tenant_isolation ON knowledge_entries;
|
||
CREATE POLICY knowledge_entries_tenant_isolation ON knowledge_entries
|
||
FOR ALL TO awooop_app
|
||
USING (project_id = current_setting('app.project_id', TRUE))
|
||
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
|
||
|
||
|
||
-- --- playbooks ---
|
||
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM pg_constraint
|
||
WHERE conname = 'chk_playbooks_project_id_not_null'
|
||
AND conrelid = 'playbooks'::regclass
|
||
) THEN
|
||
ALTER TABLE playbooks
|
||
ADD CONSTRAINT chk_playbooks_project_id_not_null
|
||
CHECK (project_id IS NOT NULL) NOT VALID;
|
||
END IF;
|
||
END $$;
|
||
|
||
ALTER TABLE playbooks
|
||
VALIDATE CONSTRAINT chk_playbooks_project_id_not_null;
|
||
|
||
ALTER TABLE playbooks ALTER COLUMN project_id SET NOT NULL;
|
||
ALTER TABLE playbooks ALTER COLUMN project_id SET DEFAULT 'awoooi';
|
||
ALTER TABLE playbooks DROP CONSTRAINT IF EXISTS chk_playbooks_project_id_not_null;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_playbooks_project_id ON playbooks (project_id);
|
||
|
||
ALTER TABLE playbooks ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE playbooks FORCE ROW LEVEL SECURITY;
|
||
DROP POLICY IF EXISTS playbooks_tenant_isolation ON playbooks;
|
||
CREATE POLICY playbooks_tenant_isolation ON playbooks
|
||
FOR ALL TO awooop_app
|
||
USING (project_id = current_setting('app.project_id', TRUE))
|
||
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
|
||
|
||
|
||
-- --- audit_logs ---
|
||
|
||
DO $$
|
||
BEGIN
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM pg_constraint
|
||
WHERE conname = 'chk_audit_project_id_not_null'
|
||
AND conrelid = 'audit_logs'::regclass
|
||
) THEN
|
||
ALTER TABLE audit_logs
|
||
ADD CONSTRAINT chk_audit_project_id_not_null
|
||
CHECK (project_id IS NOT NULL) NOT VALID;
|
||
END IF;
|
||
END $$;
|
||
|
||
ALTER TABLE audit_logs
|
||
VALIDATE CONSTRAINT chk_audit_project_id_not_null;
|
||
|
||
ALTER TABLE audit_logs ALTER COLUMN project_id SET NOT NULL;
|
||
ALTER TABLE audit_logs ALTER COLUMN project_id SET DEFAULT 'awoooi';
|
||
ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_project_id_not_null;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_audit_logs_project_id ON audit_logs (project_id);
|
||
|
||
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE audit_logs FORCE ROW LEVEL SECURITY;
|
||
DROP POLICY IF EXISTS audit_logs_tenant_isolation ON audit_logs;
|
||
CREATE POLICY audit_logs_tenant_isolation ON audit_logs
|
||
FOR ALL TO awooop_app
|
||
USING (project_id = current_setting('app.project_id', TRUE))
|
||
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
|
||
|
||
|
||
-- ===========================
|
||
-- 驗收查詢
|
||
-- ===========================
|
||
-- SELECT tablename, rowsecurity, forcerowsecurity FROM pg_tables
|
||
-- WHERE tablename IN ('incidents','knowledge_entries','playbooks','audit_logs');
|
||
--
|
||
-- -- RLS fail-closed 測試(需 awooop_app role 執行):
|
||
-- SET ROLE awooop_app;
|
||
-- SET LOCAL app.project_id = 'ewoooc';
|
||
-- SELECT count(*) FROM incidents; -- 應 = 0(無 ewoooc 資料)
|
||
-- SET LOCAL app.project_id = 'awoooi';
|
||
-- SELECT count(*) FROM incidents; -- 應 = 全部既有資料筆數
|
||
-- RESET ROLE;
|
||
--
|
||
-- -- 確認無 NULL project_id:
|
||
-- SELECT count(*) FROM incidents WHERE project_id IS NULL; -- = 0
|
||
-- SELECT count(*) FROM knowledge_entries WHERE project_id IS NULL; -- = 0
|
||
-- SELECT count(*) FROM playbooks WHERE project_id IS NULL; -- = 0
|
||
-- SELECT count(*) FROM audit_logs WHERE project_id IS NULL; -- = 0
|