# 第 5 批（A：信任鏈與客戶層級設定）（14 件 → 5 張卡）

## 這批一句話

這批修的是**「系統相信誰」這件事本身壞掉**的十四條：代理程式打進來不必證明身分（#1，最嚴重）、防竄改的那把驗章工具可以被整支換掉（M08 五條）、一家客戶底下任何一層的管理員都改得動全系統共用的登入與日誌設定（#19／#22）、交出去的檔案與日誌沒編碼（#20／#93），再加一條停權後通行證照樣能用（M13 第 17 條）。**排在第 5 批不是因為不嚴重**——#1 是全站最嚴重那條——是因為這批每條都要**先定信任鏈的形狀**（拿什麼當身分證明、哪一層才算「客戶自己」），方向不定就會各棒各修出不同的一套。**現在可以開，但卡 5A-1（代理程式身分）與卡 5A-2（防竄改）要先等決策者裁方向**，其餘三張（客戶層級設定、交出去沒把關、停權即時生效）方向已拍板，可立即派。**依賴**：不依賴前四批；卡 5A-3 與第 5 批 B／C 子集若也動 `system_config` 相關檔，序列組要對齊（本子集內已標）。

## 卡片清單

| 卡 | 卡名（做什麼） | repo／套件 | 涵蓋 SUMMARY # | 建議 model／effort | 序列組 |
|---|---|---|---|---|---|
| 5A-1 | 讓代理程式每次打進來都要證明身分，並讓「撤銷」真的斷得乾淨 | agent repo（`evidence-agent`）＋套件 `jedi-remote-agent` | #1（含 1b 撤銷只做一半） | fable／high | A |
| 5A-2 | 把驗簽工具與鎖定紀錄變成拔不掉的，讓防竄改的三層檢查不再共用一個弱點 | 套件 `jedi-integrity`＋BE（`common/util/resource_path.py`、`scripts/build/`） | #17、#18、#90、#91、#133 | fable／high | B |
| 5A-3 | 把「改全公司共用設定」收到客戶總部那一層，並補上儲存設定的密鑰遮蓋 | 套件 `jedi-system-core`＋`jedi-log`＋BE（`core/plugins/`、`app/system_config/`） | #19、#22、#134 | opus／high | C |
| 5A-4 | 交出去的檔案與日誌統一編碼：匯出的試算表不再被當公式執行、日誌不再被讀成兩筆 | 套件 `jedi-common`（共用函式）＋`jedi-log`＋`jedi-issue` | #20（M09-2＋M21-1）、#93（M09-4） | opus／high | D |
| 5A-5 | 讓停權當下通行證立刻失效，順便把日誌轉送的加密選項給客戶 | 套件 `jedi-iam`＋`jedi-log`＋FE（下拉選項） | M13 第 17 條（不在 145 件內）、#92 | opus／medium | E |

---

## 卡 5A-1：讓代理程式每次打進來都要證明身分，並讓「撤銷」真的斷得乾淨

- **範圍**：**兩個 repo 都要動**——agent repo `~/Projects/Billows/Audit-Manager/evidence-agent`（出站端要帶上身分證明）＋套件 monorepo 的 `jedi-remote-agent`（接收端要驗、歸屬比對要做進核心層）。worktree 建議：agent 側 `wt-fix-b5A-agent`、套件側 `wt-fix-b5A-remote-agent`。涵蓋 #1。
  ⚠️ **兩個 repo 要同一個 runner 同一棒做完**：接收端開始要求身分證明的那一刻，出站端沒帶就是全部 agent 掉線。分兩棒做等於中間必然有一段全斷。

- **每件的修法**：

  - **#1 裝在客戶機房那支代理程式，五條通訊管道沒有一條會問「你是誰」——網路上任何人不必帳號就能冒充它領走客戶整套主機的登入帳密；管理畫面按「撤銷」只斷了三分之一，被撤銷的機器照樣能回報假資料**

    - 修法（模組頁定案的五步，第一步要決策者先裁，見本卡 D-b5A-1）：
      1. **先定用哪種身分證明**——模組頁建議「短效通行證」（產品本來就會簽發，不必動客戶機房設備），客戶端憑證最多當輔助、不可當唯一手段（它只證明「你是登記過的機器」，不證明「這張工單是你的」）。
      2. **五條管道一起掛上驗證**，身分從驗過的通行證推出來，**不再讀請求裡自報的 `agent_uid`**。只修其中幾條，剩下的還是門戶洞開。
      3. **「這張工單是不是你的」做成核心層的必要條件**——不是只在最外層加一道，否則日後多一條呼叫路徑（批次補登、維運工具、修復腳本）就會再繞過一次。
      4. **同一層順便補「這台是不是已被撤銷」**——目前只有「領工作」那條查了，`ack` 與 `result` 兩條完全沒查（就是 1b）。既然要動這一層，邊際成本接近零。
      5. 最外層再補客戶端憑證確認，屬連帶、不可取代第 3 步。
      ⚠️ **程式裡那句「這裡不用檢查，因為外層網頁伺服器會驗憑證」的註解共四處，而它提到的把關元件 2026-08-20 已退役**——修的時候要一併刪掉，別留著誤導下一棒（CLAUDE.md 註解規範：過期脈絡會把 AI 與人一起帶偏）。

    - 🔴 入口清單：
      - **接收端：五條管道的路由表（`needs_admin=False` 那五列就是全部洞口）**
        `jedi-remote-agent/jedi_remote_agent/api/routing.py:37-41`
        （`/agents/register`、`/agents/heartbeat`、`/agents/tasks/<uid>/ack`、`/agents/tasks/<uid>/result`；第五條是同層的憑證／證據下載，runner 開檔時以 `needs_admin=False` 為判準逐列點名）
      - **接收端：五支 route 的 handler**
        `jedi-remote-agent/jedi_remote_agent/api/routes/remote_agent_route.py:164`（register）／`:173`（heartbeat）／`:182`（ack）／`:188`（result）
      - **接收端：註解自陳「純 mTLS，不掛任何 user 認證」與「自報 agent_uid 決定」**
        `jedi-remote-agent/jedi_remote_agent/api/routes/remote_agent_route.py:11`
      - **接收端：目前唯一有查 revoked 的地方（要比照補到 ack／result）**
        `jedi-remote-agent/jedi_remote_agent/app/service/agent_enrollment_service.py:377`（`if agent.status == "revoked"`）
        `jedi-remote-agent/jedi_remote_agent/domain/remote_agent/service/remote_agent_domain_service.py:107`（派工側的同一判斷，是「歸屬比對做進核心層」的落點候選）
      - **接收端：撤銷那支自陳「雲端所有資料面呼叫即拒」的註解（只對了一半，要一起改）**
        `jedi-remote-agent/jedi_remote_agent/app/service/remote_agent_service.py:113`
      - **出站端（agent repo）：四處控制面呼叫，每處都要帶上身分證明**
        `evidence-agent/core/enroll.py:159`（register，`httpx.post`）
        `evidence-agent/core/enroll.py:236-237`（heartbeat，`httpx.Client(cert=...)`）
        `evidence-agent/core/task_executor.py:167-172`（`_post`，ack 與 result 共用）
        `evidence-agent/core/task_executor.py:179`／`:210`（`_get_stream`，證據檔下載）
      - **出站端：四處出站呼叫已收攏的說明（改的時候照這份分界走，別把兩條 CA 鏈搞混）**
        `evidence-agent/core/cloud_trust.py:20-40`（`cloud-ca.pem` 驗雲端 https ≠ `cert_dir/ca.crt` 驗 agent client 憑證，混用會寫出「看似正確其實必錯」的碼）
      - **出站端：路徑常數**
        `evidence-agent/core/enroll.py:36-37`、`evidence-agent/core/task_executor.py:31-32`

    - 功能不能壞：**這張卡最大的風險是「修完全部 agent 掉線」。**
      - 手測：① DEV 起 BE ＋ 起一台 agent，確認 `register` → `heartbeat` → 派一張工單 → `ack` → `result` → 證據檔上傳全鏈走得通；② 把 agent 的身分證明刻意弄壞（改掉通行證／憑證），確認五條都回 401／403 而不是 500；③ 管理頁按「撤銷」後，讓那台 agent 再打 `ack` 與 `result`，**兩條都要被拒**（這是 1b 的驗收點，修前是照樣進得來）；④ 按「取消撤銷」後恢復正常；⑤ 授權過期的 agent 打進來要被擋（模組頁「四個讓事情更嚴重的細節」①）。
      - ⚠️ 原則十一（安全措施不可擋住正常客戶）：通行證的**發放與更新**要處理好，不能出現「通行證過期 → agent 永久失聯 → 要人進客戶機房重裝」。續期失敗時的行為要明確（重試 + 記錄，不是靜默死掉）。
      - 落地版客戶各自裝機，**不要選需要改客戶機房設定的方案當主軸**（這正是模組頁不建議走客戶端憑證的理由）。

    - 修完擋得到誰要寫進回報（模組頁已有對照表）：擋得到網路上的外人與 A 客戶冒充 B 客戶；**擋不到能實際碰到那台機器的人——那是設計本身不是缺陷**（agent 要去掃客戶主機就必須持有那些鑰匙）。回報時照抄這個分界，不要寫成「已修好、風險歸零」。

- **這張卡的手測總清單**：
  1. 全鏈正常路徑（register／heartbeat／派工／ack／result／證據下載）六步都通。
  2. 身分證明造假或缺失 → 五條全部 401／403。
  3. 撤銷後 `ack`／`result` 兩條被拒（1b 的核心驗收）。
  4. 取消撤銷後恢復。
  5. 拿 A 客戶的身分去打 B 客戶的工單 → 被拒（歸屬比對）。
  6. 通行證接近到期 → 自動更新成功，不掉線。

- **需決策者先裁的**：
  - **D-b5A-1：代理程式拿什麼當身分證明——短效通行證，還是客戶端憑證？**／我的建議：走**短效通行證**當主軸（產品本來就簽得出來、不必動客戶機房設備，落地版客戶各自裝機，改客戶設定的成本高一個量級），客戶端憑證之後再補當第二道，但不當唯一手段。
  - **D-b5A-2：「歸屬比對」要做在哪一層？**／我的建議：做進**核心層（domain／app service）當必要條件**，不要只掛在路由最外層——否則日後任何新呼叫路徑（維運工具、批次補登）都會再繞過一次，這正是這條問題的原始成因。

---

## 卡 5A-2：把驗簽工具與鎖定紀錄變成拔不掉的，讓防竄改的三層檢查不再共用一個弱點

- **範圍**：套件 `jedi-integrity`（主體）＋ BE（`common/util/resource_path.py`、`scripts/build/build_release.sh`）。worktree 建議 `wt-fix-b5A-integrity`（套件）＋ BE 同棒改兩檔。涵蓋 #17、#18、#90、#91、#133。
  🔴 **重要前提：這個套件在掃描之後被重構過，掃描總表與模組頁上的行號多半已漂移**。下面入口清單的行號是 **2026-09-21 重新開檔核對過的現值**，與報告上的舊行號不同屬正常，不要回頭照抄報告的行號。

- **每件的修法**：

  - **#17（M08-1）有主機管理權限的人把「負責核對簽章」的零件換成永遠說沒問題的假貨，之後任意竄改產品程式，三層檢查照樣顯示通過、全程零警示（已實測證實）**

    - 修法：把驗簽用的 `cryptography` **強制編譯進 core 層**，讓它不再是一個可以被單獨換掉的檔案。模組頁與掃描總表列了三個方案（① 在產生清單的工具裡新增「安全關鍵」分層並加進開機必查／② build 腳本把 `cryptography` 強制拉回 core／③ 最低限度加進效能監控優先清單），**總表建議採 ①**。另外建議把抽查邏輯改成「保證涵蓋安全關鍵檔案 ＋ 其餘隨機抽」，不要純隨機。→ 三方案要哪個見 D-b5A-3。
    - 🔴 入口清單：
      - `jedi-integrity/jedi_integrity/domain/config.py:47-48`（`DEFAULT_BOOT_LAYERS = ("core", "resources")`——thirdparty 不在開機必查之列，就是這一行）
      - `jedi-integrity/jedi_integrity/domain/config.py:37`（`DEFAULT_SPOT_CHECK_THIRDPARTY_SAMPLE = 200`，每輪隨機抽 200 個）
      - `jedi-integrity/jedi_integrity/domain/config.py:84`（該值進 config dataclass 的欄位）
      - `jedi-integrity/jedi_integrity/app/runtime_check.py`（四小時抽查的實作；報告舊指 `runtime_check.py:181-191`，⚠️ **行號已漂移，runner 開檔以「抽樣那段」為準**）
      - `scripts/build/build_release.sh:108`（`pdfminer` 在排除清單上）
      - `scripts/build/build_release.sh:375`（註解明寫 `pdfminer.six → charset_normalizer, cryptography` 這條牽連鏈）
      - `scripts/build/build_release.sh:874`／`:889`／`:894`／`:909`／`:930`（thirdparty 清單如何從產物實況產生並寫進 manifest）
      - ⚠️ `jedi_integrity/config.py` 現在只是 6 行的相容 shim（`from jedi_integrity.domain.config import IntegrityConfig`），**邏輯不在那裡**，別往 shim 裡加東西
    - 功能不能壞：改 build 腳本的排除邏輯會影響**整包產物大小與開機時間**。手測：① 在 188 build 機跑一次完整 build（`scripts/build/build_all.sh --all`），確認編譯成功且 smoke 過；② 產物開機，確認開機檢查通過且 log 有印出 core 層涵蓋了 `cryptography`；③ 故意替換 `cryptography` 的驗簽器（套件自帶測試工具做得到，FR-084 已實測過一次），確認**這次開機被擋下**；④ 四小時抽查改成「必含安全關鍵」後，確認一輪抽查就會驗到那些檔。
      ⚠️ 原則十一：不可出現「正常客戶的產物因為新分層算不出清單而開不了機」。清單產生工具與 build 腳本要同一棒一起改、一起驗。

  - **#18（M08-2）客戶端機器因竄改被鎖住後，有主機權限的人只要刪掉一個檔案就能讓機器重新開起來，而那筆鎖定紀錄正是這機制原本要保留的證據**

    - 修法（模組頁定案：**兩件都要做**）：**止血**＝把文件改成跟程式一致（零成本）；**治本**＝開機時另外去查一次資料庫紀錄。治本的兩條路見 D-b5A-4：① 開機時另開一條短連線查 tamper 紀錄表（那張表刻意設計成不受 RLS、不需登入者身分，技術上可行）；② 讓補同步機制在發現「DB 有紀錄但檔案標記不見了」時**反過來把標記補回去並終止開機**（目前補同步只有單方向）。
    - 🔴 入口清單：
      - `jedi-integrity/jedi_integrity/app/startup_gate.py`（開機唯一讀的竄改狀態只有檔案標記；`:54` import `tamper_marker`、`:99` `_refuse_for_existing_marker`、`:154` `write_marker`。⚠️ 報告舊指 `startup_gate.py:206`，**行號已漂移**）
      - `jedi-integrity/jedi_integrity/infra/tamper_event_repo.py:97`（`def exists(...)`——DB 側的查詢方法。報告說「兩個地方都零呼叫」，⚠️ **runner 開工前重跑一次 `grep -rn "\.exists(" ` 於套件與 BE 兩側確認現況**，別只信報告）
      - `jedi-integrity/jedi_integrity/app/service/tamper_sync_service.py`（補同步；程式自陳「補同步是為了把『檔案標記被清掉』的風險降到最低」，就是缺口的自白）
      - `jedi-integrity/jedi_integrity/domain/tamper_marker.py:102-129`（`read_marker` 對毀損的處理——**這支取的是嚴格側**（`corrupted: True` 即視同鎖定），是 #91 的正確對照範本）
      - BE 側 repo 使用點：`core/plugins/integrity.py:48`、`core/scheduler.py:343`（都是 `from jedi_integrity.infra.tamper_event_repo import TamperEventRepo`）
      - 文件三處（止血要改的）：`jedi-integrity/jedi_integrity/__init__.py`、`jedi-integrity/jedi_integrity/domain/tamper_marker.py` 檔頭、`jedi-integrity/README.md`（⚠️ 報告給的是舊行號 `__init__.py:11`／`tamper_marker.py:5-7`／`README.md:32`，**開檔以「宣稱有兩道鎖」那句為準**）
    - 功能不能壞：開機時多一條 DB 連線是**開機路徑**上的改動，連不上 DB 就不能變成「開不了機」。手測：① 正常開機通過；② DB 暫時連不上時開機行為要明確（原則十一：往安全的預設值倒，但不可讓正常客戶開不了機——這一點要在卡上寫清楚並由決策者確認，見 D-b5A-4 附註）；③ DB 有鎖定紀錄但把檔案標記 `rm` 掉 → **開機被擋**（這是核心驗收，修前是能開起來的）；④ 解鎖流程走完後能正常開機。

  - **#90（M08-3）有主機權限的人改一個設定，把「要核對哪個目錄」整個換掉，而且這設定的優先順序比「是不是正式打包版」還高**

    - 修法：**在打包模式下忽略這個環境變數**（與系統「只能加嚴、不能放寬」的既有設計原則一致）。改之前先確認沒有正式環境的部署真的依賴它換目錄。
    - 🔴 入口清單：
      - `common/util/resource_path.py:23`（`_ENV_OVERRIDE = "GUIDANT_RESOURCE_ROOT"`）
      - `common/util/resource_path.py:31-39`（`resource_root()`——**先看環境變數、才看 `is_packaged()`**，順序就是問題）
      - `common/util/resource_path.py:26-28`（`is_packaged()`，判斷依據 `__compiled__` / `sys.frozen`）
      - 套件側使用點（改了之後誰會受影響）：`jedi-integrity/jedi_integrity/domain/ports.py:84`（`resource_root` port）、`jedi_integrity/app/runtime_check.py:105`／`:164`／`:228`／`:268`、`jedi_integrity/app/startup_gate.py:150`／`:226`
      - ⚠️ 零引用／依賴查證：**開工前 grep 三處**——BE `.env` sample、`deploy/`／Dockerfile、188／189 兩台 `/srv/guidant-ai` 的 env 檔（唯讀，照 CLAUDE.md 讀寫分界允許），確認沒有實際部署在用這個變數換目錄。查到有在用就停下回報，不自己拿掉。
    - 功能不能壞：開發模式必須照舊能用這個變數（開發者靠它指到原始碼樹）。手測：① 開發模式設 `GUIDANT_RESOURCE_ROOT` → 仍然生效；② 打包產物設同一個變數 → **被忽略**，仍核對 binary 所在目錄；③ 打包產物不設變數 → 行為不變。

  - **#91（M08-4）有主機權限的人把記錄「解鎖檔用過沒」的那個檔案改成亂碼，系統會當作一張都還沒用過，已用過的一次性解鎖檔就能重新復活使用**

    - 修法：把「**檔案不存在**」（合理，回空清單沒問題）與「**檔案內容毀損**」（不合理，應拒絕核銷、要求聯繫原廠）分開處理。同一個套件裡 `tamper_marker.read_marker` 取的正是嚴格側，**照它的形狀改**（兩處不一致就是這條的成因）。
    - 🔴 入口清單：
      - `jedi-integrity/jedi_integrity/app/unlock.py:83-100`（`_read_used_nonces`——⚠️ **這一段行號經 2026-09-21 重新核對仍然準確**。`:92-93` 是 `FileNotFoundError, OSError → return []`（合理側）、`:96-97` 是 `ValueError, TypeError → return []`（**要改成拒絕的那一側**）、`:99` 是結構不合法 → `return []`（同樣要改）；docstring `:84-89` 明寫「毀損時取寬鬆側」的理由，改完要一起更新）
      - `jedi-integrity/jedi_integrity/app/unlock.py:51`（`USED_NONCES_FILENAME = "used-nonces.json"`）、`:75`（`used_nonces_path`）
      - `jedi-integrity/jedi_integrity/app/unlock.py:103`（`_append_used_nonce`，寫入側）、`:218`（`try_redeem`，核銷主流程）
      - **正確範本**：`jedi-integrity/jedi_integrity/domain/tamper_marker.py:102-129`（`read_marker` 回 `{"corrupted": True, ...}`）與 `jedi_integrity/app/startup_gate.py:99-113`（`_refuse_for_existing_marker` 對 `corrupted` 的處置）
    - 功能不能壞：⚠️ **原句 docstring 已經指出取嚴格側的代價——「一次檔案損毀就會讓機器再也解不開，只能重灌」**。這正是原則十一要管的情形。修法必須同時給出路：毀損時拒絕核銷，但**錯誤訊息要明確告知客戶怎麼辦**（聯繫原廠、附上機器指紋與事件編號），不能只是一句拒絕。手測：① 正常核銷一張解鎖檔 → 成功；② 同一張再用 → 被拒（原本就會拒）；③ 把 `used-nonces.json` 改成亂碼 → **核銷被拒且訊息可讀**（修前是「視同全新、可重複使用」）；④ 檔案不存在（首次解鎖）→ **仍然可以核銷**（這一側不可改壞）。

  - **#133（M08-6）系統向原廠回報「偵測到竄改」時，會把主機紀錄檔最後 50 行原封不動送出，那 50 行若剛好夾帶密碼或登入憑證就一起被送出去**

    - 修法：送出前套用系統其他地方送日誌時用的那套遮蔽規則——**工具是現成的，不需要重寫**（「先查再寫」：`jedi-log` 的 `jedi_api_log/error_tracking/masking.py` 是現成候選，runner 開工前先開檔確認它的簽名吃不吃這種純文字行）。或按總表的另一個選項：只送行數與時間戳記、不送內容。
    - 🔴 入口清單：
      - `jedi-integrity/jedi_integrity/infra/forensic.py:44`（`_LOG_TAIL_LINES = 50`）
      - `jedi-integrity/jedi_integrity/infra/forensic.py:88`（`result["log_tail"] = _log_tail(context.config)`——送出前的組裝點）
      - `jedi-integrity/jedi_integrity/infra/forensic.py:108`（`_log_tail` 本體。⚠️ 報告舊指 `forensic.py:108-129`，**`:108` 這個起點仍準確**）
      - `jedi-integrity/jedi_integrity/infra/forensic.py:76`（`collect_trigger_context`，呼叫者）
      - 現成遮蔽工具候選：`jedi-log/jedi_api_log/error_tracking/masking.py`
    - 功能不能壞：遮蔽過度會讓原廠拿不到有用的診斷資訊。手測：① 觸發一次竄改回報（測試工具做得到），確認送出的 payload 裡日誌內容仍可判讀故障原因；② 在日誌裡塞一段像密碼／token 的字串，確認送出的 payload **該段已被遮蔽**。

- **這張卡的手測總清單**：
  1. 在 188 build 機完整 build 一次，smoke 通過。
  2. 產物正常開機通過，log 顯示 `cryptography` 已在 core 層必查範圍。
  3. 替換驗簽器 → **開機被擋**（#17 核心驗收，修前是綠燈放行）。
  4. 四小時抽查一輪就涵蓋安全關鍵檔案。
  5. DB 有鎖定紀錄但 `rm` 掉檔案標記 → **開機被擋**（#18 核心驗收）。
  6. 打包產物設 `GUIDANT_RESOURCE_ROOT` → 被忽略；開發模式設 → 仍生效（#90）。
  7. `used-nonces.json` 改成亂碼 → 核銷被拒且訊息可讀；檔案不存在 → 仍可核銷（#91）。
  8. 竄改回報 payload 中密碼樣字串已遮蔽、診斷資訊仍可讀（#133）。

- **需決策者先裁的**：
  - **D-b5A-3：`cryptography` 怎麼變成拔不掉的——三方案選一？**／我的建議：採**方案 ①「在產生防竄改清單的工具裡新增安全關鍵分層」**（掃描總表亦建議此案）。理由：方案 ② 直接改 build 排除邏輯會牽動整包產物與 37 個被排除的相依套件，回歸面大；方案 ③ 只是順便驗到、不是保證。①另有一個附帶好處：日後再多一支安全關鍵套件，只要加進分層清單，不必再動 build 腳本。**並同時把抽查改成「必含安全關鍵 ＋ 其餘隨機」。**
  - **D-b5A-4：#18 治本走哪一條——開機查資料庫，還是讓補同步反向補回標記？**／我的建議：走**①開機時另開短連線查紀錄表**（那張表本來就設計成不受隔離規則、不需登入者身分，是刻意留的後路），②反向補同步當輔助一起做。**附帶要一併裁的**：DB 在開機當下連不上時要「擋下開機」還是「放行並記警示」——原則十一（不可擋住正常客戶）與防竄改的嚴格性在這裡直接衝突，我的建議是**連不上就放行但留下高等級警示紀錄並在管理頁顯示**（DB 連不上本來就會讓系統整體不可用，不必在這一關多擋一次），但這一點請決策者確認。
  - **D-b5A-5：#91 毀損即拒絕之後，客戶的出路是什麼？**／我的建議：拒絕核銷但**畫面／stdout 明確給出機器指紋＋事件編號＋聯繫原廠指引**，並確認原廠這一端有「重新簽發解鎖檔」的流程（簽發端是 license_center 獨立系統）。若簽發端目前無此流程，這條就要降級成「先記警示不拒絕」——請決策者裁。

---

## 卡 5A-3：把「改全公司共用設定」收到客戶總部那一層，並補上儲存設定的密鑰遮蓋

- **範圍**：套件 `jedi-system-core`（系統設定寫入）＋ `jedi-log`（日誌轉送設定寫入）＋ BE（`core/plugins/system_core.py`、`core/plugins/api_log.py`、`app/system_config/`）。worktree 建議 `wt-fix-b5A-config`。涵蓋 #19、#22、#134。
  **這三條併一張卡的理由**：#19 與 #22 是**同一個病灶的兩個位置**（模組頁明寫「當成同一件事處理、套用同一條規則，兩條一起修」），#134 是 #22 同一個設定頁的遮蓋漏項、改同一批檔。

- **每件的修法**：

  - **#22（M22-1）任何一家客戶（含子公司）的管理員在系統設定頁就能改掉全公司所有人的登入規則，更嚴重的是他能把員工帳號目錄指到自己的機器，等於接管所有人的登入而且看不出來**

    - 修法（**已拍板**，原則六）：**只開放給客戶自己組織樹第一層（總部）的管理員改，子公司的管理員改不到。三支寫入路徑都要補上這個判斷。** 具體做法是寫入前多問一次「這個人所屬的單位是不是客戶組織樹的第一層」——**不必改資料表結構**，組織單位本來就記得住「上一層是誰」。平台管理員身分更高、自然也改得到，不需另加條件。
      ⚠️ **「第一層」指客戶自己組織樹的第一層（總部），不是系統內建、只給平台方後台用的那個最上層**——那條界線維持不動。
      ⚠️ 修的優先序：模組頁明寫**員工帳號目錄（LDAP）比登入安全政策更該先修**（改壞了看不出來、直接接管所有人登入）。
    - 🔴 入口清單：
      - **三支寫入路徑（套件側）**：
        `jedi-system-core/jedi_system_core/app/service/system_config_service.py:54`（`create_system_config`）
        `jedi-system-core/jedi_system_core/app/service/system_config_service.py:80`（`update_system_config`）
        `jedi-system-core/jedi_system_core/app/service/system_config_service.py:165`（`update_config_by_group_key`）
      - **對應 route**：`jedi-system-core/jedi_system_core/api/routes/system_config_route.py:64`（`put(uid)`）／`:98`（`post`）
      - **BE 側守門現況（新判斷要接在這裡，不要另立第二套）**：
        `core/plugins/system_core.py:62-75`（`_GROUP_CAPABILITY_RESOURCE`，GROUP→能力點對照；`THIRD_PARTY_LOGIN`→`ldap-config`、`SMTP`→`smtp-config`、`RUNTIME_CONFIG`→`security-policy`）
        `core/plugins/system_core.py:93`（`assert_config_capability`——套件經 `adapters.config_capability_guard` 呼叫這支，**這裡就是掛新判斷的正確位置**；它已沿用 `common.authz` 的 `viewer_has_capability`，含 super_admin break-glass）
      - **繞過隔離的那段程式（問題的機制核心）**：
        `infra/system_config/system_config_root_reader.py:60`／`:96`／`:122`（`SET LOCAL app.is_super_admin = 't'`——明文關掉 RLS 以寫進 ROOT 那一列）
        `infra/system_config/system_config_root_reader.py:136`（`write_root_config_value`）
      - **三支寫入的 BE 側實際落點**：
        `app/system_config/service/shared_config_app_service.py:38-41`（`SHARED_ROOT_CONFIGS = {"SMTP": None, "THIRD_PARTY_LOGIN": "LDAP"}`——**共用設定的白名單就是這裡**）
        `app/system_config/service/shared_config_app_service.py:68-78`（`write_shared_config`）
        `app/system_config/service/security_policy_app_service.py:108`（`update_policy`，登入安全政策專屬端點）／`:162`（`write_root_config_value` 呼叫）
        `app/system_config/service/guarded_system_config_service.py:458`（`self._shared.write_shared_config(...)`）／`:585`（`create_system_config`）／`:597`（`update_system_config`）／`:636`（`update_config_by_group_key`）／`:651`（`is_shared_root_config` 分流）
      - **「這個人所屬單位在不在第一層」要用的既有能力（先查再寫）**：
        `jedi-iam/jedi_iam/domain/service/org_unit_domain_service.py:194`（已有依 `parent_id` 取子節點的用法，判「是否第一層」從這支的 repo 查 `parent_id` 即可）
        ⚠️ **runner 開工前先 grep 一次「有沒有現成的『是否客戶總部』判斷」**（`grep -rn "is_root_tenant\|parent_id is None\|top_level" jedi-iam/ common/authz/`）——`common/authz/license.py:16` 提到 `GET /license/status` 有 `is_root_tenant` 旗標，可能已有現成件。有就 reuse，別新造。
    - 功能不能壞：**改完子公司管理員會從「能存」變成「403」**——這是刻意的，但要確認 ① 客戶總部的管理員照樣存得下去（不能連總部都擋掉）；② 平台管理員照樣存得下去；③ 403 的錯誤訊息要讓客戶看得懂「要請總部的管理員來改」，不是一句無說明的拒絕（原則十一）。
      手測：① 用總部管理員改 LDAP／SMTP／登入政策 → 三頁都存得下去；② 用子公司管理員改同三頁 → **403 且訊息可讀**；③ 用平台管理員改 → 存得下去；④ 改完後讀取端仍讀得到（讀寫同落 ROOT 的閉環別弄壞，`shared_config_app_service.py` docstring 記錄過「寫入釘了 ROOT 但讀取端漏了」的舊傷）。

  - **#19（M09-1）管理員在「日誌轉送設定」填一台伺服器位址按儲存（連子公司層級的管理員都按得下去），所有客戶的系統活動紀錄從此持續送往他填的機器**

    - 修法（**已拍板**，與 #22 同一條規則）：只開放給客戶自己組織樹第一層（總部）的管理員改。**客戶自己決定日誌送去哪，這個自主權完全保留**，只是改的人必須是總部管理員。
    - 🔴 入口清單：
      - **寫入端點**：`jedi-log/jedi_api_log/forwarding/api/routes/log_forwarding_route.py:57`（`def put` — 更新設定）／`:73`（`def post` — 測試發送）
      - **守門由宿主注入**（套件側不硬編授權，改動點在 BE）：`jedi-log/jedi_api_log/forwarding/api/routes/log_forwarding_route.py:9`（檔頭說明 `adapters.settings_required` / `settings_update_required`）、`jedi-log/jedi_api_log/forwarding/plugin/contract.py:64-90`（`LogForwardingAdapters` 契約，明寫 `read_required` / `update_required` **兩欄分開是刻意的、都沒有預設值**）
      - **BE 側注入點（新判斷掛這裡）**：`core/plugins/api_log.py:263`（`from common.authz import require_capability, require_platform_admin_route`）／`:272`（`_forwarding_guard` 內 `jwt_required()(require_capability(capability)(fn))`）／`:288`（`forwarding_read_required`）／`:289`（`forwarding_update_required` — **要補「總部第一層」判斷的就是這一條**）
      - **BE 側對照說明**：`core/plugins/api_log.py:37-38`（port 對照表，`.read` / `.update` 讀寫分權是產品決定）
      - **設定實際落地處**：`jedi-log/jedi_api_log/forwarding/infra/model/log_forwarding_setting.py`（全系統只有這一份設定的那張表）
    - 功能不能壞：手測 ① 總部管理員改日誌轉送目標 → 存得下去、按「測試」送得出去；② 子公司管理員改 → **403 且訊息可讀**；③ 平台管理員改 → 存得下去；④ 讀取（GET）行為不變——**只收寫入，不要連讀都擋掉**（契約明寫讀寫分權是刻意的）。

  - **#134（M22-3）有正當權限的客戶管理員打開「儲存設定」頁時，瀏覽器就收到那把共用密鑰的明文——任何看得到他瀏覽器流量、存檔或前端錯誤紀錄的人都拿得到**

    - 修法：**三件一起做**——① 把儲存設定（`STORAGE_CONFIG`）加進遮蓋名單；② 把帳密欄位名（`access_key` / `secret_key` 之類）加進要遮蓋的清單；③ 改用寄信與員工帳號目錄那套「**不回密碼、沒帶就沿用**」的做法。
      **先查再寫**：這套「沒帶就沿用」的 pattern 主專案已經有兩份現成實作，照抄別新造——`guarded_system_config_service.py:300-320` 的 AI 供應商金鑰處理（含 `clear` 旗標、`is_set` 回報、空值＝沿用既有的完整邏輯）。
    - 🔴 入口清單：
      - **遮蓋名單（套件側，`STORAGE_CONFIG` 就是漏在這裡）**：
        `jedi-system-core/jedi_system_core/plugin/contract.py:61`（`DEFAULT_SECRET_MASKED_GROUPS = ("SMTP", "THIRD_PARTY_LOGIN", "NOTIFY_CONFIG", "ISSUE_INTEGRATE_CONFIG")` — **少 `STORAGE_CONFIG`**）
        `jedi-system-core/jedi_system_core/plugin/contract.py:65`（`DEFAULT_SECRET_VALUE_KEYS = ("secret", "private_token")` — **少儲存設定的帳密欄位名**）
        `jedi-system-core/jedi_system_core/plugin/contract.py:113-114`（兩者進 config dataclass）
      - **遮蓋機制本體與四個呼叫點**：
        `jedi-system-core/jedi_system_core/api/routes/system_config_route.py:20`（import `mask_secret_value`）／`:42-44`（清單）／`:57`（單筆）／`:73`（PUT 回應）／`:104`（POST 回應）
        `jedi-system-core/jedi_system_core/api/__init__.py:9`（`mask_secret_value` 出口）
      - **「沒帶就沿用」的現成 pattern（照抄用）**：
        `app/system_config/service/guarded_system_config_service.py:113`（`_AI_PROVIDER_SECRET_KEYS = ("api_key",)`）／`:116`（`_AI_PROVIDER_CLEAR_FLAG = "clear"`）／`:300-320`（merge 邏輯：空值＝沿用既有密文、`clear` 旗標才真的清除、`is_set` 不落地）／`:119`（`_DRIVE_APP_GROUP`，同型但密鑰在 value 頂層的第二個範例）
        `jedi-system-core/jedi_system_core/app/service/system_config_service.py:94`／`:103`（套件側既有的 `data["value"]["secret"] = existing_config.value["secret"]` 沿用寫法）
      - **儲存設定的既有隱藏處理（別弄壞）**：`app/system_config/service/guarded_system_config_service.py:106`（`_HIDDEN_GROUP = "STORAGE_CONFIG"`）、`:8`（註解：`STORAGE_CONFIG/FACTORY_DEFAULT` 出廠快照對外不存在，CM-1333）
      - **儲存設定的 group／key 常數**：`app/system_config/service/tenant_storage_config_seeder.py:42-43`（`STORAGE_CONFIG` / `CONFIG`）、`:53`
    - 功能不能壞：**遮蓋之後「存檔」不能把密鑰洗掉**——這是這類修法最常見的回歸（前端拿到遮蓋值，原樣送回來就把真密鑰覆蓋成 `***`）。照抄的 `clear` 旗標／空值沿用邏輯正是為此存在。
      手測：① 開「儲存設定」頁 → 密鑰欄位**不再有明文**（看瀏覽器 network 回應，不是只看畫面）；② 只改「儲存路徑」不動密鑰後存檔 → **檔案上傳下載仍然正常**（密鑰沒被洗掉）；③ 明確填新密鑰後存檔 → 新密鑰生效；④ 走 `clear` 指令清除密鑰 → 真的清掉；⑤ 新建租戶時從 ROOT 繼承儲存設定（`tenant_storage_config_seeder`）仍然正常。

- **這張卡的手測總清單**：
  1. 總部管理員改 LDAP／SMTP／登入政策／日誌轉送 → 四處都存得下去。
  2. 子公司管理員改同四處 → **全部 403 且訊息可讀**（核心驗收）。
  3. 平台管理員改同四處 → 存得下去。
  4. 四處的讀取（GET）行為不變，畫面照樣打得開。
  5. 日誌轉送按「測試」仍送得出去。
  6. 儲存設定頁 network 回應中密鑰已遮蓋；只改路徑存檔後檔案上傳下載仍正常。
  7. 新建租戶繼承 ROOT 儲存設定仍正常。

- **需決策者先裁的**：
  - **D-b5A-6：「客戶組織樹第一層」的判斷，要放在共用守門還是各寫入路徑各自判？**／我的建議：**在 `common/authz` 加一支共用判斷（軸⑦或併入既有 capability 軸），四個寫入端（三支 system_config ＋ 一支日誌轉送）都呼叫它**——CLAUDE.md 授權守門鐵則明禁另立第五套 helper，而這四處要的判斷一模一樣，各寫一份必然漂移。BE 側的兩個接點（`core/plugins/system_core.py:93` 與 `core/plugins/api_log.py:289`）都已經在呼叫 `common.authz`，接進去成本很低。
  - **D-b5A-7：修完之後，既有子公司自存的殘留設定列怎麼處理？**／我的建議：**本卡不動資料，只擋未來的寫入**；殘留列（`shared_config_app_service.py` 檔頭提到 STG 上 tenant 102 有 LDAP 殘留列）另開一張資料清理卡，並照環境異動鐵律只先動 DEV。**不要讓 runner 在這一棒順手清資料。**

---

## 卡 5A-4：交出去的檔案與日誌統一編碼：匯出的試算表不再被當公式執行、日誌不再被讀成兩筆

- **範圍**：套件 `jedi-common`（放共用函式的地方）＋ `jedi-log`（操作日誌匯出、日誌行組裝）＋ `jedi-issue`（意見回饋匯出）。worktree 建議 `wt-fix-b5A-output-encoding`。涵蓋 #20（M09-2＋M21-1）、#93（M09-4）。
  **併一張卡的理由**：模組頁明寫這三條「同一個根——進來時照實記，交出去時沒把關」，且 M21 那條的修法欄直接寫「與另一處同樣的問題建議抽一支共用函式一起修」。**裁定方向已定：抽一支共用函式、兩處（匯出）一起修；編碼不刪（原則七）。**

- **每件的修法**：

  - **#20（M09-2＋M21-1）任何人在帳號等欄位填進特殊開頭字元，管理員把操作日誌或意見回饋匯出成 Excel 打開時，那段內容會被當成公式執行，可能把整張表送到外部網址**

    - 修法（**已裁定**）：**抽一支共用函式，兩個匯出出口一起套**。在匯出的那一個出口統一處理，把會被 Excel 當成公式的開頭字元（`=` `+` `-` `@` 以及 tab／CR 開頭）**標示成純文字**——🔴 **字元一個都不刪，只是告訴 Excel「這是文字、不要執行」**（原則七：交出去的資料要編碼，不是刪掉，資訊一個位元都不能少）。
      - 操作日誌側：匯出時對使用者可控欄位套用。
      - 意見回饋側：對**四個**使用者可控欄位套用——標題、描述、標籤名、建立者暱稱。
      - **共用函式放哪**：`jedi-common`（兩個套件都依賴它，是唯一不會造成套件互相依賴的位置）。⚠️ **先查再寫**：開工前 `grep -rn "formula\|sanitize\|csv_injection\|^[=+@-]" jedi-common/` 確認沒有現成件；有就 extend，沒有才新增。
    - 🔴 入口清單：
      - **操作日誌匯出（M09-2）**：
        `jedi-log/jedi_api_log/api_log/app/service/api_log_service.py:77`（`def export_api_log_file`）
        `jedi-log/jedi_api_log/api_log/app/service/api_log_service.py:88-89`（`pd.ExcelWriter(..., engine='openpyxl')` → `df_clean.to_excel(...)`——**寫進檔案的那一行，編碼要在這之前**）
        `jedi-log/jedi_api_log/api_log/app/service/api_log_service.py:81`（檔名）／`:87`（`BytesIO`）／`:96`（`zip_file.writestr`）
        `jedi-log/jedi_api_log/api_log/common/utils/excel_util.py`（`ExcelUtil`，`api_log_service.py:13` import 它——**共用函式的掛載候選點**）
        `jedi-log/jedi_api_log/api_log/app/dto/api_log_export.py`（`ApiLogExportDTO`，欄位清單在這裡，判哪些是使用者可控）
        路由：`jedi-log/jedi_api_log/api_log/api/routing.py:18`（`/log/api-logs/export`）
      - **意見回饋匯出（M21-1）**：
        `jedi-issue/jedi_issue/app/feedback/service/feedback_service.py:337`（`def export_feedbacks`）
        `jedi-issue/jedi_issue/app/feedback/service/feedback_service.py:359`（`export_rows.append({...})`——**四個使用者可控欄位在這裡組進 row**）
        `jedi-issue/jedi_issue/app/feedback/service/feedback_service.py:370-380`（csv 分支：`csv.DictWriter` → `writerow`）
        `jedi-issue/jedi_issue/app/feedback/service/feedback_service.py:381-391`（excel 分支：`openpyxl.Workbook()` → 寫 row）
        ⚠️ **兩個分支都要套**（csv 與 excel 各一條路，只修一邊就是典型的「兩入口只改一邊」——CLAUDE.md 記憶 `feedback_parallel_runners_two_entrypoints_one_fixed` 點名過）
        路由：`jedi-issue/jedi_issue/plugin/__init__.py:10`（`/feedback/export/<type>`，守 `feedback.export`）
      - **為什麼「不必登入就能種」**：系統在任何權限檢查之前就把每一次請求原封記下來（操作日誌側）；意見回饋側門檻只有登入。runner 不必修這個「照實記」的行為——**修的是出口**。
    - 功能不能壞：編碼之後匯出的檔案**內容必須仍然可讀**（原則七的驗收點）。手測：① 在登入頁帳號欄填 `=1+1`（不登入成功也會被記）→ 管理員匯出操作日誌 → 用 Excel 打開，該格顯示為文字 `=1+1` 而**不是 `2`**、也不是被刪掉；② 送一則標題以 `=` 開頭的意見回饋 → 匯出 csv 與 excel **兩種**都打開確認同上；③ 正常內容（中文、數字、日期、含逗號與引號的文字）匯出後格式不變、不多出奇怪符號；④ 匯出檔案能被 Excel 與 LibreOffice 兩者打開（兩者對前導字元處理不同）。

  - **#93（M09-4）任何人（連登入都不用）在帳號之類欄位填進換行，系統把日誌送到客戶的資安監控系統時原樣帶出，對方會讀成兩筆紀錄，第二筆內容由攻擊者決定（例如偽造「某某管理員授予了超級權限」）**

    - 修法（**已裁定**）：組出那一行文字之前，把**換行編碼成看得見的兩個字元**（`\n` → 字面的 `\` `n`），其他看不見的控制字元同理——🔴 **內容一個位元都不少，只是讓它不再被讀成「下一筆」**（原則七）。
    - 🔴 入口清單：
      - `jedi-log/jedi_api_log/forwarding/common/forwarder.py:232`（`return f"1 {ts} {hostname} {app_name} {pid} {msgid} - {body}"`——**RFC 5424 那一行的組裝點，`body` 未經編碼就是這條的成因**）
      - `jedi-log/jedi_api_log/forwarding/common/forwarder.py:222-234`（`_Rfc5424Formatter.format`，整支 formatter）
      - `jedi-log/jedi_api_log/forwarding/common/forwarder.py:198`（`build_rfc5424_formatter`）
      - `jedi-log/jedi_api_log/forwarding/common/forwarder.py:163-196`（`build_target_handler`——syslog 與 gelf 兩條路都從這裡出去）
      - **GELF 那條路要不要一起處理**：`jedi-log/jedi_api_log/forwarding/common/gelf_handler.py:88`（`_build_payload`，GELF 是 JSON，換行本身會被 JSON 轉義、**理論上不受這條影響**）／`:136`（`_send_udp`）／`:142`（`_send_tcp`，以 NUL 分隔訊息）。⚠️ **runner 要開檔確認 GELF 側是否真的免疫，別憑推論跳過**；若免疫，在 commit message 註明理由。
      - 現成工具候選（先查再寫）：`jedi-log/jedi_api_log/error_tracking/masking.py`（同套件內既有的文字處理件，看能否共用掛載點）
    - 功能不能壞：編碼過度會讓 log server 上的訊息變得難讀，或讓 multi-line 的 traceback 全部擠成一行。手測：① 登入頁帳號欄填 `admin\n<偽造的一行日誌>`（真的按 Enter 那種換行）→ 登入失敗 → 在 log server 端確認**只收到一筆**、且那段換行以 `\n` 兩個字元原樣可見；② 正常的多行 traceback 送出後，log server 端仍能看出完整堆疊（不因編碼而遺失資訊）；③ syslog 與 GELF 兩種格式各測一次；④ UDP 與 TCP 兩種傳送方式各測一次（`forwarder.py:180` 的 socktype 分支）。
      ⚠️ `forwarder.py:189` 有一段既有陷阱註解（關掉 stdlib 的 NUL 結尾，否則 Graylog 整筆丟掉）——**改這支時不要把那行 `handler.append_nul = False` 弄掉**。

- **這張卡的手測總清單**：
  1. 帳號欄填 `=1+1` → 操作日誌匯出的 Excel 中該格為文字、不執行、不被刪。
  2. 意見回饋標題以 `=` 開頭 → csv 與 excel **兩種匯出**都不執行、不被刪。
  3. 正常內容（中文／數字／日期／逗號／引號）匯出後格式不變。
  4. 匯出檔用 Excel 與 LibreOffice 兩者都打得開。
  5. 帳號欄填真換行 → log server 端只收到一筆，`\n` 兩字元可見。
  6. 多行 traceback 送出後堆疊資訊完整。
  7. syslog／GELF × UDP／TCP 四種組合都驗過。

- **需決策者先裁的**：
  - **D-b5A-8：共用編碼函式放 `jedi-common` 還是各套件各放一份？**／我的建議：放 **`jedi-common`**（兩個匯出出口分屬 `jedi-log` 與 `jedi-issue` 兩個套件，各放一份就是兩份會漂移的規則，而這正是 M13 第 10 條那種「修一份、漏一份」的病）。代價是動 `jedi-common` 會牽動全部 consumer，所以**這一棒走 poetry path dependency 開發、不發版**，發版時機等決策者明示。

---

## 卡 5A-5：讓停權當下通行證立刻失效，順便把日誌轉送的加密選項給客戶

- **範圍**：套件 `jedi-iam`（認人與自動續期）＋ `jedi-log`（加密選項）＋ FE（下拉選項多一個值）。worktree 建議 `wt-fix-b5A-iam-tls`。涵蓋 **M13 第 17 條**（補進、不在 145 件內）、#92。
  **併一張卡的理由**：兩條都小、都在「信任鏈」這批的語意內、改的檔完全不重疊，一個 session 做得完。

- **每件的修法**：

  - **M13 第 17 條 管理員在「帳號管理」把某位員工停權之後，那個人如果還開著瀏覽器、或存了登入憑證，照樣能繼續操作到憑證過期；而且憑證快到期時系統還會自動幫他續期——等於停權沒有立刻生效，管理員以為人已經擋掉了，實際上沒有**（🔴 這條在 `docs/security-report/M13-iam.md` 第 98 行，**不在 145 件的 SUMMARY 裡**，共同說明指定補進第 5 批；三票一致通過、評中風險，被查出來時追蹤表已停更、沒人替它開卡）

    - 修法：現在認人的時候**只看「這張通行證有沒有被撤銷」和「這個帳號還在不在」，沒有看「這個帳號是不是已經被停權」**。補上這一眼，停權就拒絕；**自動續期那一支同樣要擋**。效果是停權當下，他手上的通行證立刻失效。
    - 🔴 入口清單：
      - **認人主路徑（缺的那一眼就在這裡）**：
        `jedi-iam/jedi_iam/middleware/core.py:74-82`（`is_token_revoked` → `raise IdentityResolutionError("revoked_token")`——**只查了撤銷**）
        `jedi-iam/jedi_iam/middleware/core.py:84-87`（`user = _lookup_user(user_service, uid)`；`if user is None: raise IdentityResolutionError("user_not_found")`——**只查了「帳號還在不在」，沒查停權狀態。新判斷加在這裡**）
        `jedi-iam/jedi_iam/middleware/core.py:103`（`_lookup_user`，內含 `elevated_readonly_scope` 提權——⚠️ docstring 明寫「這一句必須提權」，因為 context 還沒組出來、`users` 有 RLS，**不提權會全站 401**。新判斷要在這個提權查詢**拿回來的 user 物件上**判，不要另開一次查詢）
        `jedi-iam/jedi_iam/middleware/core.py:35`（`reason` 清單的說明，新增 reason 要一起補）
      - **HTTP 路徑的另一半（flask-jwt-extended 那條）**：
        `jedi-iam/jedi_iam/middleware/jwt_mw.py:162-163`（`check_if_token_revoked` → `is_token_revoked`）
        `jedi-iam/jedi_iam/middleware/jwt_mw.py:157-158`（`revoked_token_callback`）
        `jedi-iam/jedi_iam/middleware/jwt_mw.py:67`（錯誤碼清單）
        ⚠️ `middleware/core.py:60-63` 的 docstring 說明：**HTTP 路徑傳 `None` 給 `login_token_service`**（框架自己有 blocklist loader），**socket 路徑必須傳**——所以「停權」這一眼要確認**兩條路徑都補到**，不能只補 core.py 那一條。**這是典型的兩入口問題**。
      - **自動續期那一支**：
        `jedi-iam/jedi_iam/api/routes/login_route.py:84`（`@refresh_auth_required`）／`:87`（`c.service("login_token_service").refresh_user_token()`）
        `jedi-iam/jedi_iam/api/routing.py:89`（`/refresh`）
        `jedi-iam/jedi_iam/api/guards.py:60-61`（`refresh_auth_required`）
        `jedi-iam/jedi_iam/app/service/login_service.py:133`（`create_refresh_token`）／`:137`（`add_token_to_db`）
      - **停權狀態欄位在哪**：⚠️ **開工前 grep 確認**（`grep -rn "status\|is_active\|enabled" jedi-iam/jedi_iam/domain/entities/user*.py jedi-iam/jedi_iam/infra/models/user.py`）——M13 第 13 條（角色停用）已修，修法是「權限查詢時把有效期、狀態、客戶歸屬一起算進去」，**那支的寫法是本條的現成範本，先去看它怎麼判**。
    - 功能不能壞：**這條改壞的症狀是全站 401**（`_lookup_user` 的 docstring 已警告過同一個位置的類似風險）。手測：① 正常帳號登入、操作、續期 → 全部正常；② 管理員把 B 帳號停權 → B 的瀏覽器**下一個請求就 401**（不用等憑證過期，這是核心驗收）；③ B 拿 refresh token 續期 → **被拒**；④ 把 B 復權 → B 重新登入後正常；⑤ socket 連線那條路（即時通訊）也要驗到同樣行為（兩入口）；⑥ 平台管理員與 super_admin 帳號不受影響。
      ⚠️ 原則十一：錯誤訊息要讓被停權的人知道「你的帳號已被停權」，不是一句無說明的 401——不然客服會收到「我突然被登出了」的電話。

  - **#92（M09-3）管理員在日誌轉送設定頁選「傳送方式」時，下拉選單裡只有兩個選項、兩個都是明文，想開加密的客戶根本開不了**

    - 修法：**提供加密選項，讓需要的客戶開得起來**。對方收不收得了加密，屬於客戶自己的部署決定。
      ⚠️ 這條的缺口不是「沒有加密」（明文轉送是這類功能的業界常態，靠部署在受信任網段保護），是「**沒有提供加密選項**」——所以修法是**加選項，不是強制加密**。既有的兩個明文選項**要留著**（原則三：功能不能壞；已在用明文的客戶不可被斷掉）。
    - 🔴 入口清單：
      - **選項的真相來源（只有兩個值就在這裡）**：`jedi-log/jedi_api_log/forwarding/domain/service/log_forwarding_setting_domain_service.py:20`（`VALID_TRANSPORTS = ("udp", "tcp")`）
      - **DB 欄位**：`jedi-log/jedi_api_log/forwarding/infra/model/log_forwarding_setting.py:37`（`transport = Column(String(10), ..., default="udp", comment="udp / tcp"`——**欄位寬度 10 夠不夠放新值要確認**；若要加 migration，紀律段要寫「出貨基線待重產」）
      - **送出端兩條路各自的 transport 分支**：
        syslog：`jedi-log/jedi_api_log/forwarding/common/forwarder.py:177-182`（`socktype = SOCK_STREAM if transport == "tcp" else SOCK_DGRAM`——**加密要在這裡包 TLS**）
        GELF：`jedi-log/jedi_api_log/forwarding/common/gelf_handler.py:76`（`__init__(..., transport="udp", ...)`）／`:80`（`self._transport = (transport or "udp").lower()`）／`:142`（`_send_tcp`）／`:158-159`（分派）
        ⚠️ **兩條路都要支援加密，只做一邊就是兩入口只改一邊**
      - **測試發送端點（新選項也要測得起來）**：`jedi-log/jedi_api_log/forwarding/app/service/log_forwarding_app_service.py:158`（`delivery_confirmed = settings.transport == "tcp" and sent`——**這行寫死了 `== "tcp"`，加了新值會讓「已確認送達」判斷失準**）／`:183-185`（`if settings.transport == "udp"` 的提示訊息，同樣寫死）／`:105`／`:145`（相關註解）
      - **FE 下拉選單**：✅ **已補查（2026-09-21），四個落點都是現成的、全部用 PrimeVue `Dropdown`，runner 照樣式改即可、不要手刻。**
        - **元件與選項定義**：`compliance-manager-fe/src/views/log-forwarding/LogForwardingForm.vue`
          `:69-72` — `transportOptions` computed（**目前就這兩列**：`{label: t('lang.log_forwarding.transport_udp'), value: 'udp'}` / `..._tcp, 'tcp'`）
          `:271-276` — template 的 `<Dropdown id="transport" v-model="config.transport" :options="transportOptions" optionLabel="label" optionValue="value" :disabled="!canUpdate" />`（**PrimeVue Dropdown，照抄上面 `:258-263` 的 `protocol` 下拉同一形狀**）
          `:52` — 表單預設值 `transport: 'udp'`
          `:111` — 讀回設定時的 fallback `transport: res.transport || 'udp'`
          `:162` — 送出 payload 的 `transport: config.value.transport`
        - **i18n 兩份**：`src/config/locales/i18n/zh-tw/log-forwarding.json:11-14`（`transport` / `transport_udp` / `transport_tcp` / `transport_hint`）與 `src/config/locales/i18n/en/log-forwarding.json:11-14`（同四個 key）。**新選項要同時補這兩份**，並且 `transport_hint` 那句現有文案只描述 UDP 與 TCP 的差別（「UDP 送出後不確認對方是否收到；TCP 會建立連線…」），**加了加密選項後這句要跟著改**，否則畫面說明與選項不符。
        - ⚠️ **若 D-b5A-9 裁成「獨立 `use_tls` 開關」（本卡建議案），FE 要動的就不是 `transportOptions` 而是新增一個 `InputSwitch`／`Checkbox`**——同檔 `:265` 起的 field 區塊有現成的 grid 樣式可照抄。**這一點等裁決再定，runner 不要先猜一種做。**
        - 另有兩處同名但**無關**的 `transport`，不要誤改：`src/utils/detectionAssignmentParams.js:20`（`TRANSPORT_PARAM_KEY`，弱點掃描派工參數）與 `src/components/grc/JobExecutionDrawer.vue:1350-1454`（掃描派工摘要顯示）。
      - **序列化層**：`jedi-log/jedi_api_log/forwarding/api/serializers/log_forwarding.py`（request／response schema 的 transport 欄位驗證）
    - 功能不能壞：手測 ① 既有客戶用 `udp` → 不受影響、照樣送得出去；② 既有客戶用 `tcp` → 同上；③ 選新的加密選項 → 存得下去、按「測試」送得出去（需要一台收得了加密的 log server；DEV 可用 openssl s_server 或 rsyslog 起一個）；④ 對方憑證驗不過時**錯誤訊息要說得清楚是憑證問題**，不是一句「送不出去」；⑤ syslog 與 GELF 兩種格式都能用加密；⑥ `delivery_confirmed` 與提示訊息在新選項下的行為正確（不是靜默回 false）。

- **這張卡的手測總清單**：
  1. 正常帳號登入／操作／續期全部正常。
  2. 停權 B 帳號 → B 下一個請求 401（核心驗收），refresh 也被拒。
  3. socket（即時通訊）路徑同樣擋得到（兩入口）。
  4. 復權後 B 重新登入正常。
  5. 平台管理員／super_admin 不受影響。
  6. 日誌轉送 `udp`／`tcp` 兩個既有選項不受影響。
  7. 新加密選項存得下、測試送得出、憑證錯誤訊息可讀。
  8. syslog／GELF 兩種格式都支援新選項。

- **需決策者先裁的**：
  - **D-b5A-9：加密選項要做成第三個 transport 值（`tls`），還是獨立的「要不要加密」開關？**／我的建議：做成**獨立開關**（`transport` 維持 `udp`/`tcp`，另加一個 `use_tls` 布林）。理由：`transport` 這個值在送出端被寫死比對至少四處（`forwarder.py:180`、`app_service.py:158`、`:183`、`gelf_handler.py:80`），加第三個值等於要逐處改比對邏輯、漏一處就是靜默錯誤；而加密是「在 TCP 之上包一層」的正交概念，做成開關語意也更準（UDP 本來就不能包 TLS，開關可以直接在領域層拒絕 `udp + tls` 這個組合）。**代價是 DB 要加一個欄位（migration，主線 `phase=active`／`envs=*` → 出貨基線待重產）**，請決策者確認可接受。
  - **D-b5A-10：加密時要不要驗對方憑證，還是讓客戶選？**／我的建議：**預設驗、但提供「信任指定憑證」的欄位**給自簽的客戶填（原則十一：不可擋住正常客戶；很多客戶的內部 log server 是自簽憑證，硬性要求公信憑證會讓功能等於不可用）。**不提供「完全不驗」這個選項**——那會重演 M16 第 3 條與 M13 第 10 條那種「有加密但不認人」。

---

## 決策者要裁的事（彙整本批 A 子集的 D）

| D 編號 | 問題（白話） | 我的建議 | 卡住哪張卡 |
|---|---|---|---|
| **D-b5A-1** | 代理程式每次打進來，要拿什麼證明「我就是那台」？一種是用產品本來就會發的短效通行證，另一種是靠機器憑證、但要進客戶機房改設定 | **用短效通行證**——不必碰客戶機房設備；機器憑證之後再補當第二道，但不能當唯一手段 | 5A-1（動手前必裁） |
| **D-b5A-2** | 「這張工單是不是你的」這道檢查，要放在最外層的門口，還是放進核心邏輯？ | **放進核心邏輯當必要條件**——放門口的話，日後多一條呼叫路徑（維運工具、批次補登）就又繞過去了，這正是這個洞的原始成因 | 5A-1（動手前必裁） |
| **D-b5A-3** | 驗簽的那支工具怎麼變成「拔不掉」？三個做法：①在產清單的工具裡開一個「安全關鍵」層 ②改打包腳本強制編進核心 ③只把它加進監控優先名單 | **①**——②會牽動整包產物與 37 個被排除的套件，回歸面太大；③只是順便驗到、不是保證。並同時把四小時抽查改成「必含安全關鍵檔＋其餘隨機」 | 5A-2（動手前必裁） |
| **D-b5A-4** | 被鎖住的機器不該靠刪一個檔案就能重開。治本兩條路：①開機時另外連一次資料庫查鎖定紀錄 ②讓補同步發現「資料庫有紀錄、檔案標記不見了」時把標記補回去並終止開機。**附帶**：開機當下資料庫連不上時，要擋下開機還是放行留警示？ | **走①，②當輔助一起做**。附帶那點建議**連不上就放行但留高等級警示並在管理頁顯示**（資料庫連不上本來就整體不可用，不必在這關多擋一次）——但這點請您確認 | 5A-2（動手前必裁） |
| **D-b5A-5** | 解鎖紀錄檔毀損時改成「拒絕核銷」之後，客戶的出路是什麼？（原本寬鬆是為了避免「一次檔案損毀就再也解不開、只能重灌」） | 拒絕但**訊息明確給出機器指紋＋事件編號＋聯繫原廠指引**，前提是簽發端（license_center）有「重新簽發解鎖檔」的流程。**若簽發端目前沒有這個流程，這條就要降級成「先記警示不拒絕」** | 5A-2（動手前必裁） |
| **D-b5A-6** | 「這個人是不是客戶總部的管理員」這個判斷，要在共用守門寫一支給四個地方用，還是四個地方各寫一份？ | **共用一支**（放 `common/authz`）——四處要的判斷一模一樣，各寫一份必然漂移；而且專案規範明禁另立新的守門 helper | 5A-3（可邊做邊裁） |
| **D-b5A-7** | 修完擋住子公司之後，資料庫裡已經存在的「子公司自己存的那些設定列」怎麼處理？（STG 上已知有一筆 LDAP 殘留） | **這一棒不動資料，只擋未來的寫入**；殘留列另開一張清理卡，並照環境鐵律只先動 DEV | 5A-3（可邊做邊裁） |
| **D-b5A-8** | 防「公式炸彈」的那支共用函式放哪？放共用基礎套件（動它會牽動全部使用者），還是兩個套件各放一份？ | **放共用基礎套件**——各放一份就是兩份會各自漂移的規則，正是「修一份、漏一份」那個病。這一棒走開發期的本機路徑依賴、**不發版**，發版時機等您明示 | 5A-4（可邊做邊裁） |
| **D-b5A-9** | 日誌轉送的加密選項，做成「傳送方式」下拉的第三個值，還是另加一個「要不要加密」的開關？ | **另加開關**——「傳送方式」這個值在送出端被寫死比對至少四處，加第三個值要逐處改、漏一處就是靜默錯誤；加密本質是「在 TCP 之上包一層」，做成開關語意也更準。**代價是資料庫要加一個欄位（要套 migration，出貨基線待重產）**，請您確認可接受 | 5A-5（動手前必裁） |
| **D-b5A-10** | 開了加密之後，要不要驗對方的憑證？還是讓客戶自己選要不要驗？ | **預設驗，但給一個「信任這張指定憑證」的欄位**讓自簽的客戶填。**不提供「完全不驗」這個選項**——那會重演「有加密但不認人」那兩條 | 5A-5（可邊做邊裁） |
