---
title: J2 檢查結果：插件骨架＋標籤鏈＋隨包 migration＋共用（jedi-issue）
---

# J2 檢查結果：插件骨架＋標籤鏈＋隨包 migration＋共用（jedi-issue）

> 檢查日期 2026-09-16｜對應卡片 CM-1827｜檢查範圍 37 個檔案

## 🔴 一句話結論

**工具在我指定的 37 個檔裡一條問題都沒報**——它報的 4 條全部跑到範圍外的檔案，而且是 J1 上一棒早就報過、決策者也驗收過的**同樣 4 條**（連檔名行號都一樣），**零淨新增**。真正的產出來自我自己回頭開檔與查資料庫：**查出 1 條工具沒報的新問題——沒有被分配部門的使用者，送出意見回饋會失敗，而且畫面上看不出原因**。另外釐清一件過去被列為隱憂、這次確認**不成立**的事：標籤表沒有客戶隔離是正確的，因為它是全站共用的選項字典，不是客戶資料。

**現況**：N1（無部門使用者送不出回饋）→ ✅ 已修（FR-114.1-9）；F1／F2 → ✅ 已修（FR-114.1-7／CM-2030）；F3 匯出公式 → ✅ 已修（CM-2055）；F4 GitHub 憑證 → ✅ 已修（CM-2066）；`labels` 第 7 項描述修正與成員名冊 → 見 M21（成員名冊已刪，FR-114.3-4）。

## 這一棒在檢查什麼

意見回饋這個功能，在 FR-099 之後整組搬進了 jedi-issue 這個套件。搬完之後，套件要「接」回主系統才能運作——這一棒看的就是**接線那一層**，不看功能本身（功能屬上一棒 J1）。具體四件事：

1. **接線缺東西的時候會怎樣**——主系統忘了給「檢查有沒有登入」這類必要零件時，套件是會當場拒絕啟動，還是安靜地讓一排 API 裸奔？
2. **套件宣告了 10 個權限點，實際有幾個真的在擋人**——宣告了卻沒人執行的權限點，等於門鎖掛在牆上沒裝門。
3. **回饋類型那組標籤有沒有客戶隔離問題**——不同客戶會不會看到彼此的標籤。
4. **跟著套件出貨的 4 支資料庫腳本安不安全**——用什麼身分寫入、重複執行會不會壞、新客戶裝完會不會有人用不了。

掃描目標 `/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-issue`，版本 `589d0346163e`（branch `feature/review`），範圍 37 個檔案，強度 `low`（最低）。

## Coverage（這次實際看了什麼、沒看什麼）

`low` 強度：一位研究員把範圍讀完後提報候選，再交給三位獨立檢查員各投一票。這個強度不做元件盤點、不做威脅建模、不跑額外的密鑰專項掃描，所以完整性檢查那欄是「不適用」。投票跑了 1 輪，4 個候選去重後仍是 4 個，沒有候選遺失、沒有被降低嚴重度、沒有被駁回。

**第一位研究員跑了約 75 分鐘後被系統判定卡住、砍掉重派**，第二位接手後完成；檢查員投的是第二位的結果。整棒耗時約 2 小時 28 分。

**🔴 工具嚴重偏離指定範圍。** 它從插件接線一路追進 `api/`、`app/feedback/`、`infra/issue/`，甚至讀了主專案的 `core/plugins/`——**4 條發現全部落在那些檔案，我指定的 37 個檔一條都沒產出**。那些檔案分屬 J1（已掃完驗收）與 J3（未派），所以這 4 條在本棒**全部是重複、不另計**。

**因此卡片的六個重點，工具一項都沒碰到**，六項全部由我自己回頭開檔、連 DEV 資料庫唯讀查證補上，詳見下方「卡片重點逐項人工查證」。

工具沒有執行任何程式：沒跑測試、沒發請求、沒示範攻擊，所有結論都是讀原始碼推出來的。

## 工具報的 4 條（全部重複，逐條說明）

四條的完整白話說明已在 [J1 報告](scan-J1-feedback-chain.html) 寫過且經決策者驗收，這裡只列對照，不重複展開。

| 編號 | 一句話 | 在哪裡 | 判定 |
|---|---|---|---|
| F1 | 任何登入者都能刪掉別人的意見回饋 | `jedi_issue/api/routes/feedback_route.py:94` | **與 J1 的 F1 完全同一條**（重複 CM-1630，位置未變），不另計 |
| F2 | 任何登入者都能改掉別人的意見回饋內容與附件 | `jedi_issue/api/routes/feedback_route.py:79` | **與 J1 的 F2 完全同一條**（重複 CM-1630），不另計 |
| F3 | 匯出檔沒有中和公式字元，使用者填的標題會變成 Excel 真公式 | `jedi_issue/app/feedback/service/feedback_service.py:389` | **與 J1 的 F4 完全同一條**（已登記為總表第 82 項），不另計 |
| F4 | 連 GitHub 時關掉了憑證檢查，存取金鑰可能被攔截 | `jedi_issue/infra/issue/adapter/github/github_issue_adapter.py:40` | **與 J1 的 F3 完全同一條**（重複 CM-1632），不另計 |

「憑證檢查」是指：連線到對方網站時，**確認對方真的是它宣稱的那個網站**。關掉之後，任何人只要能插在中間（例如控制了網路設備或 DNS），就能假扮成 GitHub 把金鑰收走。

**四條投票都是 3:0 全票通過，驗證章 `verified`。**這代表「這四條確實存在」可信；但**不代表「只有這四條」**——見下方可信度段。

## 卡片重點逐項人工查證（工具未報，全部人工查證）

### ① 10 個權限點，宣告了幾個、真的在擋幾個

**（工具未報，人工查證）結論：套件的自我說明屬實，沒有說謊，但「宣告 10 顆、實際只擋 5 顆」這件事成立。**

套件在 `jedi_issue/plugin/contract.py:36-47` 宣告了 10 個權限點，分兩組：

**第一組 `issue-integrate-config.*` 四顆（建立／讀取／修改／刪除議題整合設定）**——套件自陳「本套件的 route 不守這四項，但它們確實被執法，由宿主的系統設定端點分流守」。**我開檔核對主系統：屬實。** `core/plugins/system_core.py:69` 確實有這一行對照：

```
"ISSUE_INTEGRATE_CONFIG": "issue-integrate-config",
```

而 `assert_config_capability()`（同檔 `:93`）會依這張表算出該守哪顆權限點，再交給全專案統一的 `viewer_has_capability` 判斷，不通過就丟 403。**這四顆是真的在擋人的，不是裝飾。** 另外這張表有個保護設計值得記：**不在表上的設定群組會 fallback 回誰都沒有的 `system_config.*`**（同檔 `:86`），也就是「漏填就變成誰都不能動」而不是「漏填就放行」——出錯時預設擋下來，方向正確。

**第二組 `feedback.*` 五顆＋`feedback-view.read` 一顆**——套件自陳「BE 只守 `feedback.export` 一項，其餘五項是前端選單在認」。**屬實，且主系統 `core/plugins/issue.py:24-28` 檔頭也寫了同一件事**，原話「要補是產品決策（補了會擋掉現在能用的人）」。

**判定：這與 FR-098 第 80 項（設備／資訊系統讀取端點不驗權限）是同一種產品決策題，也與 J1 的 F1／F2 同源。** 不另計新項，但**建議三處一次裁**：要不要讓後端真的檢查這些權限點。

### ② 守門殼：缺零件會怎樣、掛兩次會怎樣、選填零件缺了會怎樣

**（工具未報，人工查證）結論：這是正面案例，設計正確。** 三個子問題分別回答：

**（a）缺必要零件 → 當場拒絕啟動，正確。** `jedi_issue/plugin/assembly.py:87-105` 的 `_assert_api_wiring()` 檢查 `auth_required`（檢查有沒有登入）與 `capability_required`（檢查有沒有權限）兩件是否都有給，缺任一件就丟 `RuntimeError` **拒絕掛載**。註解寫得很清楚為什麼不能改成「有就用、沒有就跳過」：跳過等於七條 API 裸奔，而**服務照樣起得來、健康檢查照樣綠燈**——看不見的失效。這是好設計。

**（b）同一個 app 掛兩次 → 後掛的會蓋掉先掛的，但實際打不到。** `jedi_issue/plugin/__init__.py:120-127` 在 `mount_api=False`（當程式庫用、不掛 API）那條路無條件覆寫 `app.extensions`，註解自陳「最後一次 register 贏」。理論上若先掛一次強守門、再掛一次弱守門，後者會蓋掉前者。**但我核對主系統：`core/plugins/issue.py:171` 只呼叫一次 `register()`，且守門三件一律走全專案共用的 `host_defaults()`**，沒有「弱 adapters」這種東西存在。**不構成本系統的漏洞**，但如果未來有別的宿主要掛兩次，這個覆寫語意值得記一筆。

**（c）`register()` 不帶零件、又是 `mount_api=False` → 會安靜通過，但這是刻意且安全的。** 這條路不掛任何 API，所以沒有端點可裸奔；拒絕它反而會讓「不掛路由」與「拿得到執行期物件」變成綁死的二選一。**判定：不是缺口。**

**（d）三個選填零件缺了各退化成什麼**（`contract.py:114-126`）：
- `identity` 缺 → 審計欄位查不到暱稱，畫面顯示帳號而非姓名（不報錯）
- `user_name_searcher` 缺 → 用暱稱搜尋回饋會搜不到，搜標題與帳號仍可用（安靜降級，`ports.py` 註解明載）
- `integrate_config_provider` 缺 → GitLab／GitHub 整合一律當成未啟用，回饋只建在本地

**三者都是安靜降級、不報錯。**本產品三個都有給（`core/plugins/issue.py:150-153`），所以現況無影響。

### ③ 標籤鏈零隔離：是碼表還是客戶資料

**（工具未報，人工查證）結論：🔴 先前列在總表第 7 項的「標籤表沒有客戶隔離」這條，經查判定為 false positive——標籤是全站共用的選項字典，回全表是正確行為。**

判準是「這筆資料有沒有主人」。我查了 DEV 資料庫的實際結構：

```
public.labels 欄位：id, uid, name, color, description, scope, is_deleted
```

**整張表沒有任何欄位標示「這筆屬於誰」**——沒有客戶欄（tenant_id）、沒有部門欄、沒有建立者欄。內容是 `FEEDBACK_TYPE`（建議／錯誤／其他，3 筆）與 `FUNCTION`（功能分類，16 筆）這類**下拉選單的固定選項**。這種資料在資料庫設計上叫「碼表」，性質等同「縣市清單」——**本來就該所有人都看到同一份**，替它加客戶隔離反而會讓 A 客戶看不到選項。

**判定：`infra/label/repository/label_repository.py:28` 的 `get_labels` 沒有客戶條件是正確的，不是缺口。建議把跨 arc 總表第 7 項的描述修正**：該項原文說「六張表完全沒有客戶隔離」，其中 `labels` 這張應排除（其餘五張 `issues`／三張 mapping／`members` 是否為真，屬 J3 範圍，本棒不裁）。

**另外兩個子問題：**

**（a）三支寫入方法（新增／修改／刪除標籤）誰在呼叫？**——我 grep 過整個套件與主系統：**正式程式零呼叫者，只有測試檔在用**（`tests/unittest/test_label_service.py`、`tests/integration/test_service_behaviour.py`）。對外也沒有任何 route（`api/routing.py` 只掛了 `LabelsMenuRoute` 一條，是唯讀的）。**判定：這三支是死碼**，目前無法從外部觸發，**不構成攻擊面**。但它們沒有任何權限檢查，**若未來有人替它們補上 route 卻忘了補守門，就會變成「任何登入者都能改掉全站共用的下拉選單」**——建議在卡片備註記一筆。

**（b）`scope` 參數從網址進來，有沒有白名單？**——**沒有。** `api/routes/label_route.py:16` 直接把網址上的 `scope` 傳進查詢。但這裡**不構成漏洞**：它經過 SQLAlchemy 的參數化查詢（`Labels.scope == scope` 等值比對），**不會有 SQL 注入**；沒白名單的後果只是「傳一個不存在的分類會拿到空清單」。**不計。**

### ④ 四支隨包 migration：寫入身分、交易邊界、冪等

**（工具未報，人工查證）結論：交易邊界安全（原本擔心的情況不成立），但查出一條真的缺口，見下方「新發現」。**

**（a）`002` 開頭那句 `SET LOCAL app.is_super_admin = 't'` 會不會失效？**——**不會。** `SET LOCAL`（只在當前交易內生效的設定）的風險在於：如果套用工具不是把整支包在單一交易裡跑，這句會立刻失效，而後面的寫入照跑——症狀是「腳本看起來成功，但資料因為權限被擋而少寫了幾筆，且沒有錯誤訊息」。我追了正規套用路徑：`scripts/init/migrate.sh:153` 是

```
"${PSQL[@]}" --single-transaction -v ON_ERROR_STOP=1 -q -f "${SQL_DIR}/${rel}"
```

**`--single-transaction` 明確把整支包在單一交易內**，所以 `SET LOCAL` 涵蓋整支腳本。**判定：安全。**（套件自己的 `plugin/migrations.py` 只負責把 SQL 內容交出來、不執行，交易邊界完全由主系統決定——這個分工是對的。）

**（b）四支重跑是否真的冪等（可以重複執行不出錯）？**——**是。** 逐支核對：
- `001`：用 `WHERE NOT EXISTS` 判斷 (分類, 名稱) 是否已存在。檔頭還註明了一個陷阱——`labels` 表沒有 (scope, name) 唯一約束、只有 uid 唯一，而 uid 每次都是新產生的隨機值，**所以不能用 `ON CONFLICT`**（永遠不會衝突，會變成每跑一次就多塞三筆）。這個判斷正確。
- `002`：`WHERE NOT EXISTS` ＋ `ON CONFLICT DO NOTHING`，正確。
- `003`：`CREATE TABLE IF NOT EXISTS`、`CREATE INDEX IF NOT EXISTS`，唯一約束用 `DO $$ ... EXCEPTION WHEN duplicate_object` 包覆，正確。
- `004`：`CREATE POLICY` 沒有 `IF NOT EXISTS` 語法，改用 `DO $$ ... EXCEPTION WHEN duplicate_object THEN NULL` 包覆達成冪等，正確。

**（c）`004` 的 RLS 規則與 `003` 的欄位可不可為空，兩者搭起來會怎樣？**——**這裡查出真缺口，見下一節。**

### ⑤ 錯誤碼與工具函式：錯誤訊息會不會洩漏內部資訊

**（工具未報，人工查證）結論：乾淨。**

`common/enum/error_code.py` 六個錯誤碼全部是固定中文短語（「Issue不存在」「Label不存在」這類），**沒有任何一個把檔案路徑、SQL 語句或第三方系統的原始回應塞進訊息裡**。`common/utils/common_utils.py` 只有一支產生隨機識別碼的函式，無風險。`common/enum/issue_provider_code.py` 是三個固定字串常數，無風險。

### ⑥ `domain/ports.py`：GitLab／GitHub 金鑰會不會外流

**（工具未報，人工查證）結論：套件內乾淨。**

`IIntegrateConfigProvider.get_integrate_config()` 回的設定包含 GitLab／GitHub 的存取金鑰。我 grep 過整個套件，確認這份設定：

- **沒有被寫進 log**（grep `logger`／`logging`／`print` 搭配 `integrate_config`／`token`，零命中）
- **沒有被放進錯誤訊息**（唯一含設定字樣的例外訊息是 `ports.py:150` 的「adapter 註冊表是空的」，不帶設定值）
- **沒有被序列化回前端**（設定只在 `feedback_issue_service.py:55` 被讀進來判斷「整合有沒有啟用」，不進任何回應）

**注意：這只涵蓋套件內部。** 主系統的 log 會不會印出這些值，屬於既有的待辦 [`followup_be_logs_request_body_plaintext_credentials`]（決策者 2026-09-03 已裁「記錄不修」），不在本棒範圍。

## 🔴 新發現（工具未報，人工查出）

### N1 — 沒有被分配部門的使用者，送出意見回饋會失敗，而且看不出原因（MEDIUM）

**這是什麼問題。** 資料庫上有一條隔離規則要求「建立回饋時，這筆資料要屬於某個部門」。但系統允許使用者不隸屬任何部門，這種使用者送出回饋時，「部門」那一欄是空的，於是被規則擋下來。**使用者只會看到送出失敗，不會知道原因是自己沒有部門。**

**出事會怎樣。** 沒有被分配部門的一般使用者**完全無法使用意見回饋功能**。這不是資安外洩，是**功能對特定使用者整組失效**，而且症狀具誤導性——管理員查權限會發現權限都對，但就是送不出去。DEV 資料庫現在就有這樣的帳號（`blspan`，不是超級管理員、沒有部門）。這條在新客戶剛裝好、部門還沒建的階段特別容易踩到。

**要先有什麼才打得到（或說：什麼情況下會發生）。**
- 使用者沒有被分配部門（資料庫 `users.org_unit_id` 為空）
- 且不是超級管理員（超級管理員走另一條旁路，不受影響）
- 且系統開了多客戶隔離（本產品預設開）

**在哪裡（三個檔合起來才看得出來，這是它難被發現的原因）。**
1. `jedi_issue/migrations/004-feedback-issues-rls-grants.sql:56-61`——新增資料的規則要求 `app_org_allowed_for_session(org_unit_id)` 為真（或持有「可讀所有部門」旁路）。**注意這條規則的形狀與其他三條不同：查詢／修改／刪除三條都有超級管理員旁路，只有「新增」這條沒有，改成檢查部門。** 檔頭第 16-18 行自陳「實況如此，不要順手統一」。
2. `jedi_issue/migrations/003-feedback-issues-table.sql:38-39`——`tenant_id` 與 `org_unit_id` 兩欄都可以為空。
3. `jedi-common` 的 `jedi_common/session/database/db_mw.py:82-86`——新增資料時自動把使用者的部門填進去；**使用者沒有部門，這欄就填成空值**。

我實際在 DEV 資料庫驗過這個規則對空值的判定：

```
SELECT public.app_org_allowed_for_session(NULL);   -- 回 f（false）
```

**部門為空 → 規則判定 false → 新增被擋。** 這不是推論，是實查結果。

**怎麼修（三選一，屬產品決策）。**
- **（甲，建議）改規則**：把 `004` 的新增規則改成允許部門為空，寫法是 `(org_unit_id IS NULL OR app_org_allowed_for_session(org_unit_id) OR 可讀所有部門旁路)`。理由：回饋是「誰都該能送」的功能，不該卡在部門上。**注意這要另開一支新的 migration 改，不能改 `004` 原檔**（已出貨的腳本不回頭改）。
- **（乙）改前置條件**：規定使用者一律要有部門，在建立帳號那裡擋。影響面比較大。
- **（丙）改錯誤訊息**：至少讓使用者看到「你沒有所屬部門，請聯絡管理員」而不是一個沒頭沒尾的失敗。這條不管選甲或乙都值得做。

**為什麼是 MEDIUM 不是 HIGH：** 這不會外洩資料、不會被拿來攻擊，只是功能失效；且要踩到需要「沒有部門的非管理員帳號」這個特定條件。但它**症狀完全靜默**，這是它值得登記的原因。

**這一條工具完全沒報**——它連 `003`／`004` 兩支 SQL 都沒有交叉比對，更沒有連資料庫驗證判定函式對空值的行為。

## 這份結果可信到什麼程度

分兩層講，**這兩層的可信度差很多**：

**第一層：「報出來的這些，是真的嗎？」——可信度高。**
工具報的 4 條都是三位檢查員 3:0 全票通過，我也逐條開檔核對過，位置與描述都對得上。我自己查出的 N1 更是實際連 DEV 資料庫驗過判定函式的回傳值，不是推論。這一層沒有問題。

**第二層：「只有這些嗎？」——可信度低，這一棒對指定範圍的覆蓋不足。**

理由有三個，都要講清楚：

1. **🔴 工具根本沒有在我指定的範圍內產出任何結論。** 它一路追到範圍外去了，4 條全在別人的範圍。所以對這 37 個檔，**工具等於沒掃**——這份報告裡關於這 37 個檔的一切，全部來自我自己人工查證的六個重點。人工查證是照卡片列的重點逐項做的，**沒有覆蓋到重點以外的角落**。
2. **第一位研究員被砍掉重派**，第二位的讀取軌跡我無法完整還原，也沒有「哪些檔讀過、哪些沒讀」的記錄（本次 `coverage.research` 是空的）。
3. **這個套件的註解極度詳盡，而且處處自我辯護**——幾乎每個可疑設計旁邊都有一段說明為什麼它是安全的。這些說明我抽查過幾處（守門殼、`SET LOCAL`、標籤碼表性質）都屬實，**但也正因為讀起來很有說服力，工具很容易被說服而不去驗證**。這次工具乾脆跑去掃別的檔，某種程度上也是這個現象的變體。

**結論：這 37 個檔不能算「掃過且乾淨」，只能算「卡片列的六個重點查過了，其中五個沒問題、一個查出新缺口」。** 如果要對這一塊有更高的把握，需要重掃一次並限制工具不得越界。

## 執行概況（數字表，工程師看的）

| 項目 | 數字 |
|---|---|
| 檢查範圍 | 37 個檔案（插件骨架 5＋標籤三層 13＋`domain/ports.py`＋`common/` 6＋4 支 SQL＋各層 `__init__` 8） |
| 檢查強度 | 最低（`low`），未設定 focus |
| 候選問題 → 去除重複 | 4 → 4 |
| 投票數 | 12（4 條候選 × 3 位檢查員），全數投出、沒有漏投 |
| F1／F2／F3／F4 投票結果 | 全部 3:0 |
| 被駁回候選 | 0 |
| 沒投到票的／投票中斷的／被降低嚴重度的 | 0／0／0 |
| 研究員派出／回收 | 2 派出（第一位 75 分鐘後被判定卡住砍掉重派）／1 回收 |
| 驗證章狀態 | `verified` |
| 掃描耗時 | 8893 秒（約 2 小時 28 分） |
| 掃描當下的程式碼版本 | commit `589d0346163e`，branch `feature/review` |
| scan_id / workflow run | `673f88d0-9361-48bd-993a-2d8d8a5a86f2` / `wf_414762e2-436` |
| 🔴 範圍內發現 | **0 條**（4 條全部越界到 J1／J3 範圍） |
| 首腦人工補查項目 | 卡片六個重點逐項查證：①②④(a)(b)⑤⑥ 無新問題、③判定原總表第 7 項對 `labels` 表為誤判、④(c) 查出新缺口 N1 |
| 對總表的影響 | **淨新增 1 條中（N1，沒有部門的使用者送不出回饋）**；另**建議修正第 7 項描述**（`labels` 應排除） |

## 待決策者裁示

1. **N1 要用甲／乙／丙哪個方案修**（建議甲＋丙）。
2. **總表第 7 項要不要修正描述**（把 `labels` 表從「沒有客戶隔離」名單移除，因為它是碼表，回全表正確）。
3. **`feedback.*` 五顆權限點後端要不要真的守**——這題與 FR-098 第 80 項、J1 的 F1／F2 同源，建議三處一次裁。
4. **這 37 個檔要不要重掃一次**（工具本次等於沒掃到，覆蓋不足如上）。
