## 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>
547 lines
22 KiB
PL/PgSQL
547 lines
22 KiB
PL/PgSQL
-- AwoooP Phase 1: Control Plane Schema Foundation
|
||
-- 2026-05-04 ogt + Claude Sonnet 4.6(ADR-111~118,Phase 1 Task 1.3~1.7)
|
||
-- 2026-05-04 db-expert review 修正版:C-1/C-2/C-4/C-5/M-1/M-2/M-4/M-5/Mi-1/Mi-2/Mi-3
|
||
-- 2026-05-04 critic review 修正版:awooop_app role 建立 + GRANT、移除 __platform__ 後門、
|
||
-- active_pointer_guard SECURITY DEFINER、pg_partman 冪等、immutability 強化
|
||
--
|
||
-- ⚠️ 部署順序鎖死(ADR-118 RLS 前置條件):
|
||
-- 1. apps/api 必須先 deploy「會 SET LOCAL app.project_id」的版本
|
||
-- 2. K8s rollout 完成(kubectl rollout status deploy/api = 100%)
|
||
-- 3. 31 個 background loop 改用 awooop_platform_admin role(PR-10 完成)
|
||
-- 4. 以上完成後,才執行此 migration SQL
|
||
--
|
||
-- ⚠️ 不包含 Batch 1 高流量表(incidents/knowledge_entries/playbooks/audit_logs)
|
||
-- → 請執行 awooop_phase1_batch1_rls_2026-05-04.sql(三步式 migration)
|
||
--
|
||
-- 執行前確認:
|
||
-- 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');
|
||
--
|
||
-- 執行角色:awooop_migration(BYPASSRLS)
|
||
-- 預估執行時間:< 30 秒(全為新表,無既有資料修改)
|
||
--
|
||
-- 回滾路徑:
|
||
-- 見 awooop_phase1_control_plane_ROLLBACK.sql
|
||
-- ---------------------------------------------------------------------------
|
||
|
||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||
|
||
-- ===========================
|
||
-- Step 1: DB Roles(ADR-118 D1)
|
||
-- ===========================
|
||
|
||
DO $$
|
||
BEGIN
|
||
-- awooop_platform_admin: 平台管理(BYPASSRLS,背景 loop 使用)
|
||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awooop_platform_admin') THEN
|
||
CREATE ROLE awooop_platform_admin NOLOGIN;
|
||
END IF;
|
||
ALTER ROLE awooop_platform_admin BYPASSRLS;
|
||
|
||
-- awooop_migration: migration 執行(BYPASSRLS,只在 migration 期間使用)
|
||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awooop_migration') THEN
|
||
CREATE ROLE awooop_migration NOLOGIN;
|
||
END IF;
|
||
ALTER ROLE awooop_migration BYPASSRLS;
|
||
|
||
-- awooop_app: 應用程式角色(受 RLS 約束,需 SET LOCAL app.project_id)
|
||
-- 必須在 GRANT 之前建立;NOLOGIN 代表 app connection user 要 SET ROLE awooop_app
|
||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awooop_app') THEN
|
||
CREATE ROLE awooop_app NOLOGIN;
|
||
END IF;
|
||
END $$;
|
||
|
||
|
||
-- ===========================
|
||
-- Step 2: awooop_projects(租戶主表)
|
||
-- ===========================
|
||
|
||
CREATE TABLE IF NOT EXISTS awooop_projects (
|
||
project_id VARCHAR(64) PRIMARY KEY,
|
||
display_name VARCHAR(256) NOT NULL,
|
||
migration_mode VARCHAR(32) NOT NULL DEFAULT 'legacy_awoooi_default',
|
||
budget_limit_usd NUMERIC(14, 4) CHECK (budget_limit_usd IS NULL OR budget_limit_usd >= 0),
|
||
allowed_channels JSONB NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(allowed_channels) = 'array'),
|
||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
CONSTRAINT chk_migration_mode CHECK (
|
||
migration_mode IN ('legacy_awoooi_default','shadow','canary','active')
|
||
)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_awooop_projects_active
|
||
ON awooop_projects(is_active) WHERE is_active = TRUE;
|
||
|
||
|
||
-- ===========================
|
||
-- Step 3: awooop_contract_revisions(六合約共用 revision,append-only)
|
||
-- ===========================
|
||
|
||
CREATE TABLE IF NOT EXISTS awooop_contract_revisions (
|
||
revision_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
|
||
contract_family VARCHAR(32) NOT NULL,
|
||
contract_id VARCHAR(128) NOT NULL,
|
||
version_major SMALLINT NOT NULL DEFAULT 1 CHECK (version_major >= 0),
|
||
version_minor SMALLINT NOT NULL DEFAULT 0 CHECK (version_minor >= 0),
|
||
lifecycle_status VARCHAR(16) NOT NULL DEFAULT 'draft',
|
||
body_json JSONB NOT NULL,
|
||
-- body_hash: SHA-256 hex(64 chars),強制格式
|
||
body_hash VARCHAR(64) NOT NULL CHECK (body_hash ~ '^[0-9a-f]{64}$'),
|
||
body_schema_version VARCHAR(16) NOT NULL DEFAULT 'v1.0',
|
||
-- publish_signature: HMAC-SHA256 hex,draft 時 NULL
|
||
publish_signature VARCHAR(128) CHECK (
|
||
publish_signature IS NULL OR publish_signature ~ '^[0-9a-f]+$'
|
||
),
|
||
publisher_id VARCHAR(128),
|
||
published_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
CONSTRAINT uq_revision_version
|
||
UNIQUE (project_id, contract_family, contract_id, version_major, version_minor),
|
||
CONSTRAINT chk_contract_family CHECK (
|
||
contract_family IN (
|
||
'project_tenant','agent','mcp_gateway','policy_routing',
|
||
'runtime_run_state','channel_event','platform_resource'
|
||
)
|
||
),
|
||
CONSTRAINT chk_lifecycle CHECK (
|
||
lifecycle_status IN ('draft','published','active','revoked')
|
||
)
|
||
);
|
||
|
||
-- runtime 讀取路徑:找某 contract 最新 published/active 版本
|
||
CREATE INDEX IF NOT EXISTS idx_revisions_lookup
|
||
ON awooop_contract_revisions
|
||
(project_id, contract_family, contract_id, lifecycle_status,
|
||
version_major DESC, version_minor DESC);
|
||
|
||
-- forensic 驗章反查
|
||
CREATE INDEX IF NOT EXISTS idx_revisions_hash
|
||
ON awooop_contract_revisions (body_hash);
|
||
|
||
|
||
-- ===========================
|
||
-- Step 4: awooop_active_revisions(active pointer)
|
||
-- ===========================
|
||
|
||
CREATE TABLE IF NOT EXISTS awooop_active_revisions (
|
||
pointer_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
|
||
contract_family VARCHAR(32) NOT NULL,
|
||
contract_id VARCHAR(128) NOT NULL,
|
||
-- NOT NULL + ON DELETE RESTRICT(C-1 修正)
|
||
active_revision_id UUID NOT NULL REFERENCES awooop_contract_revisions(revision_id)
|
||
ON DELETE RESTRICT,
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
CONSTRAINT uq_active_pointer
|
||
UNIQUE (project_id, contract_family, contract_id)
|
||
);
|
||
|
||
|
||
-- ===========================
|
||
-- Step 5: awooop_contract_outbox(ADR-113,C-2 修正版)
|
||
-- ===========================
|
||
|
||
CREATE TABLE IF NOT EXISTS awooop_contract_outbox (
|
||
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
event_type VARCHAR(64) NOT NULL,
|
||
-- FK 到 projects(C-2 修正:outbox 不可是孤兒事件)
|
||
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
|
||
contract_family VARCHAR(32) NOT NULL,
|
||
contract_id VARCHAR(128) NOT NULL,
|
||
old_revision_id UUID REFERENCES awooop_contract_revisions(revision_id),
|
||
new_revision_id UUID NOT NULL REFERENCES awooop_contract_revisions(revision_id),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
delivered_at TIMESTAMPTZ,
|
||
relay_attempts INT NOT NULL DEFAULT 0,
|
||
-- C-2 新增:exponential backoff 支援
|
||
next_retry_at TIMESTAMPTZ,
|
||
last_error TEXT,
|
||
-- C-2 新增:上游 publisher 重試去重(同一 revision 的同一事件類型只記一次)
|
||
CONSTRAINT uq_outbox_event UNIQUE (new_revision_id, event_type)
|
||
);
|
||
|
||
-- relay worker 主查詢:未投遞 + 可重試(含 next_retry_at NULL = 立即重試)
|
||
CREATE INDEX IF NOT EXISTS idx_outbox_pending
|
||
ON awooop_contract_outbox (next_retry_at NULLS FIRST, created_at)
|
||
WHERE delivered_at IS NULL;
|
||
|
||
-- 觀察用:per project backlog 體量
|
||
CREATE INDEX IF NOT EXISTS idx_outbox_backlog_per_project
|
||
ON awooop_contract_outbox (project_id, created_at)
|
||
WHERE delivered_at IS NULL;
|
||
|
||
|
||
-- ===========================
|
||
-- Step 6: awooop_channel_event_dedupe(ADR-114,M-1 Partition 版)
|
||
-- ===========================
|
||
-- pg_partman 維護 1 天 partition,retention 7 天,DROP PARTITION 毫秒清完
|
||
|
||
CREATE TABLE IF NOT EXISTS awooop_channel_event_dedupe (
|
||
dedupe_id UUID NOT NULL DEFAULT gen_random_uuid(),
|
||
project_id VARCHAR(64) NOT NULL,
|
||
channel_type VARCHAR(32) NOT NULL,
|
||
provider_event_id VARCHAR(256) NOT NULL,
|
||
run_id UUID NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
-- Partition key 必須是 PK 的一部分(declarative partition 要求)
|
||
PRIMARY KEY (dedupe_id, created_at),
|
||
CONSTRAINT uq_channel_event_dedupe
|
||
UNIQUE (project_id, channel_type, provider_event_id, created_at)
|
||
) PARTITION BY RANGE (created_at);
|
||
|
||
-- 初始化 pg_partman(若 pg_partman 已安裝)
|
||
DO $$
|
||
BEGIN
|
||
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_partman') THEN
|
||
-- 冪等:已在 part_config 則跳過 create_parent(重跑 migration 安全)
|
||
IF NOT EXISTS (
|
||
SELECT 1 FROM partman.part_config
|
||
WHERE parent_table = 'public.awooop_channel_event_dedupe'
|
||
) THEN
|
||
PERFORM partman.create_parent(
|
||
p_parent_table := 'public.awooop_channel_event_dedupe',
|
||
p_control := 'created_at',
|
||
p_type := 'native',
|
||
p_interval := '1 day',
|
||
p_premake := 4
|
||
);
|
||
END IF;
|
||
UPDATE partman.part_config
|
||
SET retention = '7 days',
|
||
retention_keep_table = false
|
||
WHERE parent_table = 'public.awooop_channel_event_dedupe';
|
||
ELSE
|
||
-- pg_partman 未安裝:手動建前 14 天 partition(含今日 ±7 天)
|
||
DECLARE
|
||
d DATE;
|
||
BEGIN
|
||
FOR d IN
|
||
SELECT generate_series(
|
||
CURRENT_DATE - INTERVAL '7 days',
|
||
CURRENT_DATE + INTERVAL '7 days',
|
||
INTERVAL '1 day'
|
||
)::DATE
|
||
LOOP
|
||
EXECUTE format(
|
||
'CREATE TABLE IF NOT EXISTS awooop_channel_event_dedupe_%s
|
||
PARTITION OF awooop_channel_event_dedupe
|
||
FOR VALUES FROM (%L) TO (%L)',
|
||
to_char(d, 'YYYYMMDD'),
|
||
d::TIMESTAMPTZ,
|
||
(d + INTERVAL '1 day')::TIMESTAMPTZ
|
||
);
|
||
END LOOP;
|
||
END;
|
||
END IF;
|
||
END $$;
|
||
|
||
-- run_id 反查(Mi-5)
|
||
CREATE INDEX IF NOT EXISTS idx_dedupe_run
|
||
ON awooop_channel_event_dedupe (run_id);
|
||
|
||
|
||
-- ===========================
|
||
-- Step 7: awooop_platform_subjects(ADR-115)
|
||
-- ===========================
|
||
|
||
CREATE TABLE IF NOT EXISTS awooop_platform_subjects (
|
||
subject_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
|
||
channel_type VARCHAR(32) NOT NULL,
|
||
channel_user_id VARCHAR(256) NOT NULL,
|
||
channel_chat_id VARCHAR(256),
|
||
platform_subject_id VARCHAR(128) NOT NULL,
|
||
display_name VARCHAR(256),
|
||
roles JSONB NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(roles) = 'array'),
|
||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
CONSTRAINT uq_platform_subject
|
||
UNIQUE (project_id, channel_type, channel_user_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_platform_subjects_lookup
|
||
ON awooop_platform_subjects (project_id, channel_type, channel_user_id);
|
||
|
||
-- platform_subject_id 反查(Operator Console M2 用)
|
||
CREATE INDEX IF NOT EXISTS idx_platform_subjects_resolve
|
||
ON awooop_platform_subjects (project_id, platform_subject_id);
|
||
|
||
-- 近期活躍 user 查詢
|
||
CREATE INDEX IF NOT EXISTS idx_platform_subjects_last_seen
|
||
ON awooop_platform_subjects (project_id, last_seen_at DESC);
|
||
|
||
|
||
-- ===========================
|
||
-- Step 8: awooop_project_migration_state(Strangler Fig 追蹤)
|
||
-- ===========================
|
||
|
||
CREATE TABLE IF NOT EXISTS awooop_project_migration_state (
|
||
state_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
project_id VARCHAR(64) NOT NULL REFERENCES awooop_projects(project_id),
|
||
capability VARCHAR(64) NOT NULL,
|
||
current_phase VARCHAR(32) NOT NULL DEFAULT 'legacy_awoooi_default',
|
||
phase_entered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
CONSTRAINT uq_project_capability UNIQUE (project_id, capability),
|
||
CONSTRAINT chk_capability CHECK (
|
||
capability IN (
|
||
'run_execution','contract_governance',
|
||
'budget_tracking','principal_mapping'
|
||
)
|
||
),
|
||
CONSTRAINT chk_phase CHECK (
|
||
current_phase IN (
|
||
'legacy_awoooi_default','shadow','canary',
|
||
'read_only','suggest','auto_remediate'
|
||
)
|
||
)
|
||
);
|
||
|
||
|
||
-- ===========================
|
||
-- Step 9: awooop_published_revisions VIEW(ADR-112 D6 draft 隔離)
|
||
-- ===========================
|
||
|
||
CREATE OR REPLACE VIEW awooop_published_revisions AS
|
||
SELECT *
|
||
FROM awooop_contract_revisions
|
||
WHERE lifecycle_status IN ('published', 'active');
|
||
|
||
|
||
-- ===========================
|
||
-- Step 10: updated_at 自動更新 trigger(Mi-1)
|
||
-- ===========================
|
||
|
||
CREATE OR REPLACE FUNCTION awooop_set_updated_at()
|
||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||
BEGIN
|
||
NEW.updated_at = NOW();
|
||
RETURN NEW;
|
||
END;
|
||
$$;
|
||
|
||
DO $$
|
||
DECLARE
|
||
t TEXT;
|
||
BEGIN
|
||
FOREACH t IN ARRAY ARRAY[
|
||
'awooop_projects',
|
||
'awooop_active_revisions',
|
||
'awooop_platform_subjects',
|
||
'awooop_project_migration_state'
|
||
] LOOP
|
||
EXECUTE format(
|
||
'DROP TRIGGER IF EXISTS trg_%s_updated_at ON %I;
|
||
CREATE TRIGGER trg_%s_updated_at
|
||
BEFORE UPDATE ON %I
|
||
FOR EACH ROW EXECUTE FUNCTION awooop_set_updated_at();',
|
||
t, t, t, t
|
||
);
|
||
END LOOP;
|
||
END $$;
|
||
|
||
|
||
-- ===========================
|
||
-- Step 11: Immutability Trigger(C-5 完整版,ADR-112 D2)
|
||
-- ===========================
|
||
-- 允許的 lifecycle 流轉:
|
||
-- draft → published(publish 操作)
|
||
-- published → active (activate 操作)
|
||
-- active → revoked (revoke 操作)
|
||
-- 禁止:body/hash/signature/version 在 published/active/revoked 後修改
|
||
|
||
CREATE OR REPLACE FUNCTION awooop_revision_immutability_guard()
|
||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||
BEGIN
|
||
-- 所有 lifecycle_status 下都禁止修改身份欄位(project_id/family/contract_id)
|
||
IF NEW.project_id IS DISTINCT FROM OLD.project_id
|
||
OR NEW.contract_family IS DISTINCT FROM OLD.contract_family
|
||
OR NEW.contract_id IS DISTINCT FROM OLD.contract_id
|
||
THEN
|
||
RAISE EXCEPTION
|
||
'revision % identity fields (project_id/contract_family/contract_id) are immutable',
|
||
OLD.revision_id;
|
||
END IF;
|
||
|
||
-- draft 可以自由修改,離開 draft 後鎖住核心欄位
|
||
IF OLD.lifecycle_status IN ('published', 'active', 'revoked') THEN
|
||
IF NEW.body_json IS DISTINCT FROM OLD.body_json
|
||
OR NEW.body_hash IS DISTINCT FROM OLD.body_hash
|
||
OR NEW.publish_signature IS DISTINCT FROM OLD.publish_signature
|
||
OR NEW.version_major IS DISTINCT FROM OLD.version_major
|
||
OR NEW.version_minor IS DISTINCT FROM OLD.version_minor
|
||
OR NEW.publisher_id IS DISTINCT FROM OLD.publisher_id
|
||
OR NEW.published_at IS DISTINCT FROM OLD.published_at
|
||
OR NEW.body_schema_version IS DISTINCT FROM OLD.body_schema_version
|
||
THEN
|
||
RAISE EXCEPTION
|
||
'revision % (%) is immutable: body/signature/version cannot be changed',
|
||
OLD.revision_id, OLD.lifecycle_status;
|
||
END IF;
|
||
END IF;
|
||
|
||
-- lifecycle_status 流轉白名單
|
||
IF NEW.lifecycle_status IS DISTINCT FROM OLD.lifecycle_status THEN
|
||
IF NOT (
|
||
(OLD.lifecycle_status = 'draft' AND NEW.lifecycle_status = 'published') OR
|
||
(OLD.lifecycle_status = 'published' AND NEW.lifecycle_status = 'active') OR
|
||
(OLD.lifecycle_status = 'active' AND NEW.lifecycle_status = 'revoked')
|
||
) THEN
|
||
RAISE EXCEPTION
|
||
'illegal lifecycle transition on revision %: % -> %',
|
||
OLD.revision_id, OLD.lifecycle_status, NEW.lifecycle_status;
|
||
END IF;
|
||
END IF;
|
||
|
||
RETURN NEW;
|
||
END;
|
||
$$;
|
||
|
||
DROP TRIGGER IF EXISTS trg_revision_immutability ON awooop_contract_revisions;
|
||
CREATE TRIGGER trg_revision_immutability
|
||
BEFORE UPDATE ON awooop_contract_revisions
|
||
FOR EACH ROW EXECUTE FUNCTION awooop_revision_immutability_guard();
|
||
|
||
-- DELETE 完全禁止(append-only 語意)
|
||
CREATE OR REPLACE FUNCTION awooop_revision_no_delete()
|
||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||
BEGIN
|
||
RAISE EXCEPTION
|
||
'awooop_contract_revisions is append-only: DELETE forbidden on revision %',
|
||
OLD.revision_id;
|
||
END;
|
||
$$;
|
||
|
||
DROP TRIGGER IF EXISTS trg_revision_no_delete ON awooop_contract_revisions;
|
||
CREATE TRIGGER trg_revision_no_delete
|
||
BEFORE DELETE ON awooop_contract_revisions
|
||
FOR EACH ROW EXECUTE FUNCTION awooop_revision_no_delete();
|
||
|
||
|
||
-- ===========================
|
||
-- Step 12: Active Pointer Guard(M-5,確保 active_revision_id 指向正確的 active revision)
|
||
-- ===========================
|
||
|
||
-- SECURITY DEFINER:trigger 以 migration 擁有者執行,繞過 awooop_contract_revisions 的 RLS,
|
||
-- 確保跨租戶指向檢測(FORCE RLS 下 SECURITY INVOKER 只能看自己租戶的 revision)
|
||
CREATE OR REPLACE FUNCTION awooop_active_pointer_guard()
|
||
RETURNS TRIGGER LANGUAGE plpgsql
|
||
SECURITY DEFINER
|
||
SET search_path = public, pg_catalog
|
||
AS $$
|
||
DECLARE
|
||
rev RECORD;
|
||
BEGIN
|
||
SELECT project_id, contract_family, contract_id, lifecycle_status
|
||
INTO rev
|
||
FROM awooop_contract_revisions
|
||
WHERE revision_id = NEW.active_revision_id;
|
||
|
||
IF NOT FOUND THEN
|
||
RAISE EXCEPTION 'revision % not found', NEW.active_revision_id;
|
||
END IF;
|
||
IF rev.project_id <> NEW.project_id
|
||
OR rev.contract_family <> NEW.contract_family
|
||
OR rev.contract_id <> NEW.contract_id
|
||
THEN
|
||
RAISE EXCEPTION
|
||
'active pointer contract identity mismatch: pointer=(%,%,%) revision=(%,%,%)',
|
||
NEW.project_id, NEW.contract_family, NEW.contract_id,
|
||
rev.project_id, rev.contract_family, rev.contract_id;
|
||
END IF;
|
||
IF rev.lifecycle_status <> 'active' THEN
|
||
RAISE EXCEPTION
|
||
'active pointer must reference an active revision (got %)', rev.lifecycle_status;
|
||
END IF;
|
||
RETURN NEW;
|
||
END;
|
||
$$;
|
||
|
||
DROP TRIGGER IF EXISTS trg_active_pointer_guard ON awooop_active_revisions;
|
||
CREATE TRIGGER trg_active_pointer_guard
|
||
BEFORE INSERT OR UPDATE ON awooop_active_revisions
|
||
FOR EACH ROW EXECUTE FUNCTION awooop_active_pointer_guard();
|
||
|
||
|
||
-- ===========================
|
||
-- Step 13: GRANT awooop_app 基本操作權限
|
||
-- ===========================
|
||
-- awooop_app 受 RLS 約束,需設定 app.project_id 才能存取資料
|
||
-- awooop_platform_admin / awooop_migration 有 BYPASSRLS,不需 GRANT(直接用 superuser 連線)
|
||
|
||
GRANT SELECT, INSERT, UPDATE, DELETE ON awooop_contract_revisions TO awooop_app;
|
||
GRANT SELECT, INSERT, UPDATE ON awooop_active_revisions TO awooop_app;
|
||
GRANT SELECT, INSERT ON awooop_contract_outbox TO awooop_app;
|
||
GRANT SELECT, INSERT ON awooop_channel_event_dedupe TO awooop_app;
|
||
GRANT SELECT, INSERT, UPDATE ON awooop_platform_subjects TO awooop_app;
|
||
GRANT SELECT ON awooop_projects TO awooop_app;
|
||
GRANT SELECT ON awooop_project_migration_state TO awooop_app;
|
||
GRANT SELECT ON awooop_published_revisions TO awooop_app;
|
||
|
||
|
||
-- ===========================
|
||
-- Step 14: awooop_* 表 RLS(ADR-118,C-4 fail-closed 修正版)
|
||
-- ===========================
|
||
-- ⚠️ fail-closed:沒有 SET LOCAL app.project_id 的 session 看不到任何資料
|
||
-- ⚠️ awooop_platform_admin / awooop_migration 已 BYPASSRLS,不受 policy 約束
|
||
-- ⚠️ WITH CHECK 防止 INSERT 時塞入不同 tenant 的 project_id
|
||
-- ⚠️ 移除 __platform__ 後門(critic C-3 修正):平台層改用 BYPASSRLS 角色,不靠 GUC 魔術字串
|
||
|
||
ALTER TABLE awooop_contract_revisions ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE awooop_contract_revisions FORCE ROW LEVEL SECURITY;
|
||
DROP POLICY IF EXISTS contract_revisions_tenant ON awooop_contract_revisions;
|
||
CREATE POLICY contract_revisions_tenant ON awooop_contract_revisions
|
||
FOR ALL TO awooop_app
|
||
USING (project_id = current_setting('app.project_id', TRUE))
|
||
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
|
||
|
||
ALTER TABLE awooop_active_revisions ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE awooop_active_revisions FORCE ROW LEVEL SECURITY;
|
||
DROP POLICY IF EXISTS active_revisions_tenant ON awooop_active_revisions;
|
||
CREATE POLICY active_revisions_tenant ON awooop_active_revisions
|
||
FOR ALL TO awooop_app
|
||
USING (project_id = current_setting('app.project_id', TRUE))
|
||
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
|
||
|
||
ALTER TABLE awooop_platform_subjects ENABLE ROW LEVEL SECURITY;
|
||
ALTER TABLE awooop_platform_subjects FORCE ROW LEVEL SECURITY;
|
||
DROP POLICY IF EXISTS platform_subjects_tenant ON awooop_platform_subjects;
|
||
CREATE POLICY platform_subjects_tenant ON awooop_platform_subjects
|
||
FOR ALL TO awooop_app
|
||
USING (project_id = current_setting('app.project_id', TRUE))
|
||
WITH CHECK (project_id = current_setting('app.project_id', TRUE));
|
||
|
||
|
||
-- ===========================
|
||
-- Step 15: AWOOOI 種子資料(ADR-111 bootstrap)
|
||
-- ===========================
|
||
|
||
INSERT INTO awooop_projects (project_id, display_name, migration_mode, is_active)
|
||
VALUES ('awoooi', 'AWOOOI', 'legacy_awoooi_default', TRUE)
|
||
ON CONFLICT (project_id) DO NOTHING;
|
||
|
||
INSERT INTO awooop_project_migration_state (project_id, capability, current_phase)
|
||
VALUES
|
||
('awoooi', 'run_execution', 'legacy_awoooi_default'),
|
||
('awoooi', 'contract_governance', 'legacy_awoooi_default'),
|
||
('awoooi', 'budget_tracking', 'legacy_awoooi_default'),
|
||
('awoooi', 'principal_mapping', 'legacy_awoooi_default')
|
||
ON CONFLICT (project_id, capability) DO NOTHING;
|
||
|
||
|
||
-- ===========================
|
||
-- 驗收查詢(執行後人工確認)
|
||
-- ===========================
|
||
-- \dt awooop_*
|
||
-- SELECT project_id, display_name, migration_mode FROM awooop_projects;
|
||
-- SELECT project_id, capability, current_phase FROM awooop_project_migration_state;
|
||
-- SELECT tablename, rowsecurity, forcerowsecurity FROM pg_tables
|
||
-- WHERE tablename LIKE 'awooop_%';
|
||
-- -- RLS fail-closed 測試:
|
||
-- SET LOCAL app.project_id = 'ewoooc';
|
||
-- SELECT count(*) FROM awooop_contract_revisions; -- 應回傳 0('ewoooc' 不存在 projects)
|
||
-- SET LOCAL app.project_id = 'awoooi';
|
||
-- SELECT count(*) FROM awooop_projects; -- 應回傳 1
|