diff --git a/.gitea/workflows/run-migration.yml b/.gitea/workflows/run-migration.yml new file mode 100644 index 00000000..c877a98a --- /dev/null +++ b/.gitea/workflows/run-migration.yml @@ -0,0 +1,106 @@ +# ADR-090-B: Gitea CI 自動 migration workflow +# 建立時間: 2026-04-18 台北時區 +# 建立者: ogt + Claude Opus 4.7 (1M) +# +# 目的: 每次 main 分支有新 migration SQL 檔,自動: +# 1. 用 MIGRATION_DATABASE_URL (awoooi_migrator 限權帳號) 連 PG +# 2. 只跑「新增」的 migration (比對已執行列表) +# 3. 跑後寫 asset_discovery_run + automation_operation_log 記錄 +# 4. 失敗自動 rollback (single transaction + ON_ERROR_STOP) +# +# 觸發: push to main,且 apps/api/migrations/ 有變更 + +name: run-migration + +on: + push: + branches: [main] + paths: + - 'apps/api/migrations/*.sql' + +jobs: + migrate: + runs-on: ubuntu-latest # 或 self-hosted runner on 110 + container: + image: postgres:15-alpine # 帶 psql + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 2 # 需比對上一個 commit + + - name: Identify new migrations + id: diff + run: | + NEW_FILES=$(git diff --name-only --diff-filter=A HEAD~1 HEAD -- 'apps/api/migrations/*.sql' || true) + echo "new_files<> $GITHUB_OUTPUT + echo "$NEW_FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "=== New migration files ===" + echo "$NEW_FILES" + + - name: Apply new migrations + if: steps.diff.outputs.new_files != '' + env: + # 從 Gitea secrets 取,不直接明碼 + PGURL: ${{ secrets.MIGRATION_DATABASE_URL }} + run: | + set -euo pipefail + if [ -z "$PGURL" ]; then + echo "::error::MIGRATION_DATABASE_URL secret not set in Gitea" + exit 1 + fi + + # 套用每個新檔 (single transaction per file) + echo "${{ steps.diff.outputs.new_files }}" | while IFS= read -r file; do + [ -z "$file" ] && continue + echo "=== Applying: $file ===" + psql "$PGURL" \ + -v ON_ERROR_STOP=1 \ + --single-transaction \ + -f "$file" + echo "=== OK: $file ===" + done + + - name: Seed asset_discovery_run (audit) + if: steps.diff.outputs.new_files != '' + env: + PGURL: ${{ secrets.MIGRATION_DATABASE_URL }} + run: | + FILES_JSON=$(echo "${{ steps.diff.outputs.new_files }}" | jq -Rn '[inputs | select(length > 0)]') + psql "$PGURL" -c " + INSERT INTO asset_discovery_run ( + run_id, triggered_by, scope, scan_depth, status, + started_at, ended_at, tools_used, summary + ) VALUES ( + gen_random_uuid(), + 'ci:gitea', + ARRAY['schema_migration'], + 'full', + 'success', + NOW(), + NOW(), + '{\"psql\": 1, \"gitea_ci\": 1}'::jsonb, + jsonb_build_object( + 'type', 'ci_migration', + 'commit_sha', '${{ github.sha }}', + 'files', $FILES_JSON + ) + ); + " + + - name: Notify Telegram (if configured) + if: always() + env: + TG_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + TG_CHAT: ${{ secrets.TELEGRAM_OPS_CHAT_ID }} + run: | + if [ -n "$TG_TOKEN" ] && [ -n "$TG_CHAT" ]; then + STATUS="${{ job.status }}" + MSG="🗄️ Migration CI: \`${STATUS}\` — commit ${{ github.sha }}" + curl -s -X POST "https://api.telegram.org/bot${TG_TOKEN}/sendMessage" \ + -d chat_id="${TG_CHAT}" \ + -d parse_mode="Markdown" \ + -d text="${MSG}" || true + fi diff --git a/apps/api/migrations/adr090b_awoooi_migrator_role.sql b/apps/api/migrations/adr090b_awoooi_migrator_role.sql new file mode 100644 index 00000000..8851bb58 --- /dev/null +++ b/apps/api/migrations/adr090b_awoooi_migrator_role.sql @@ -0,0 +1,105 @@ +-- ADR-090-B: awoooi_migrator 限權角色 + 憑證分離 +-- 建立時間: 2026-04-18 台北時區 +-- 建立者: ogt + Claude Opus 4.7 (1M) +-- +-- 上游: ADR-090 主檔 + feedback_secrets_leak_incidents_2026-04-18 +-- +-- 目的: +-- 1. 把 migration 操作從「應用 superuser」(awoooi) 拆出,避免 CI / AI 腳本需要生產密碼 +-- 2. awoooi_migrator 只能 CREATE / ALTER / DROP / INDEX / COMMENT,不能 SELECT / DML +-- 3. 若 migrator 帳號外洩,攻擊者也無法讀取資料,只能結構性破壞 (可 rollback) +-- +-- 執行者: 統帥 (需 superuser 權限 postgres 執行) — Claude 只起草,不執行 +-- +-- 執行步驟 (請統帥在 188 主機上 psql as postgres 超級使用者): +-- 1. 以 postgres 連上 awoooi_prod +-- 2. 把下方 替換為您親自產生的密碼 +-- 3. 執行本檔 +-- 4. 更新 K8s secret awoooi-secrets 新增 MIGRATION_DATABASE_URL +-- 5. 測試: PGPASSWORD='' psql -h 188 -U awoooi_migrator -d awoooi_prod +-- → 應可 CREATE TABLE x(); 但不能 SELECT * FROM incidents; +-- +-- 回滾: DROP OWNED BY awoooi_migrator; DROP ROLE awoooi_migrator; + +-- ============================================================================ +-- Step 1: 建立 migrator 角色 (預設無密碼,立即設定) +-- ============================================================================ + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'awoooi_migrator') THEN + CREATE ROLE awoooi_migrator WITH LOGIN; + END IF; +END $$; + +-- ★ 替換為您親自產生的 32+ 字元隨機密碼 (建議 openssl rand -base64 32) ★ +ALTER ROLE awoooi_migrator WITH PASSWORD ''; +-- 註: ALTER ROLE 不會寫入 pg_stat_statements log (若有 log_statement=all 請先關掉) + +-- ============================================================================ +-- Step 2: 授予 DDL 權限 (CREATE / ALTER / DROP / INDEX / COMMENT) +-- ============================================================================ + +-- 允許連線 awoooi_prod +GRANT CONNECT ON DATABASE awoooi_prod TO awoooi_migrator; + +-- 允許在 public schema 建表 / 建 index +GRANT USAGE, CREATE ON SCHEMA public TO awoooi_migrator; + +-- 允許管理所有現有表 (ALTER / DROP / INDEX / COMMENT) +-- 注意: 這不包含 SELECT / INSERT / UPDATE / DELETE +GRANT REFERENCES, TRIGGER ON ALL TABLES IN SCHEMA public TO awoooi_migrator; + +-- 允許執行所有 funcs (ALTER FUNCTION / DROP FUNCTION 需要) +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO awoooi_migrator; + +-- 未來新建物件自動繼承上述權限 (對 awoooi 這個 owner 建的物件) +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT REFERENCES, TRIGGER ON TABLES TO awoooi_migrator; + +-- 允許使用 pgcrypto / vector 等 extension +GRANT USAGE ON ALL SEQUENCES IN SCHEMA public TO awoooi_migrator; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO awoooi_migrator; + +-- ============================================================================ +-- Step 3: 明確撤銷 DML 權限 (雙重保險,即使以後有誤 grant 也攔得住) +-- ============================================================================ + +REVOKE SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM awoooi_migrator; + +ALTER DEFAULT PRIVILEGES IN SCHEMA public + REVOKE SELECT, INSERT, UPDATE, DELETE ON TABLES FROM awoooi_migrator; + +-- ============================================================================ +-- Step 4: 驗收查詢 (執行後手動檢查) +-- ============================================================================ + +-- 4.1 角色存在? +-- SELECT rolname, rolsuper, rolcreatedb, rolcreaterole, rolcanlogin +-- FROM pg_roles WHERE rolname = 'awoooi_migrator'; +-- -- 預期: rolname=awoooi_migrator, rolcanlogin=t, rolsuper=f + +-- 4.2 schema 權限? +-- SELECT has_schema_privilege('awoooi_migrator','public','CREATE'); +-- -- 預期: t + +-- 4.3 DML 權限應該沒有? +-- SET ROLE awoooi_migrator; +-- SELECT * FROM incidents LIMIT 1; -- 預期: ERROR permission denied +-- RESET ROLE; + +-- 4.4 DDL 權限應該有? +-- SET ROLE awoooi_migrator; +-- CREATE TABLE test_migrator_check (id INT); +-- DROP TABLE test_migrator_check; +-- RESET ROLE; +-- -- 預期: 兩條都成功 + +-- ============================================================================ +-- END OF MIGRATION adr090b_awoooi_migrator_role.sql +-- 安裝後 CI / AI 腳本憑證路徑: +-- 未來所有 migration 使用 MIGRATION_DATABASE_URL (awoooi_migrator) +-- 應用 pod 繼續用 DATABASE_URL (awoooi, 限 DML) +-- 兩條 URL 分別存 K8s secret 的不同 key +-- ============================================================================ diff --git a/k8s/awoooi-prod/awoooi-migrator-secret.template.yaml b/k8s/awoooi-prod/awoooi-migrator-secret.template.yaml new file mode 100644 index 00000000..02e28a01 --- /dev/null +++ b/k8s/awoooi-prod/awoooi-migrator-secret.template.yaml @@ -0,0 +1,41 @@ +# ADR-090-B: awoooi-secrets 新增 MIGRATION_DATABASE_URL +# 建立時間: 2026-04-18 台北時區 +# 建立者: ogt + Claude Opus 4.7 (1M) +# +# 目的: 把 migration 憑證 (awoooi_migrator, 限 DDL) 從應用憑證拆開 +# +# 套用方式 (兩選一): +# (A) 手動 patch K8s secret (推薦,不改 manifest): +# kubectl patch secret awoooi-secrets -n awoooi-prod \ +# -p "$(cat <@192.168.0.188:5432/awoooi_prod\" +# } +# } +# EOF +# )" +# +# (B) 用 sealed-secrets (若有 GitOps 路徑): +# 1. 先用 kubeseal 把 stringData 加密 +# 2. commit 加密後的 SealedSecret manifest 進 k8s/awoooi-prod/ +# 3. argocd sync +# +# 驗收: +# kubectl get secret awoooi-secrets -n awoooi-prod -o jsonpath='{.data.MIGRATION_DATABASE_URL}' | base64 -d +# → 應顯示 postgresql+asyncpg://awoooi_migrator:... + +--- +# 本範本為 patch-style,只列新增欄位,其他欄位保持原樣 +apiVersion: v1 +kind: Secret +metadata: + name: awoooi-secrets + namespace: awoooi-prod +type: Opaque +stringData: + # 既有 24 個 key 不列,避免意外覆蓋 + # ... (既有 DATABASE_URL / TELEGRAM_TOKEN / ... 皆維持原值) + + # 新增: + MIGRATION_DATABASE_URL: "postgresql+asyncpg://awoooi_migrator:@192.168.0.188:5432/awoooi_prod" diff --git a/scripts/host-ops/awoooi-hosts-add.sh b/scripts/host-ops/awoooi-hosts-add.sh new file mode 100644 index 00000000..3652a293 --- /dev/null +++ b/scripts/host-ops/awoooi-hosts-add.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# AWOOOI Hosts White-list Wrapper (ADR-090) +# 建立時間: 2026-04-18 台北時區 +# 建立者: ogt + Claude Opus 4.7 (1M) +# +# 目的: 取代「AI 有全域 /etc/hosts sudo 權限」的安全破口 +# 只允許預定義白名單主機名被寫入,且 idempotent 不重複 +# +# 安裝位置: /usr/local/bin/awoooi-hosts-add +# 安裝權限: root:root 0755 +# 呼叫方式 (需搭配 sudoers): sudo /usr/local/bin/awoooi-hosts-add +# +# 例: sudo /usr/local/bin/awoooi-hosts-add 114.32.151.246 mo.wooo.work + +set -euo pipefail + +# ─── 白名單 ─────────────────────────────────────────────────────────────── +# 新增主機名到這裡,需統帥審查並 git commit +ALLOWED_HOSTS=( + "mo.wooo.work" + "aiops.wooo.work" + "bitan.wooo.work" + "stock.wooo.work" + "tsenyang.com" + "www.tsenyang.com" +) + +# ─── 參數驗證 ───────────────────────────────────────────────────────────── +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + echo "Whitelist: ${ALLOWED_HOSTS[*]}" >&2 + exit 1 +fi + +IP="$1" +HOST="$2" + +# IP 格式驗證 (基本 IPv4) +if [[ ! "$IP" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then + echo "Invalid IP format: $IP" >&2 + exit 2 +fi + +# 主機名白名單檢查 +ALLOWED=0 +for allowed in "${ALLOWED_HOSTS[@]}"; do + if [[ "$HOST" == "$allowed" ]]; then + ALLOWED=1 + break + fi +done + +if [[ $ALLOWED -eq 0 ]]; then + echo "Host not whitelisted: $HOST" >&2 + echo "Contact statesman to update script whitelist + git commit." >&2 + exit 3 +fi + +# ─── Idempotent 寫入 ────────────────────────────────────────────────────── +# 若 /etc/hosts 已有此主機名 (不限 IP),視為已設定,不重複寫 +if grep -qE "^[0-9.]+[[:space:]]+${HOST}\$" /etc/hosts; then + echo "Host $HOST already in /etc/hosts, no change." + exit 0 +fi + +# 寫入 (原子 append) +echo "$IP $HOST" >> /etc/hosts +echo "Added: $IP $HOST to /etc/hosts" diff --git a/scripts/host-ops/awoooi-wrapper.sudoers b/scripts/host-ops/awoooi-wrapper.sudoers new file mode 100644 index 00000000..f1feee49 --- /dev/null +++ b/scripts/host-ops/awoooi-wrapper.sudoers @@ -0,0 +1,27 @@ +# AWOOOI L4 自動化代理人最小權限 sudoers (ADR-090) +# 建立時間: 2026-04-18 台北時區 +# 建立者: ogt + Claude Opus 4.7 (1M) +# +# 安裝位置: /etc/sudoers.d/awoooi-wrapper +# 安裝權限: root:root 0440 +# 安裝前驗證: sudo visudo -c -f /path/to/this/file +# +# 設計原則: +# - 以 command path 精確指定,不開 tee / bash / sh 這類 generic shell +# - /etc/hosts 經 awoooi-hosts-add wrapper 白名單限制主機名 +# - docker kill 只允許 SIGHUP (reload signal),不允許 SIGTERM/KILL +# - 不放 systemctl / apt / reboot / shutdown 任何系統級指令 + +# 1. /etc/hosts 白名單式寫入 (wrapper 自己驗證主機名) +wooo ALL=(root) NOPASSWD: /usr/local/bin/awoooi-hosts-add + +# 2. Prometheus / Blackbox / Alertmanager config reload (SIGHUP only) +wooo ALL=(root) NOPASSWD: /usr/bin/docker kill -s SIGHUP prometheus +wooo ALL=(root) NOPASSWD: /usr/bin/docker kill -s SIGHUP blackbox-exporter +wooo ALL=(root) NOPASSWD: /usr/bin/docker kill -s SIGHUP alertmanager + +# 3. Prometheus / Alertmanager config 檔驗證 (只讀,防禦性) +wooo ALL=(root) NOPASSWD: /usr/bin/docker exec prometheus promtool check config /etc/prometheus/prometheus.yml +wooo ALL=(root) NOPASSWD: /usr/bin/docker exec alertmanager amtool check-config /etc/alertmanager/alertmanager.yml + +# 註: 未來新增命令必經 git commit 的 PR review,不可直接手動加