---
title: "出版簽章金庫 Vault 架設手冊 (FR-173)"
brand: "Guidant AI · **FR-173** 出版管線定稿"
eyebrow: "FR-173 · 操作手冊 · 192.168.50.172"
h1: "Vault 架設手冊"
subtitle: "單節點、apt 安裝、內建 Raft 儲存、內網自簽 TLS、稽核 log、Transit Ed25519 簽章鑰不可匯出"
lede: "從零架一台出版簽章 Vault 的逐步指令，以及重開機、升級、換憑證等日常維運。**172 現在裝成什麼樣、還差什麼，看 [Vault 現況](vault-status.md)。**給決策者自己登入照抄執行。**這份手冊不含任何祕密**：解封鑰匙與 root token 只在初始化當下印在你的終端機，要你自己分開存，絕不進 repo、不貼進對話。每一段都附「預期結果」，跑完對一下再往下。GitLab 串接、祕密搬家、CI 簽章 job 不在這份，FR-173 開卡後由卡處理。"
chips: [
  {text: "主機 192.168.50.172（Ubuntu 26.04）", kind: plain},
  {text: "Vault 2.2.1 Community", kind: plain},
  {text: "祕密不進這份文件", kind: crit},
]
footer: "FR-173 Vault 架設手冊 · 主機座標與步驟；祕密的存放位置見決策者自己的保管紀錄"
---

## 0. 主機座標與範圍 {#coords nav="座標"}

| 項目 | 值 |
|---|---|
| 主機 | `192.168.50.172`，主機名 `hashicorp-vault` |
| 系統 | Ubuntu 26.04.1 LTS（codename `resolute`），4 vCPU、15 GB、磁碟 48 GB |
| 安裝方式 | HashiCorp 官方 apt 套件，systemd 服務，不用 docker |
| 服務位址 | `https://192.168.50.172:8200`（API 與 UI）；`8201` 是叢集埠，單節點只綁本機 |
| 誰可以連 8200 | 188（GitLab runner、build 機）、決策者 Mac；其餘擋掉 |
| 資料目錄 | `/opt/vault/data`（Raft） |
| TLS 目錄 | `/opt/vault/tls` |
| 稽核 log | `/var/log/vault/audit.log` |
| 快照目錄 | `/opt/vault/snapshots`（每天一份，再複製到別台） |
| 簽章鑰 | Transit 引擎 `transit/`，鑰名 `guidant-release`，Ed25519，**不可匯出、不可刪** |

**這份做完的狀態**：Vault 跑起來、已初始化並解封、稽核 log 開著、Transit 鑰建好且能簽能驗、每天自動快照。**還沒做**：GitLab OIDC 角色、KV 祕密、CI 簽章 job、公鑰進 `release-keys/`（這些是 FR-173 的卡）。

---

## 1. 安裝 Vault {#install nav="安裝"}

```bash
ssh jedi@192.168.50.172

wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list

sudo apt update && sudo apt install -y vault jq
vault version
```

**預期**：`Vault v2.2.1`（或更新的 2.x）。套件會建好 `vault` 系統使用者、`/etc/vault.d/`、`/opt/vault/`、`vault.service`。

---

## 2. 內網自簽 TLS 憑證 {#tls nav="TLS"}

自己當一張小 CA，簽 Vault 的伺服器憑證；188 與 Mac 之後信任這張 CA。

🔴 **不要 `cd /opt/vault/tls`**：安裝套件後這個目錄是 `vault` 使用者專屬（700），jedi 進不去，`cd` 會失敗但後面的 `sudo openssl` 照樣跑，檔案就全寫到你當下所在的目錄（2026-11-01 實際踩過，檔案跑到 `/home/jedi/`）。下面一律寫完整路徑。

```bash
T=/opt/vault/tls
sudo mkdir -p $T

# CA（10 年）
sudo openssl genrsa -out $T/vault-ca.key 4096
sudo openssl req -x509 -new -key $T/vault-ca.key -sha256 -days 3650 -subj "/CN=Guidant Release Vault CA" -out $T/vault-ca.pem

# 伺服器憑證（2 年），SAN 要含 IP 與主機名
sudo openssl genrsa -out $T/vault-key.pem 2048
sudo openssl req -new -key $T/vault-key.pem -subj "/CN=hashicorp-vault" -out $T/vault.csr
printf "subjectAltName=IP:192.168.50.172,IP:127.0.0.1,DNS:hashicorp-vault,DNS:localhost\nextendedKeyUsage=serverAuth\n" | sudo tee $T/vault.ext >/dev/null
sudo openssl x509 -req -in $T/vault.csr -CA $T/vault-ca.pem -CAkey $T/vault-ca.key -CAcreateserial -days 730 -sha256 -extfile $T/vault.ext -out $T/vault-cert.pem

sudo chown -R vault:vault $T
sudo chmod 600 $T/vault-ca.key $T/vault-key.pem
sudo chmod 644 $T/vault-ca.pem $T/vault-cert.pem

# CA 憑證是公開的，放一份 jedi 讀得到的位置給 CLI 用（$T 本身 jedi 進不去）
sudo cp $T/vault-ca.pem /etc/vault.d/vault-ca.pem && sudo chmod 644 /etc/vault.d/vault-ca.pem
sudo ls -l $T
sudo openssl x509 -in $T/vault-cert.pem -noout -subject -ext subjectAltName -enddate
```

**預期**：SAN 列出 `IP Address:192.168.50.172`、`hashicorp-vault`；到期日約兩年後。

🔴 **`vault-ca.key` 是這張 CA 的私鑰**：之後換伺服器憑證才用得到。跑完把它複製一份到你的保管處（`sudo cat /opt/vault/tls/vault-ca.key` 貼到密碼管理器），然後從主機刪掉也可以（`sudo shred -u /opt/vault/tls/vault-ca.key`），兩年後換憑證時再拿回來。

---

## 3. 設定檔 {#config nav="設定"}

```bash
sudo tee /etc/vault.d/vault.hcl >/dev/null <<'EOF'
# ── Guidant AI 出版簽章金庫（192.168.50.172）────────────────────────────
# 架法與每一段為什麼這樣設，見 repo docs/features/FR-173-2611-release-pipeline/vault-setup-runbook.md

# 開網頁管理介面：瀏覽器開 https://192.168.50.172:8200/ui 就能看狀態、登入、查鑰匙。
# 走同一個 8200 埠、同一張 TLS 憑證，不另開埠。
ui = true

# 不鎖定記憶體。Vault 預設會把記憶體鎖住、避免祕密被換頁寫到磁碟；
# 但內建儲存（Raft）用的是記憶體對映檔，兩者一起開會吃光記憶體，官方要求這種儲存一定要關。
# 這台不開 swap 就沒有換頁風險（Ubuntu 預設有小 swap，要的話 sudo swapoff -a 並註解掉 /etc/fstab 那行）。
disable_mlock = true

# 對外公告「找我請打這個網址」。GitLab runner、CLI、網頁介面重新導向都用它，
# 所以要填別台機器連得到的 IP，不能填 127.0.0.1。
api_addr = "https://192.168.50.172:8200"

# 叢集節點之間互相溝通用的位址。這台是單節點，沒有別的節點要連，綁本機就好。
# 日後要加第二台做備援時才改成對外 IP。
cluster_addr = "https://127.0.0.1:8201"

# ── 監聽：Vault 在哪個埠收請求 ───────────────────────────────────────────
listener "tcp" {
  # 收 API 與網頁請求的埠。0.0.0.0 表示所有網卡都聽，誰能連進來由第 4 段防火牆決定
  #（只放 188 與決策者 Mac）。
  address = "0.0.0.0:8200"

  # 叢集埠只聽本機，外面連不到（單節點用不到）。
  cluster_address = "127.0.0.1:8201"

  # 第 2 段簽出來的伺服器憑證與私鑰。所有連線都加密，不准走明文 http。
  tls_cert_file = "/opt/vault/tls/vault-cert.pem"
  tls_key_file  = "/opt/vault/tls/vault-key.pem"

  # 最低只接受 TLS 1.2，擋掉舊版不安全的協定。
  tls_min_version = "tls12"
}

# ── 儲存：鑰匙與設定存在哪 ───────────────────────────────────────────────
# 用 Vault 內建的 Raft 儲存，不必另外裝資料庫。磁碟上的內容全部加密，
# 沒有解封鑰匙打不開；簽章私鑰就存在這裡面。
storage "raft" {
  # 資料目錄。每天的快照（第 10 段）就是從這裡拍的。
  path = "/opt/vault/data"

  # 這個節點的名字，叢集裡辨識用。單節點隨便取但取了就不要改，改了要重建。
  node_id = "release-vault-1"
}
EOF
sudo chown vault:vault /etc/vault.d/vault.hcl && sudo chmod 640 /etc/vault.d/vault.hcl
sudo mkdir -p /opt/vault/data && sudo chown -R vault:vault /opt/vault/data
vault operator diagnose -config=/etc/vault.d/vault.hcl -skip=storage 2>&1 | tail -15
```

**預期**：`diagnose` 最後沒有紅色的 `[ failure ]`；黃色 `[ warning ]` 若是說 mlock 關掉、或 swap 開著，可以接受（看上面那段說明決定要不要關 swap）。

設定檔的權限 `640`、擁有者 `vault`：只有 Vault 服務本身和 root 讀得到，一般使用者看不到檔案內容（雖然這份沒有祕密，但習慣上這樣設）。

---

## 4. 防火牆 {#firewall nav="防火牆"}

```bash
sudo ufw allow OpenSSH
sudo ufw allow from 192.168.50.188 to any port 8200 proto tcp comment 'GitLab runner / build'
sudo ufw allow from <你的 Mac 的內網 IP> to any port 8200 proto tcp comment 'decision maker'
sudo ufw enable
sudo ufw status numbered
```

**預期**：只有 22（全開）與 8200（188、你的 Mac）兩條。Mac 換 IP 時要回來改這條。

---

## 5. 啟動 {#start nav="啟動"}

```bash
sudo systemctl enable --now vault
sudo systemctl status vault --no-pager | head -5

echo 'export VAULT_ADDR=https://127.0.0.1:8200' >> ~/.bashrc
echo 'export VAULT_CACERT=/etc/vault.d/vault-ca.pem' >> ~/.bashrc
source ~/.bashrc
vault status
```

**預期**：服務 `active (running)`；`vault status` 顯示 `Initialized false`、`Sealed true`。

**起不來時**：`sudo journalctl -u vault --no-pager -n 20` 看真正的錯誤（`status` 只顯示最後一行）。連續失敗三次後 systemd 會拒絕再起，修好後要先 `sudo systemctl reset-failed vault` 再 `sudo systemctl start vault`。

---

## 6. 初始化（只做一次，🔴 只在你的終端機） {#init nav="初始化"}

```bash
vault operator init -key-shares=5 -key-threshold=3
```

這一步會印出 **5 把解封鑰匙（Unseal Key 1～5）與 1 個 Initial Root Token**。**只會印這一次**，之後拿不回來。

🔴 **保管規則**（照做再往下）：

| 東西 | 用途 | 存哪 |
|---|---|---|
| 解封鑰匙 5 把 | Vault 每次重開機要用其中任 3 把解封 | **分開放**：例如你的密碼管理器 2 把、公司保險箱或另一個管理員 2 把、離線紙本 1 把。任一處外洩都還差 1 把 |
| Root token | 最高權限，只在初次設定用 | 第 9 步做完就**撤銷**，不保留 |

- 不貼進這個對話、不貼進 Notion、不存進任何 repo 或 188。
- 清掉終端機捲動紀錄：`clear && history -c`，並確認沒被其他工具錄下來。

---

## 7. 解封與登入 {#unseal nav="解封"}

```bash
vault operator unseal   # 貼第 1 把，Enter
vault operator unseal   # 貼第 2 把
vault operator unseal   # 貼第 3 把
vault status            # Sealed false

vault login             # 貼 root token
```

**預期**：`Sealed false`、`HA Mode active`（單節點也是 active）。

**以後每次 Vault 主機重開機都要做這一步**（拿 3 把解封）。重開後沒解封，CI 簽章 job 會失敗，退回 Mac 備用章。

---

## 8. 稽核 log、Transit 簽章鑰 {#engines nav="稽核與簽章鑰"}

```bash
# 稽核 log：每次簽章誰簽、何時簽都記下來
sudo mkdir -p /var/log/vault && sudo chown vault:vault /var/log/vault
vault audit enable file file_path=/var/log/vault/audit.log
vault audit list

# Transit 引擎＋Ed25519 簽章鑰（不可匯出、不可刪）
vault secrets enable transit
vault write -f transit/keys/guidant-release type=ed25519
vault read transit/keys/guidant-release
```

**預期**：`read` 結果 `type ed25519`、`exportable false`、`deletion_allowed false`、`allow_plaintext_backup false`。**這三個 false 一個都不能是 true**：`exportable` 一旦打開就關不掉，鑰匙就變成拿得出來。

**試簽一次、驗一次**：

```bash
vault write transit/sign/guidant-release input=$(printf 'hello' | base64)
# 回 signature = vault:v1:<base64>
vault write transit/verify/guidant-release input=$(printf 'hello' | base64) signature=<上一步的 vault:v1:...>
# 回 valid = true
```

**取公鑰**（給 FR-173 V-1 算 kid、放進 `release-keys/`；公鑰不是祕密，可以貼回對話）：

```bash
vault read -format=json transit/keys/guidant-release | jq -r '.data.keys["1"].public_key'
```

---

## 9. 管理者帳號，撤銷 root token {#admin nav="管理者"}

root token 不該留著。建一個管理者政策與一個 userpass 帳號給你日常用：

```bash
vault policy write release-admin - <<'EOF'
path "sys/*"              { capabilities = ["create","read","update","delete","list","sudo"] }
path "auth/*"             { capabilities = ["create","read","update","delete","list","sudo"] }
path "transit/keys/*"     { capabilities = ["read","list","update"] }
path "transit/sign/*"     { capabilities = ["update"] }
path "transit/verify/*"   { capabilities = ["update"] }
path "kv/*"               { capabilities = ["create","read","update","delete","list"] }
EOF

vault auth enable userpass
```

🔴 **以下分段貼，不要整段一起貼。** `password=-` 是「從標準輸入讀」，不會跳提示、打字會顯示，整段貼時會把後面幾行指令當成密碼吃掉（2026-11-01 實際踩過）。用下面的寫法：輸入不顯示、不進 history。

```bash
read -rs -p '新密碼: ' P; echo
printf %s "$P" | vault write auth/userpass/users/jedi password=- policies=release-admin
unset P
```

看到 `Success! Data written to: auth/userpass/users/jedi`，把密碼存進 Apple「密碼」。這段重跑會覆寫密碼，忘記密碼時也用它重設（要先用別的管理者或 root 登入）。

🔴 **先確認新帳號登得進去，才撤銷 root。** 反過來做，root 撤掉後新帳號又登不進去，就只剩解封鑰匙救援（2026-11-01 實際踩過，靠 `generate-root` 救回）。

```bash
vault login -no-store -method=userpass username=jedi > /dev/null && echo 試登成功
```

看到 `試登成功` 再往下（`-no-store` 不會蓋掉目前的 root 登入）：

```bash
vault token revoke -self
vault login -method=userpass username=jedi > /dev/null
vault token lookup | grep -E "^(policies|display_name) "
```

`> /dev/null` 讓 token 不印在畫面上；沒有錯誤訊息就是登入成功。**輸出裡有 `token`、`Unseal Key` 的行，不要貼到任何地方。**

**萬一 root 已撤、新帳號又登不進去**：

```bash
vault operator generate-root -init                       # 記下 OTP 與 Nonce（不要貼出去）
vault operator generate-root -nonce=<Nonce>              # 跑三次，每次一把解封鑰匙；第三次印 Encoded Token
vault operator generate-root -decode=<Encoded Token> -otp=<OTP>   # 印出新的 root token
vault login                                              # 貼它；接著回到本段開頭重做，最後一樣撤銷
```

**預期**：用 `jedi` 登入後 `vault token lookup` 的 `policies` 是 `[default release-admin]`。

帳號名 `jedi` 是決策者 2026-11-01 指定（與主機帳號同名）。稽核 log 記成 `userpass-jedi`；**有第二個管理員時每人各開一個帳號**，不要共用，否則稽核分不出是誰簽的。

root token 撤銷後，要再拿 root 只能用 3 把解封鑰匙跑 `vault operator generate-root`，這是設計上的保險。

---

## 10. 每天快照 {#snapshot nav="快照"}

Transit 鑰匙拿不出來，**快照是唯一的備份**。沒快照、主機壞了，這把章就沒了（還有 Mac 備用章，但要出一版換 kid）。

```bash
sudo mkdir -p /opt/vault/snapshots && sudo chown vault:vault /opt/vault/snapshots

# 快照用的最小權限 token（只能拿快照）
vault policy write snapshot - <<'EOF'
path "sys/storage/raft/snapshot" { capabilities = ["read"] }
EOF
vault token create -policy=snapshot -period=768h -display-name=daily-snapshot -field=token | sudo tee /etc/vault.d/snapshot.token >/dev/null
sudo chown root:root /etc/vault.d/snapshot.token && sudo chmod 600 /etc/vault.d/snapshot.token

sudo tee /etc/cron.daily/vault-snapshot >/dev/null <<'EOF'
#!/bin/sh
set -e
export VAULT_ADDR=https://127.0.0.1:8200 VAULT_CACERT=/opt/vault/tls/vault-ca.pem
export VAULT_TOKEN="$(cat /etc/vault.d/snapshot.token)"
vault token renew >/dev/null
f=/opt/vault/snapshots/vault-$(date +%Y%m%d).snap
vault operator raft snapshot save "$f"
chown vault:vault "$f"
find /opt/vault/snapshots -name 'vault-*.snap' -mtime +30 -delete
EOF
sudo chmod 755 /etc/cron.daily/vault-snapshot
sudo /etc/cron.daily/vault-snapshot && ls -la /opt/vault/snapshots/
```

**預期**：`/opt/vault/snapshots/` 有今天的 `.snap`。本機留 30 天。

🔴 **快照要另外複製到別台**（主機整台壞掉時才救得回）。快照內容是加密的，沒有解封鑰匙打不開，可以放在一般備份位置。存到哪台、怎麼複製由你決定，決定後回寫這節。

**還原（演練用，不要在正式那台做）**：新機裝好 Vault（第 1～5 步）→ 初始化一次（臨時的）→ `vault operator raft snapshot restore -force <檔>` → 用**原本那 3 把解封鑰匙**解封。

---

## 11. 188 與 Mac 信任這張 CA {#trust nav="信任 CA"}

```bash
# 在你的 Mac
scp jedi@192.168.50.172:/etc/vault.d/vault-ca.pem ~/.vault-ca.pem
curl --cacert ~/.vault-ca.pem https://192.168.50.172:8200/v1/sys/health
# 回 JSON，"sealed":false

# 在 188（從 Mac 轉一手過去）
scp ~/.vault-ca.pem jedi@192.168.50.188:/tmp/vault-ca.pem
ssh jedi@192.168.50.188 "sudo cp /tmp/vault-ca.pem /usr/local/share/ca-certificates/guidant-release-vault.crt && sudo update-ca-certificates && curl -s https://192.168.50.172:8200/v1/sys/health"
```

**預期**：兩邊都拿到 `"initialized":true,"sealed":false`。

---

## 11b. 網頁介面 {#ui nav="網頁介面"}

設定檔的 `ui = true` 已開，不用另外裝。瀏覽器開 `https://192.168.50.172:8200/ui`：

1. 第一次會跳憑證警告（自簽 CA），按「進階」→「繼續」；要永久消掉就照第 11 段把 CA 加進 Mac 鑰匙圈：`sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain ~/.vault-ca.pem`。
2. 登入方式（Method）從預設的 **Token** 改選 **Username**，帳號 `jedi`、密碼是 Apple「密碼」裡那組。**不用貼任何 token。**

| 左側選單 | 用來做什麼 |
|---|---|
| Secrets Engines → `transit/` → `guidant-release` | 簽章鑰匙。**Key Actions** 分頁可手動 Sign、Verify；**Details** 看設定（Exportable、Deletion allowed 應為 No）；**Versions** 看每一版公鑰，懷疑外洩時在這裡 Rotate |
| Access | 帳號與登入方式（`userpass/` 底下是 `jedi`；日後多一個 GitLab 用的 `jwt/`） |
| Policies | 權限規則，例如 `release-admin` |

網頁上沒有任何按鈕能取出私鑰；稽核 log 網頁看不到，要在 172 上看 `/var/log/vault/audit.log`。主機重開機後網頁會顯示已封住，在那頁貼 3 把鑰匙也能解封。

---

## 12. 裝完之後 {#after nav="裝完之後"}

裝完把結果記到 [Vault 現況](vault-status.md)：版本、簽章鑰三個 false、公鑰與 kid、快照存哪、還沒做的項目。那一頁是 172 狀態的唯一來源，本手冊只寫步驟。

接著照第 14 段做 FR-173 各批次開工前的設定。

---

## 13. 日常維運 {#ops nav="維運"}

| 情境 | 做什麼 |
|---|---|
| 主機重開 | `vault operator unseal` 三次（3 把鑰匙）或在網頁解封；沒解封 CI 簽章會失敗 |
| 忘記 jedi 密碼 | 用 `generate-root` 拿臨時 root（第 9 段末），重設密碼後撤銷 root |
| 升級 Vault | `sudo apt update && sudo apt install --only-upgrade vault && sudo systemctl restart vault`，然後解封 |
| 伺服器憑證到期（兩年） | 拿回 `vault-ca.key`，照第 2 步重簽 `vault-cert.pem`，`sudo systemctl reload vault` |
| 查誰簽了什麼 | `sudo jq -c 'select(.request.path=="transit/sign/guidant-release") \| {t:.time, who:.auth.display_name}' /var/log/vault/audit.log` |
| 懷疑鑰匙外洩 | `vault write -f transit/keys/guidant-release/rotate` 產新版本 → 新公鑰進 `release-keys/` 出一版 → 舊版本設 `min_decryption_version` 停用 |
| 稽核 log 太大 | 加 logrotate（之後 FR-173 D-1 補） |

---

## 14. FR-173 開工前設定 {#fr173 nav="FR-173 設定"}

**這一段在做什麼**：讓 CI 能自己蓋章、自己拿祕密，不用人在 Mac 上操作。Vault 裡要準備四樣東西：

| 東西 | 白話 | 哪一節 |
|---|---|---|
| 章 | 兩把簽章鑰匙：正式章（客戶機認得，第 8 段已建）、測試章（只有 190 認得） | 14.1 |
| 祕密 | CI 裝套件、推映像、傳檔案、送簽要用的帳密，集中放在 Vault，機器上不再放明文 | 14.5 |
| 權限（policy） | 一張「能做哪些事」的清單，例如「能用測試章簽、能讀 Nexus 帳密」 | 14.2、14.6 |
| 身分規則（角色） | 「誰來要，就給哪張權限清單」的判斷規則 | 14.2、14.4、14.6 |

**誰會來要**：

- **人**：只有兩個帳號。`jedi` 是管理員；`release-dev` 是給 V-1 開發用的，只能蓋測試章（14.2）。
- **CI**：沒有帳號密碼。每次跑的時候，GitLab 發一張臨時身分證給它，上面寫著哪個專案、跑的是 tag 還是 branch、誰按下 Run pipeline。Vault 先向 gitlab.com 確認身分證是真的（14.4），再照角色規則決定給哪張權限清單（14.6），job 跑完就失效。

連線方向：job 在 188 上跑，透過內網連 172 的 Vault；Vault 主動向 gitlab.com 抓公鑰來驗證身分證。外面連不進 172，這一段也沒有開任何對外的口。

在 172 用 jedi 登入後**一行一行貼**（有 `read -rs` 的那行會等你打字，整段貼會把下一行當密碼吃掉）。先：

```bash
export VAULT_ADDR=https://127.0.0.1:8200 VAULT_CACERT=/etc/vault.d/vault-ca.pem
vault login -method=userpass username=jedi > /dev/null
```

### 14.1 測試章（派 V-1 前）

**做什麼**：多建一把只給測試機用的章。測試包用它簽，190 認得，客戶機不認得，所以測試包就算流出去也裝不上客戶機器。

```bash
vault write -f transit/keys/guidant-test type=ed25519
vault read transit/keys/guidant-test
```

**預期**：與正式章一樣三個 false。測試章只給 190 用，公鑰**不進**安裝包，也不放 188 STG、189 POC（任何 branch 都能簽測試章，信任它等於信任沒審過的程式碼）。

### 14.2 開發用「只能簽章」帳號（派 V-1 前）

**做什麼**：V-1 的小弟在 Mac 上開發蓋章工具時，要真的連 Vault 試簽。開一個權限很小的帳號給它用，不能拿管理員帳號。第一段是權限清單，後三行是建帳號（密碼不會顯示在畫面上）。

只能簽**測試章**、讀兩把公鑰。正式章只給 CI 的 `release-ci` 角色（受保護 tag）：開發帳號密碼放在 Mac 的 `.env`，能簽正式章就等於拿到密碼的人簽得出客戶會信的東西。

```bash
vault policy write release-sign-dev - <<'EOF'
path "transit/sign/guidant-test"    { capabilities = ["update"] }
path "transit/keys/guidant-release" { capabilities = ["read"] }
path "transit/keys/guidant-test"    { capabilities = ["read"] }
EOF
read -rs P
printf '%s' "$P" | vault write auth/userpass/users/release-dev password=- token_policies=release-sign-dev token_ttl=1h
unset P
```

在 Mac：

```bash
mkdir -p ~/.config/guidant
scp jedi@192.168.50.172:/etc/vault.d/vault-ca.pem ~/.config/guidant/vault-ca.pem
```

update-center `.env` 加四行：`VAULT_ADDR=https://192.168.50.172:8200`、`VAULT_CACERT=/Users/<你>/.config/guidant/vault-ca.pem`、`VAULT_DEV_USERNAME=release-dev`、`VAULT_DEV_PASSWORD=<剛設的>`。

### 14.3 測試章公鑰只放 190（派批 2 前）

**做什麼**：機器只認自己 `/etc/guidant/release-keys/` 資料夾裡有的公鑰。把測試章的公鑰放到 190，190 才收得下測試包。第一段把公鑰從 Vault 取出存到 Mac，第二段傳到 190。

在 Mac：

```bash
ssh jedi@192.168.50.172 'export VAULT_ADDR=https://127.0.0.1:8200 VAULT_CACERT=/etc/vault.d/vault-ca.pem; vault read -format=json transit/keys/guidant-test' \
  | python3 -c 'import sys,json,base64,hashlib;k=base64.b64decode(json.load(sys.stdin)["data"]["keys"]["1"]["public_key"]);kid=hashlib.sha256(k).hexdigest()[:16];der=bytes.fromhex("302a300506032b6570032100")+k;print(kid);open(f"/tmp/{kid}.pub","w").write("-----BEGIN PUBLIC KEY-----\n"+base64.b64encode(der).decode()+"\n-----END PUBLIC KEY-----\n")'
```

第一行印出測試章 kid（這行要在已登入的終端機跑，或先在 172 登入；公鑰不是祕密）。然後只放 190：

```bash
scp /tmp/<kid>.pub jedi@192.168.50.190:/tmp/ && ssh jedi@192.168.50.190 "sudo install -m 644 /tmp/<kid>.pub /etc/guidant/release-keys/<kid>.pub"
```

188、189 不放：測試章任何 branch 都簽得出來，放了等於讓沒審過的程式碼裝得進 STG、POC。

### 14.4 讓 Vault 認得 GitLab（派 C-3 前）

**做什麼**：開一種新的登入方式，叫 `jwt`，並告訴 Vault 這種身分證是 gitlab.com 發的。做完以後 Vault 才分得出身分證的真假，但還沒有任何權限，要等 14.6 建角色才會給。

```bash
vault auth enable jwt
vault write auth/jwt/config oidc_discovery_url=https://gitlab.com bound_issuer=https://gitlab.com
```

### 14.5 祕密進 KV（派 C-3 前放 Nexus，派批 2 前放其餘）

**做什麼**：開一個存祕密的地方，叫 `kv`，把 CI 要用的五組帳密放進去。CI 跑的時候才臨時讀出來，188 和 GitLab 變數裡就不必再放明文。

```bash
vault secrets enable -path=kv kv-v2
```

值到網頁 `https://192.168.50.172:8200/ui` → `kv` → Create secret 貼，不要打在指令列（會留在 shell 歷史）：

| 路徑 | 欄位 |
|---|---|
| `ci/nexus` | `npm_auth`（`.npmrc` 裡 `_auth=` 那串）、`pypi_user`、`pypi_password` |
| `ci/r2` | `access_key_id`、`secret_access_key`、`endpoint` |
| `ci/harbor` | `user`、`password` |
| `ci/update-site` | `url`、`ci_token`（register-draft 那把） |
| `ci/license-center` | `api_token`、`url`（Agent 打包送簽用；值是 188 `/opt/guidant-ai-agent/.env` 的 `LICENSE_CENTER_API_TOKEN`／`LICENSE_ACTIVATION_SERVER_URL`，放進 KV 後等 C-2 驗完再從 `.env` 刪） |

### 14.6 GitLab 角色

**做什麼**：建兩張權限清單、兩條身分規則，決定 CI 能拿到什麼：

| 指令 | 白話 |
|---|---|
| `vault policy write ci-release` | 正式包權限清單：能用正式章簽、能讀全部五組帳密 |
| `vault policy write ci-test` | 測試包權限清單：只能用測試章簽、能讀 Nexus、Harbor、License Center |
| `vault write auth/jwt/role/release-ci` | 規則：BE 或 Agent 專案、受保護的 `v*` tag、而且是決策者按下的 → 給正式包權限 |
| `vault write auth/jwt/role/test-ci` | 規則：BE 或 Agent 專案、任何 branch、而且是決策者按下的 → 給測試包權限 |

FE 的規則 `fe-ci`（FE 專案任何 job → 只能讀 Nexus）在 C-3 前已經建好。

**只有決策者能發版、能跑測試包，靠 GitLab 帳號編號（`user_id`）擋**：GitLab 免費版的受保護 tag／branch 只能選角色、不能指定某個人，而五位成員都是 Maintainer 以上，用角色擋不住。所以由 Vault 認 GitLab job 身分證裡的 `user_id`（按下 Run pipeline 的那個人）。決策者是 `21126655`（`raymond.jedicotech`）。**之後要加人**：查他的 `user_id`（`glab api users?username=<帳號>`），加進下面兩個角色的 `user_id` 陣列，整塊重貼一次即可，不用改程式、不用重新出版。

```bash
vault policy write ci-release - <<'EOF'
path "transit/sign/guidant-release" { capabilities = ["update"] }
path "transit/keys/guidant-release" { capabilities = ["read"] }
path "kv/data/ci/*" { capabilities = ["read"] }
EOF

vault policy write ci-test - <<'EOF'
path "transit/sign/guidant-test" { capabilities = ["update"] }
path "transit/keys/guidant-test" { capabilities = ["read"] }
path "kv/data/ci/nexus" { capabilities = ["read"] }
path "kv/data/ci/harbor" { capabilities = ["read"] }
path "kv/data/ci/license-center" { capabilities = ["read"] }
EOF
```

```bash
vault write auth/jwt/role/release-ci - <<'EOF'
{
  "role_type": "jwt",
  "user_claim": "user_login",
  "bound_audiences": ["https://192.168.50.172:8200"],
  "token_policies": ["ci-release"],
  "token_ttl": "30m",
  "bound_claims": {
    "project_path": ["auditmanager/compliance-manager-be", "auditmanager/evidence-agent"],
    "ref_type": "tag",
    "ref_protected": "true",
    "user_id": ["21126655"]
  }
}
EOF
```

```bash
vault write auth/jwt/role/test-ci - <<'EOF'
{
  "role_type": "jwt",
  "user_claim": "user_login",
  "bound_audiences": ["https://192.168.50.172:8200"],
  "token_policies": ["ci-test"],
  "token_ttl": "30m",
  "bound_claims": {
    "project_path": ["auditmanager/compliance-manager-be", "auditmanager/evidence-agent"],
    "ref_type": "branch",
    "user_id": ["21126655"]
  }
}
EOF
```

`fe-ci`（只讀 Nexus、誰推都能跑）已建，不用重貼。

**預期**：`vault list auth/jwt/role` 列出 `fe-ci`、`release-ci`、`test-ci`（14.6b 建完再多一個 `classifier-ci`）。

**為什麼用 JSON 寫**：`bound_claims='{...}'` 會被當字串送出，Vault 回 `expected a map, got 'string'`。

**為什麼測試包也拿得到 License Center token**：BE、Agent 打包都要把檔案清單送 License Center 簽（BE `build_release.sh`、Agent `build_all.sh`），沒簽打不出映像；這把鑰客戶端也認，所以測試包和正式包一樣只開給名單上的人。

**殘餘風險**：`user_id` 是「誰按下 Run pipeline」，不是「誰寫的程式」。在別人推的 branch 上按測試包，簽的就是那個 branch 的程式碼；按之前看清楚跑的是哪個 commit。

**為什麼不再加 `ref_protected`**：受保護 branch 在免費版也只能選角色，五位成員都是 Maintainer 以上，加了擋不到任何人；真正的關卡是 `user_id`。測試包要 License Center token 是因為沒簽的產物打不出映像，拿掉就出不了測試包。

**同一把 token 目前還有更大的口子**：188 上 `/opt/guidant-ai-agent/.env`、`/opt/guidant-ai-be/.env` 都明文放著 License Center token，而 188 runner 以 jedi 身分跑，任何在 188 跑的 job（包括 C-3 之後 FE 每次 push 的檢查）讀得到這兩個檔。所以第二批 CI 驗完後，**刪 188 上這些明文、再到 License Center 換一把新 token 放進 Vault**，Vault 這道限制才真的有效。在那之前，風險與現況相同。

### 14.6b jedi 套件的角色（分類器映像推 Harbor）

**做什麼**：jedi 的分類器映像由 BE 觸發，跑在 jedi 的 branch 上，`release-ci`（只認受保護 tag）登不進去；放寬 `release-ci` 又會讓任何 branch 蓋得出正式章。所以另建一個只給 jedi 用、只能讀 Harbor 帳密、不能蓋章的角色。

```bash
vault policy write ci-classifier - <<'EOF'
path "kv/data/ci/harbor" { capabilities = ["read"] }
EOF
```

```bash
vault write auth/jwt/role/classifier-ci - <<'EOF'
{
  "role_type": "jwt",
  "user_claim": "user_login",
  "bound_audiences": ["https://192.168.50.172:8200"],
  "token_policies": ["ci-classifier"],
  "token_ttl": "30m",
  "bound_claims": {
    "project_path": "commonservice3/jedi-python-package",
    "user_id": ["21126655"]
  }
}
EOF
```

**原則：每個 repo 的 CI 一個自己的角色，權限只開它要的**。角色綁專案路徑，祕密照用途分開讀；要蓋正式章的只有 `release-ci`，而且只認受保護 tag。新增 repo 要接 Vault 時，比照這段另建角色，不要把它塞進既有角色。

### 14.7 GitLab 專案設定

**做什麼**：在 GitLab 網頁上做兩件事。第一，把 `v*` tag 設成受保護，只有 Maintainer 能建，這樣 14.6 的 `ref_protected` 規則才有意義。第二，允許 Agent 的 CI 下載 FE 打包好的主控台前端。

- BE、Agent 專案：Settings → Repository → Protected tags → 加 `v*`，Allowed to create 選 Maintainers（免費版只能選角色；「只有決策者」由 14.6 的 `user_id` 擋）。
- FE 專案：Settings → CI/CD → Job token permissions → 加 `auditmanager/evidence-agent`（Agent CI 要下載 FE 的主控台前端產物）。
- 188 照第 11 段信任 CA（2026-11-03 裝上；FE CI 第一次實跑前沒裝，curl 報 `SSL certificate problem` 即此原因）。
