# FR-095.1 P1：專案成員新增／移除／角色指派＋守門入口＋掛載契約——資安掃描報告

- **卡片**：CM-1780（FR-095 第 1 棒）
- **範圍**：jedi 套件 repo `jedi-task-platform/jedi_task_platform/participant/` 底下 39 支檔（人員名冊 API 的網址入口到守門，加上守門單一入口與掛載契約）
- **掃描時間**：2026-09-14（UTC 03:35 起，跑了約 2 小時 50 分）
- **掃描版本**：`df85e350a191513341836dd1727b664ae0e5876b`（branch `feature/FR-075`，工作區乾淨）
- **工具**：Claude Code `claude-security` plugin v0.11.0，effort `low`、focus `attack-surface`
- **驗證章**：`unverified` — **但不是面板失敗**。三個檢查員對 4 條發現各投一票、12 票全數投出、4 條全 3:0 通過；`unverified` 是渲染器在最後一步退掉 F1／F4 兩條造成的（研究員寫的檔案路徑多帶一層目錄前綴，對不上掃描根目錄）。詳見第 7 節
- **只掃不修**：本棒沒有改任何程式碼，也沒有動任何環境（只對 DEV 資料庫做了唯讀查詢）

---

## 1. 🔴 一句話結論

**掃完了，範圍內找到 2 個真問題（工具報成 4 條，是同 2 個問題在「網址入口」與「業務邏輯」各報一次），最嚴重的是「任何登入者送一個空的請求，就能把全公司所有客戶的專案人員名冊一次撈光」——連「哪個專案」都不必知道。** 而這五張表的資料庫層隔離（就是「每個客戶只能看自己資料」的機制）是關的，沒有第二道防線。

另外我人工核對出 **4 件工具完全沒報的事**，其中兩件是真缺口（第 5 節 ④⑤），一件是工具說法**不成立**、我把它排除掉（第 5 節 ②）。

---

## 2. 這一棒在檢查什麼（白話）

一個稽核專案上有一份人員名冊：誰是這個專案的成員、他是「管理者」還是「只能看」、誰負責哪一條控制項。這份名冊決定了所有後續的權限。

這一棒檢查的是：**管理這份名冊的那幾支 API（新增成員、移除成員、改角色、查名冊），從網址進來之後，後端有沒有問一句「你是不是這個專案的人」。**

另外還檢查了兩樣基礎設施：
- **守門的單一入口**（`participant/common/guard.py`）——六支管名冊的服務都靠它檢查權限，所以要看「誰有接上、誰沒接上、接了但有條件可以繞過」。
- **套件掛進主系統的契約**（`participant/plugin/`）——看主系統少傳一個東西時，套件會不會安靜地啟動但不檢查權限。

---

## 3. 掃到什麼：總覽表

### 3.1 範圍內（本棒的發現）

工具報 4 條，去重後是 **2 個問題**。下表以「問題」為單位，括號註明對應工具的哪幾條。

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 |
|---|---|---|---|---|---|
| **P1-1**<br>🔴 高<br>(工具 F1+F2) | 「查專案成員名冊」這支 API **完全沒有檢查你是不是這個專案的人**，而且查詢條件全部非必填——送一個空的請求 `{}`，程式會把「沒有條件」當成「不用過濾」，變成**無條件全表查詢** | **一次撈光全庫 751 筆成員資料**（DEV 實測筆數），含：專案編號、角色（誰是管理者）、成員的登入帳號與暱稱、建立時間。跨客戶的資料也會一起回來，因為這張表沒有客戶欄位、也沒開隔離。**登入帳號外洩可拿去做針對性的密碼攻擊**；「誰是哪個專案的管理者」外洩可拿來挑社交工程目標 | **只要一個能登入的帳號**（任何角色，甚至不必是任何專案的成員）。不需要知道任何編號 | 入口 `participant/api/routes/project_participant_route.py:46`<br>缺口本體 `participant/app/service/project_participant_service.py:44` | 在 `get_project_participants` 開頭加一道「你是不是這個專案的成員」檢查（主專案已有現成的 `common.authz.project.assert_project_participant`，套件側比照 `common/guard.py` 開一支 port 由主系統注入），並且**把 `project_id` 改成必填、沒給就回 400**，不要讓它退化成全表撈。同檔 `get_project_participant_menus`（:150）是同一個缺口，要一起補 |
| **P1-2**<br>🟡 中<br>(工具 F3+F4) | 「查控制項層成員名冊」的兩支 API（清單與選單）**同樣沒有成員資格檢查**，三個編號欄位也全是非必填 | 用遞增猜測的整數編號，讀走**任意專案**在群組層與控制項層的人員指派。這條路徑還會順手把**專案層**的成員一起撈出來，等於一次請求拿到該專案三層的完整人員與角色配置。送空請求時同樣退化成全表 | 同上，只要一個能登入的帳號 | 入口 `participant/api/routes/project_control_participant_route.py:65`<br>缺口本體 `participant/app/service/project_control_participant_service.py:167`（選單版在同檔 :142） | 在 `get_control_participants_with_inherits` 與 `get_control_participant_menu` 兩支開頭加同一道成員資格檢查，並要求 `project_id` 必填。`control_group_participant_service.py` 的 `get_control_group_participant_menu`（:35）與 `get_control_group_participants_with_inherits`（:158）在同一條鏈上，不補的話從它們還是繞得回同樣的資料 |

### 3.2 人工核對追加（工具未報，見第 5 節詳述）

| # | 內容 | 嚴重度 |
|---|---|---|
| **P1-3** | 第六支服務 `project_group_participant_service.py` **全檔零守門**——新增／修改／刪除成員都不檢查權限，連「有注入才檢查」的條件式都沒有 | 🟡 中（目前沒掛 route，但 AI 儀表板讀得到、主系統也可直接呼叫） |
| **P1-4** | 流程參與者的守門寫在 `return` 後面：撈不到紀錄就整段跳過檢查，而它 docstring 說的「交後續 validate 拋 NotFound」**那個 validate 方法根本不存在** | 🟡 中 |
| **P1-5** | 寫入成員時只檢查「你是不是 `project_id` 這個專案的 manager」，**沒有任何一段檢查 `group_id`／`control_id` 屬不屬於這個專案**——可在自己的專案下寫入一筆指向別人專案群組的參與者列 | 🟢 低～中（要先是某專案 manager） |

### 3.3 範圍外

**本棒沒有範圍外發現。** 工具的密鑰專項這次沒有回報任何憑證類問題（4 條候選全在範圍內）。

---

## 4. 每條發現的詳述

### P1-1 — 查專案成員名冊沒有守門，空請求＝全表撈（高，可信度 高）

**現況**：已修（M10-1，1.21.0 出貨）

**這是什麼問題（白話）**

畫面上「專案成員」那份清單，後端是用 `POST /api/1.0/project-participants` 取得的。這支 API 只確認「你有登入」，**完全沒有確認「這個專案是不是你的」**。

更糟的是它的查詢條件（`project_id`、`user_uid`）**兩個都不是必填**。當你什麼都不填時，程式底層的查詢組裝器看到條件是「空的」，就把它們全部丟掉，最後送出去的 SQL 是**一句沒有任何條件的 SELECT**——整張表都回來。

**出事會怎樣**

送一個空的 `{}` 就拿到全庫的成員名冊。DEV 資料庫實測這張表有 **751 筆**。回傳內容包含：

- 專案編號、成員的使用者編號與暱稱
- **角色**（誰是 manager、誰是 viewer）——等於一份「誰能核准什麼」的地圖
- **建立者／修改者的登入帳號**（`created_user` / `updated_user` 存的是 login_name）

跨客戶的資料也會一起回來：這張表**沒有客戶欄位、也沒開資料庫隔離**（實測見下方核對註記），所以資料庫層攔不住。

**要先有什麼才打得到**

只有一個條件：**一個能登入的帳號**。任何角色都行，不必是任何專案的成員，也不需要知道任何編號。這是本次掃描裡門檻最低的一條。

**在哪裡**

- 入口：`participant/api/routes/project_participant_route.py:46`——`@use_kwargs(ProjectParticipantQuery, location='json')` 把整個請求 body 展開成 `**req_data` 直接丟給 service
- 條件非必填：`participant/api/serializers/project_participant.py:4-6`，`ProjectParticipantQuery` 兩個欄位都沒寫 `required=True`
- 缺口本體：`participant/app/service/project_participant_service.py:44`
- 退化成全表的那一步：`jedi_common` 的 `BaseRepositoryImpl._gen_filters()`，對 `None` 值一律跳過
- **對照組（同一個檔案裡有做的）**：`:90`（新增）、`:112`（修改）、`:131`（刪除）三處都呼叫了 `assert_project_manager`——**所以「讀漏掉」是不一致，不是刻意**

**怎麼修**

1. 在 `get_project_participants` 開頭加成員資格檢查。主專案已有現成實作 `common.authz.project.assert_project_participant`（`common/authz/project.py:101-120`）；套件側比照 `common/guard.py` 現有的 `assert_project_manager`，再開一支 `assert_project_participant` port 由主系統注入。
2. **把 `project_id` 改成必填**，沒給就回 400——不要讓「沒有條件」變成「全部給你」。
3. 同檔的 `get_project_participant_menus`（:150，服務 `GET /project-participants/menu`）是同一個缺口，一起補。
4. 長期建議：替這五張表補上客戶欄位或資料庫層隔離，讓應用層漏一次不會直接變成跨客戶外洩。

**首腦核對註記（我自己開檔＋查 DEV 資料庫確認）**

- ✅ **開檔核對成立**。`project_participant_service.py:43-45` 確實是 `get_all(ProjectParticipantQueryEntity(**kwargs))` 一行到底；`grep` 全檔，`assert_project_manager` 只出現在 :90/:112/:131 三個寫入方法，讀取路徑一次都沒有。serializer 兩個欄位確實沒有 `required`。
- ✅ **資料庫層真的沒有第二道防線**。唯讀 SQL 實查 DEV（2026-09-14）：`compliance.project_participants` 的隔離開關（RLS）是 **關的**，751 筆；`control_group_participants`（11 筆）、`project_control_participants`（5 筆）、`process_participants`（0 筆）、`project_group_participants`（0 筆）也全是關的。對照組 `compliance.projects` 開關是**開的**、4 條規則（用 `cm_app` 身分查 `projects` 回 0 筆，證明規則確實在作用）。
- ✅ **AI 儀表板那條路也通**。`di_containers/dashboard_apis/participant.py:19-29` 申報的 `participant.get_project_participants` 呼叫的就是同一支方法，`required_params` 是空的、`project_id` 列在 `optional_params`——所以 AI 儀表板問「有哪些專案成員」時走的是同一個無守門路徑。
- ⚠️ 工具說「跨租戶的 login_name 會外洩，因為 `created_user`/`updated_user` 是直接從 model 對應、不經名冊查詢」——這一點**我開檔確認成立**：`ProjectParticipantResponse`（serializer :22-33）確實同時 dump `created_user` 與 `created_user_name`，前者是原始 login_name。

---

### P1-2 — 查控制項層成員名冊沒有守門（中，可信度 高）

**現況**：已修（M10-2，1.21.0 出貨）

**這是什麼問題（白話）**

稽核專案底下有「控制項群組 → 控制項」兩層，每一層都可以指派負責人。查這兩層名冊的 API（`POST /api/1.0/project-control-participants` 與它的 `/menu` 版本）同樣只確認「你有登入」。

**出事會怎樣**

編號是**遞增的整數**（不是猜不到的亂數 UUID），所以攻擊者可以直接從 1 開始試。試中一個就拿到那個專案的群組層＋控制項層人員指派。

而且這條路徑內部會呼叫 `get_control_group_participants_with_inherits`，那支又去查專案層的成員——**等於一次請求拿到該專案三層完整的人員與角色配置**。

送空請求 `{}` 時，三個編號欄位都是 `load_default=None`，同樣退化成無條件全表查詢。

**要先有什麼才打得到**

同 P1-1：一個能登入的帳號。因為編號是可遞增猜測的整數，實務上連「取得編號」這一步都算不上門檻。

**在哪裡**

- 入口：`participant/api/routes/project_control_participant_route.py:65`
- 條件非必填：`participant/api/serializers/project_control_participant.py:5-7`，三個 id 都是 `load_default=None`
- 缺口本體：`participant/app/service/project_control_participant_service.py:167`（清單）、`:142`（選單）
- 同一條鏈上的另外兩個讀取點：`control_group_participant_service.py:35`（選單）、`:158`（含繼承的清單）
- **對照組**：同檔 `:59`（新增）、`:103`（修改）、`:136`（刪除）都有 `assert_project_manager`

**怎麼修**

在 `get_control_participants_with_inherits` 與 `get_control_participant_menu` 兩支開頭加成員資格檢查，並要求 `project_id` 必填。**`control_group_participant_service.py` 的兩個讀取點要一起補**，否則從它們仍然繞得回同樣的資料。

**首腦核對註記**

- ✅ **開檔核對成立**。`project_control_participant_service.py` 的 `grep` 結果：`assert_project_manager` 只在 :59/:103/:136 三個寫入方法；`get_control_participant_menu`（:142）與 `get_control_participants_with_inherits`（:157）確實零守門。
- ✅ **「順手撈出專案層」成立**。`get_control_participants_with_inherits:161-163` 確實呼叫 `control_group_participant_service.get_control_group_participants_with_inherits(project_id, group_id)`，而那支內部（`control_group_participant_service.py:158` 起）會查專案層參與者做繼承解析。
- ⚠️ 工具 F4 說「跨租戶的列會因為 `UserEnrichmentMixin` 走主系統名冊（有隔離的 `users` 表）而被部分遮蔽，最後 dict 去重時被收掉」——**這一環我沒有進到 `UserEnrichmentMixin` 去逐行核對**（那支不在本棒 39 檔範圍內）。標記為**未經核對的推論**。但「同客戶內部跨專案的完整名冊外洩」這一半是確定成立的，不受這點影響。

---

### P1-3 — 第六支服務全檔零守門（中，工具未報，人工查證）

**現況**：🗑️ 裁定刪除，已刪除（M10-7，1.21.0 出貨）

**這是什麼問題（白話）**

管名冊的服務一共六支。其他五支的寫入方法都長這樣：

```python
if enforce_role and self._participant_role_service:
    assert_project_manager(...)
```

也就是「**有注入角色服務才檢查**」。而第六支 `project_group_participant_service.py`（專案群組參與者）**連這個條件式都沒有**——全檔 grep `assert_`、`enforce_role`、`participant_role_service` **零命中**，它的建構子根本沒收這個參數。

**出事會怎樣**

`add_project_group_participant`（:88）、`update_project_group_participant`（:116）、`delete_project_group_participant`（:143）三支寫入方法，任何呼叫端都能直接呼叫，**不會有任何權限檢查，也不會留下任何錯誤或警告**。這是典型的「靜默全開」：服務照常起得來、健康檢查照樣綠燈。

**目前的實際風險**

比前兩條低，因為：
- `participant/api/routing.py` 的 `FROZEN_URLS` 裡**沒有這支的 route**——所以現在沒有 HTTP 端點直通它。
- 但 **AI 儀表板讀得到**：`di_containers/dashboard_apis/participant.py:97-108` 申報了 `participant.get_project_group_participants`（讀取路徑）。
- 主系統的 DI 有把它組出來（`di_containers/flow_engine/project_participant_containers.py:113-117`）並注入套件（`core/plugins/participant.py:137`），所以**哪天有人給它掛上 route，那一刻就是三支無守門的寫入端點上線**。

**在哪裡**

`participant/app/service/project_group_participant_service.py`，全檔 137 行。建構子在 :16-22（只收 `user_service` 與 domain service 兩個參數）。

**怎麼修**

建構子補收 `participant_role_service`，三支寫入方法比照其他五支加上守門。**同時建議把「有注入才檢查」這個條件式本身當成待議題**——它讓「漏注入」變成「靜默全開」，是本 arc 疑點的主要型態。

**順帶記錄一個未來陷阱（非資安）**：同檔 `_resolve_ids_from_uids`（:31-33）是一支**恆回 `(0, 0)` 的 stub**，註解自陳「舊 OSCAL 評估計畫模型停用後未重建」。`get_project_group_participant_menu_by_uid`（:41）等四支 `_by_uid` 方法全部靠它，等於永遠查 `project_id=0`。`project_control_participant_service.py:194-198` 與 `control_group_participant_service.py:188` 有同樣的 stub。這不是資安問題，但會讓任何「補了守門卻用 uid 路徑測試」的人得到假的通過結果。

---

### P1-4 — 流程參與者的守門寫在 return 後面，而它依賴的 validate 不存在（中，工具未報，人工查證）

**現況**：🗑️ 裁定刪除，已刪除（M10-8，1.21.0 出貨）

**這是什麼問題（白話）**

改／刪流程參與者時，API 只帶「流程編號」不帶「專案編號」，所以程式要先反查出專案編號才知道要檢查誰。反查的程式長這樣：

```python
record = self.process_participant_domain_service.get_one(...)
if record is None or record.project_id is None:
    return                       # ← 撈不到就整段跳過
assert_project_manager(...)      # ← 守門在這一行
```

**撈不到紀錄就直接 `return`，守門那一行永遠不會執行。**

docstring 自己寫「查無 record 則交後續 validate 拋 NotFound」——意思是「沒關係，後面那支 `validate_participant_user_is_exist` 會擋下來」。

**我開檔核對的結果：那支 validate 方法根本不存在。**

`ProcessParticipantDomainService`（`domain/service/process_participant_domain_service.py`，全檔 27 行）只有 7 個方法：`get_all` / `get_one` / `add` / `update` / `delete` / `delete_by_project_id` 和建構子。**沒有 `validate_participant_user_is_exist`，也沒有 `validate_participant_user_is_not_exist`。** 那兩支只存在於 `ProjectParticipantDomainService`（:45、:51）。

**出事會怎樣**

`update_process_participant`（:82）與 `delete_process_participant`（:103）呼叫 `_assert_manager_for_process` 時，若 `(process_id, user_id)` 組合撈不到紀錄，守門被跳過，程式繼續往下跑到 `self.process_participant_domain_service.validate_participant_user_is_exist(...)` —— 這一行會拋 `AttributeError`，變成 HTTP 500。

所以**目前的結果是 500 錯誤而不是資料外洩**。但這是「靠一個 bug 擋住另一個 bug」：

- 哪天有人把那支 validate 補上（很自然的修法），守門跳過的缺口就從「500」變成「真的通過」。
- 而且 500 本身也是問題：這條路徑等於**完全不能用**。DEV 實測 `process_participants` 表 0 筆，所以這個功能可能根本沒人在用，也就沒人回報過。

另外要注意 `record.project_id is None` 這一半——就算撈得到紀錄，只要那筆的 `project_id` 是空的，守門一樣被跳過。這張表的 `project_id` 沒有 NOT NULL 約束（0 筆資料無從觀察實況）。

**在哪裡**

`participant/app/service/process_participant_service.py:49-56`（`_assert_manager_for_process`），呼叫端在同檔 :88（update）與 :107（delete）。

**怎麼修**

把「撈不到就 return」改成「撈不到就拋 NotFound」。守門的預設行為必須是**拒絕**，不是放行——這正是同套件 `common/guard.py` 檔頭寫的原則（「未接線時 fail loudly，絕不放行」），只是這一處沒照做。順帶把 docstring 裡那句對不存在方法的引用改掉。

---

## 5. 卡片「重點看什麼」逐條回應

卡片列了六項思考起點，逐條回應如下。

| 卡片列的疑點 | 本棒結論 |
|---|---|
| ① **五支讀端點只驗登入、`project_id` 呼叫端自填**（首腦已核 ✅ 屬實） | ✅ **成立，且比卡片描述更嚴重**。卡片說的是「知道任一專案編號就能讀走別家的名單」，實際上**連編號都不用給**——條件全非必填，空請求直接全表。三層（route / service / repo）**沒有任何一層補上守門**，repo 層那一層反而是問題的放大器（`_gen_filters` 把空條件當成不過濾）。詳見 P1-1、P1-2 |
| ② **更新／刪除流程參與者時守門寫在 `return` 之後**（首腦已核 ✅ 屬實） | ✅ **成立，而且它宣稱的兜底不存在**。docstring 說「交後續 validate 拋 NotFound」，但 `ProcessParticipantDomainService` 全檔 27 行、7 個方法，**根本沒有那支 validate**。目前的實際結果是 500 而不是放行。詳見 P1-4（工具完全沒報這條） |
| ③ **六支服務都是「有注入才守門」，其中一支根本沒注入** | ✅ **成立**。`project_group_participant_service.py` 全檔 grep 零命中，建構子沒收 `participant_role_service`。六支的守門條件對照表見第 6 節。詳見 P1-3（工具完全沒報這條） |
| ④ **body 自填的 `project_id`／`group_id`／`control_id` 沒人驗證彼此相屬**（待 runner 核對） | ✅ **核對成立**。`control_group_participant_route.py:26-31` 與 `project_control_participant_route.py:86-93` 確實全由 body 帶入，而 `add_control_group_participant`（`control_group_participant_service.py:81-82`）的守門是 `assert_project_manager(role_service, project_id, group_id=group_id)`——它只問「你是不是 `project_id` 這個專案的 manager」，**沒有任何一段程式問「`group_id` 屬不屬於這個專案」**。所以在自己是 manager 的專案下，可以寫入一筆指向別的專案的 group／control 的參與者列。工具沒有報這條，我把它列為 **P1-5（低～中）**：它需要先是某專案的 manager，門檻比前兩條高，但寫入型的越權比讀取更難清理 |
| ⑤ **「查不到人就回空 dict」的降級會不會反向變放行**（待 runner 核對） | ❌ **不成立，可以排除**。我逐段讀完 `common/user_directory.py` 七處降級（:49-113）：它們回空 dict 的對象是**「把 user_id 換成人名」的顯示用查詢**（`get_users_by_ids` / `get_org_units_by_ids` / `get_users_by_login_names`），純粹影響畫面上的姓名與部門欄位顯示為空。**授權判斷完全不走這條路**——授權走的是 `common/guard.py` → 主系統的 `assert_project_manager`，兩者沒有交集。主系統 `core/plugins/participant.py:142-146` 那段紅字警告講的後果也確實只是「部門欄與 nickname 留空，看起來像資料掉了」。**這條是誤報方向，沒有「查不到人→不是參與者→跳過檢查」的路徑** |
| ⑥ **守門單一入口未接線時的行為、`configure()` 會不會被呼叫兩次或傳 None、掛載契約會不會讓宿主少傳 adapter 也能啟動** | ⚠️ **大致健康，但有一個真缺口（P1-3 已述）**。逐項：<br>• `guard.py:44-52` 未接線時 raise RuntimeError 拒絕執行——**這段是對的**，核對屬實。<br>• `configure()` 被呼叫兩次或傳 None：`grep` 全套件，正式呼叫點只有 `assembly.py:80`（掛載模式）與 `:114`（library 模式），兩條路徑都在 `register()` 內、都傳 `adapters.project_role_guard`。唯一傳 `None` 的地方是測試檔 `tests/participant/test_participant_plugin_contract.py:159`（刻意測 fail-closed）。**正常執行路徑無法把已接好的守門清掉**。<br>• 掛載契約：`assembly.py:_assert_wiring`（:26-44）檢查 `auth_required` / `project_role_guard` / `user_directory` 三者缺一即拒絕掛載，**契約這一層是 fail-closed 的、做得好**。<br>• **但契約擋不住 P1-3**：`_assert_wiring` 檢查的是「主系統有沒有把守門 adapter 傳進來」，**沒有檢查「六支 service 是不是都真的把守門接上」**。`project_group_participant_service` 的 DI 組裝（`di_containers/flow_engine/project_participant_containers.py:113-117`）少傳 `participant_role_service`，契約檢查完全看不見——因為那是主系統 DI 容器內部的事，不經過套件的 adapter 表 |
| ⑦ **能力點守門是空的**（卡片指明只審「每條 route 有沒有真的被 `jwt_required` 包住」） | ✅ **包住了**。`routing.py:52-57` 的 `R()` 在 `auth_required=None` 時直接回原 class；主系統 `core/plugins/participant.py:140` 確實傳了 `auth_required=jwt_required()`，且 `assembly.py:_assert_wiring` 會在缺它時拒絕掛載。`_guarded()`（`assembly.py:47-57`）逐一包 get/post/put/delete/patch 五個動詞，**沒有漏網的動詞**。`api/guards.py:10-12` 的 `CAPABILITIES` 空 tuple 屬設計自陳，本棒照卡片指示不重審這個政策 |

---

## 6. 套件公開方法 vs 宿主守門 對照表

套件棒必交（FR-088 P2 換來的紀律）。本棒 scope 內六支 app service 的 public 方法逐一列出。

**「守門是否條件式」欄的意思**：寫「是」代表守門包在 `if enforce_role and self._participant_role_service:` 裡面——只要主系統漏注入角色服務，或呼叫端傳 `enforce_role=False`，檢查就被跳過。

### ProjectParticipantService（`project_participant_service.py`）

| 方法 | 宿主哪裡呼叫 | 呼叫前有沒有守門 | 條件式？ |
|---|---|---|---|
| `get_project_participants` (:43) | route `project_participant_route.py:46`；`app/flow_control/service/project_service.py:259/431/586/633`；AI 儀表板 `di_containers/dashboard_apis/participant.py:19` | ❌ **無** | — |
| `get_participants_by_project_ids` (:48) | `app/flow_control/service/project_service.py:144` | ❌ 無（主系統 `list_projects` 自身的可見性過濾算前置） | — |
| `get_project_participants_permissions` (:63) | 套件內部 | ❌ 無 | — |
| `add_project_participant` (:80) | route；`app/flow_control/service/project_service.py:453`（傳 `enforce_role=False`）；`app/module_frame/service/module_frame_service.py:242`（傳 `enforce_role=False`） | ✅ `assert_project_manager` :90 | **是** |
| `update_project_participant` (:108) | route；`project_service.py:463`（`enforce_role=False`） | ✅ :112 | **是** |
| `delete_project_participant` (:127) | route；`project_service.py:473`（`enforce_role=False`） | ✅ :131 | **是** |
| `get_project_participant_menus` (:150) | route `/project-participants/menu` | ❌ **無** | — |
| `get_project_participants_by_uid` (:164) | route（`project_uid` 分支） | ❌ 無 | — |
| `get_project_participant_menus_by_uid` (:169) | route | ❌ 無 | — |
| `delete_by_project_id` (:174) | 主系統刪專案流程 | ❌ 無（授權由刪專案那一層把關） | — |

### ProjectControlParticipantService（`project_control_participant_service.py`）

| 方法 | 宿主哪裡呼叫 | 守門 | 條件式？ |
|---|---|---|---|
| `get_project_control_participants` (:34) | AI 儀表板 `dashboard_apis/participant.py:68` | ❌ **無** | — |
| `add_project_control_participant` (:41) | route `project_control_participant_route.py` | ✅ :59 | **是** |
| `update_project_control_participant` (:85) | route | ✅ :103 | **是** |
| `delete_project_control_participant` (:119) | route | ✅ :136 | **是** |
| `get_control_participant_menu` (:142) | route `/project-control-participants/menu` | ❌ **無** | — |
| `get_control_participants_with_inherits` (:157) | route `:65` | ❌ **無** | — |
| `get_control_participants_with_inherits_by_uid` (:201) | route（uid 分支，但 `_resolve_ids_from_uids` 是恆回 0 的 stub） | ❌ 無 | — |
| `get_control_participant_menu_by_uid` (:210) | route（同上 stub） | ❌ 無 | — |
| `delete_by_project_id` (:219) | 主系統刪專案流程 | ❌ 無 | — |

### ControlGroupParticipantService（`control_group_participant_service.py`）

| 方法 | 宿主哪裡呼叫 | 守門 | 條件式？ |
|---|---|---|---|
| `get_control_group_participant_menu` (:35) | 被 `ProjectControlParticipantService:144` 呼叫 | ❌ **無** | — |
| `get_control_group_participants` (:58) | AI 儀表板 `dashboard_apis/participant.py:82` | ❌ **無** | — |
| `add_control_group_participant` (:66) | route `control_group_participant_route.py:25` | ✅ :82 | **是** |
| `update_control_group_participant` (:105) | route :44 | ✅ :121 | **是** |
| `delete_control_group_participant` (:136) | route :63 | ✅ :151 | **是** |
| `get_control_group_participants_with_inherits` (:158) | 被 `ProjectControlParticipantService:161` 呼叫 | ❌ **無** | — |
| `get_control_group_participant_menu_by_uid` (:193) / `..._with_inherits_by_uid` (:200) | 套件內（uid stub） | ❌ 無 | — |
| `delete_by_project_id` (:207) | 主系統刪專案流程 | ❌ 無 | — |

### ProcessParticipantService（`process_participant_service.py`）

| 方法 | 宿主哪裡呼叫 | 守門 | 條件式？ |
|---|---|---|---|
| `get_process_participants` (:43) | AI 儀表板 `dashboard_apis/participant.py:31` | ❌ **無** | — |
| `add_process_participant` (:59) | **宿主零取用**（`app/flow_engine/service/workflow_execution_service.py:113` 注入了這支 service 但全檔未呼叫任何方法；無 route） | ✅ :66 | **是** |
| `update_process_participant` (:81) | **宿主零取用** | ⚠️ :88 走 `_assert_manager_for_process`，**撈不到紀錄即跳過**（P1-4） | **是**＋缺口 |
| `delete_process_participant` (:102) | **宿主零取用** | ⚠️ 同上（:107） | **是**＋缺口 |
| `get_process_participant_menus` (:127) | **宿主零取用** | ❌ 無 | — |
| `get_process_participants_with_inherits` (:138) | **宿主零取用** | ❌ 無 | — |

**「宿主零取用」是未來陷阱，登記在此**：`workflow_execution_service.py` 第 85 行收了 `ProcessParticipantService` 進建構子、第 113 行存成屬性，但全檔 grep `process_participant_service.` **零命中**——注入了卻從未使用。加上 `routing.py` 的 `FROZEN_URLS` 裡沒有任何流程參與者的 URL，這整組方法目前只有 AI 儀表板的讀取那一支是活的。哪天有人接上 route，P1-4 的缺口就會從「打不到」變成「打得到」。

### ProjectGroupParticipantService（`project_group_participant_service.py`）

| 方法 | 宿主哪裡呼叫 | 守門 | 條件式？ |
|---|---|---|---|
| `get_project_group_participants` (:24) | AI 儀表板 `dashboard_apis/participant.py:97` | ❌ **無** | — |
| `get_project_group_participant_menu` (:35) | **宿主零取用** | ❌ **無** | — |
| `get_project_group_participants_with_inherits` (:48) | **宿主零取用** | ❌ **無** | — |
| `add_project_group_participant` (:61) | **宿主零取用**（無 route） | ❌ **無** | **連條件式都沒有**（P1-3） |
| `update_project_group_participant` (:89) | **宿主零取用** | ❌ **無** | 同上 |
| `delete_project_group_participant` (:116) | **宿主零取用** | ❌ **無** | 同上 |
| `delete_by_project_id` (:132) | `core/plugins/participant.py:137` 注入，主系統刪專案流程 | ❌ 無 | — |

### ParticipantRoleService（`participant_role_service.py`）

這支是**被守門呼叫的角色查詢**，不是被守門保護的對象。`get_user_role_with_inherits` 三層往上找（control → group → project），都沒有就回 `None`——**回 `None` 時主系統的 `assert_project_manager` 會 raise，不是放行**（已核對 `common/authz/project.py:16-30`）。這一段是對的。

---

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

分兩層講，不要混在一起。

### 第一層：「報出來的這些，真的存在嗎」→ **可信度高**

- 三個獨立檢查員從零讀檔，對 4 條候選各投一票，**12 票全數投出，沒有漏投，4 條全部 3:0 通過**。
- 檢查員主動把 F1 的嚴重度**往下調**（研究員報 HIGH，三票分別是 MEDIUM/HIGH/MEDIUM，最終取 MEDIUM），代表投票不是橡皮圖章。
- **範圍內每一條我都自己開檔核對過**，另外用唯讀 SQL 在 DEV 查了五張表的隔離狀態與筆數。核對結果與工具一致，只有兩處標為未核對的推論（P1-1 的 AI 儀表板路徑我另外核對確認、P1-2 的 `UserEnrichmentMixin` 遮蔽效果我沒追）。
- 我把工具沒報的兩條真缺口補上（P1-3、P1-4），並且**排除掉一條卡片列的疑點**（第 5 節 ⑤，降級不會反向變放行）。

**關於驗證章 `unverified` 這件事要說清楚**：`verification.status` 蓋的是 `unverified`，但**原因不是面板失敗、不是撞額度、不是中途被砍**。渲染器原文：

```
refused F1: finding F1 file 'jedi_task_platform/participant/api/routes/project_participant_route.py' does not exist in the scanned tree
refused F4: finding F4 file 'jedi_task_platform/participant/api/routes/project_control_participant_route.py' does not exist in the scanned tree
```

研究員寫檔案路徑時多帶了一層 `jedi_task_platform/` 前綴（掃描根目錄已經是那一層了），渲染器逐字比對找不到檔案就把那兩條退掉，並因此把驗證章降成 `unverified`。**面板該做的事全做完了**（12 票、4 條全通過，見 `votes.json`），被退掉的兩條也不是額外的問題——F1 是 F2 的 route 層鏡像、F4 是 F3 的 route 層鏡像，同一個缺陷報兩次。機器可讀的 `.jsonl` / `.sarif` 只含 F2、F3 兩條；本報告四條齊全。

### 第二層：「這 39 個檔只有這些問題嗎」→ **不可宣稱**

- effort 設 `low`，代表**只派了一位研究員**通讀全範圍（實際派 2、回 2，含密鑰專項那支），不是逐檔窮舉。
- 工具**沒有申報逐檔閱讀帳本**（`coverage.research` 是 `null`），所以「哪幾個檔真的被讀完了」無從查證。
- **工具的注意力明顯集中在讀端點那一組**：4 條候選全部是同 2 個問題的兩層鏡像，等於 12 票裡沒有一票花在其他面向。卡片列的疑點 ②③④ 工具**一條都沒碰**，全是我人工追出來的。
- 第 6 節的對照表是我逐檔 grep 建的，不是工具產物。

**白話總結：報出來的問題是真的；但「掃過了」不等於「這 39 個檔乾淨了」。** 尤其寫入路徑（六支 service 的 add/update/delete）工具幾乎沒有著墨，那一塊的信心來自我的人工對照表而不是工具。

---

## 8. 執行概況（工程師看的數字）

照 stamp 的 `verification` 欄與 `coverage.json` 實抄：

| 項目 | 數值 |
|---|---|
| `status` | **`unverified`**（原因見第 7 節：2 條在渲染階段因路徑前綴被退，非面板失敗） |
| `verification.reason` | `2 finding(s) were refused at render and are absent from this report: F1, F4` |
| `candidates` | 4（去重後 4） |
| `panel_votes` | 12 |
| `panel_quorum_findings` | 4 |
| `researchers_dispatched / returned` | 2 / 2 |
| `unreviewed_candidate_sites` | 0 |
| `verificationRun` | 1 |
| `lostCandidates` | 無 |
| `severityLowered` | F1（HIGH→MEDIUM），由檢查員投票結果決定（MEDIUM / HIGH / MEDIUM） |
| 掃描範圍 | 39 支檔（`scopeFileCount: 39`，與卡片第二步的 `git ls-files` 對上） |
| effort / focus | `low` / `attack-surface` |
| 完整度檢查 | `not-applicable`（範圍掃描不做全樹盤點） |
| `skippedComponents` / `droppedComponents` | 皆空 |
| **Run ID** | **`wf_b2dc8f24-2f9`** |
| 耗時 | 約 2 小時 50 分（10,186 秒），14 個 agent 全數完成、0 失敗 |
| 工具原始產物 | `CLAUDE-SECURITY-20260914-033458/`（BE repo 根目錄，該目錄自帶 `.gitignore` 不入版控） |

**沒有執行任何程式碼**：所有發現都是讀程式碼推導出來的，沒有實際打過 API、沒有驗證過任何攻擊手法。對資料庫只做了唯讀查詢（確認隔離開關與筆數）。

---

## 9. 建議的後續（交決策者裁）

**現況**：本站 FR-095 已掃完；以下為掃描當時的建議，各項現況見前文發現小節的「現況」註記與 `docs/security-report/M10-task-platform.md`（資安問題已無未修，1.21.0 出貨）。

不做決定，只列出選項與各自的代價。

1. **P1-1 與 P1-2 一起修**——同一種缺陷（讀端點缺守門＋條件非必填退化成全表）、同一種修法，涉及四支 service 的六個讀取點。建議開**一張**修正卡。要注意的是這會改變 API 契約（`project_id` 從選填變必填），前端若有送空條件的呼叫會壞掉，**修之前要先查前端**。

2. **P1-3（第六支服務零守門）單獨修**——它同時要動套件（建構子與三支寫入方法）與主系統 DI（補注入）。目前沒有 route 直通，**不算緊急**，但它是「靜默全開」的典型，放著會在某次加 route 時變成真的洞。

3. **P1-4（守門寫在 return 後 ＋ validate 不存在）單獨修**——這一支目前是 500，功能等於不能用。修的時候要一起決定：是補上缺的 validate，還是把「撈不到就 return」直接改成拋 NotFound。**建議選後者**，因為守門的預設必須是拒絕。

4. **P1-5（`group_id` 不驗相屬）**——第 5 節 ④ 的發現，寫入型越權。門檻較高（要先是某專案 manager），但寫入的破壞比讀取難清理。建議與 1 併卡或獨立一張，交決策者定。

5. **「有注入才守門」這個寫法本身要不要收掉**——這是本 arc 疑點的共同型態（`if enforce_role and self._participant_role_service:`）。改成「沒注入就 raise」（比照 `common/guard.py` 檔頭自己寫的原則）是治本，但會影響現有六處 `enforce_role=False` 的內部呼叫端。**這是跨模組決策，不在本棒決定**。

6. **這五張表要不要補資料庫層隔離**——`project_participants` 等五張表全部沒有客戶欄位、沒開隔離。套件 migration 檔頭自陳「沒有 RLS 是刻意的，租戶隔離由專案層擋」，但**這個前提已被跨 arc 總表第 43 條推翻**（`projects` 的隔離也是關的）。屬跨 arc 議題，建議與 FR-094 一起收口。

**與既有案的關係**：本棒發現全部屬跨 arc 總表 §2.9 🅰 組「只驗身分不驗歸屬」同型。總表第 43 條曾提過一句 `project_participants`，但那是講「表沒開隔離」，**不是講這幾支 API 沒守門**——本棒的 API 層缺口是新發現，不是重複。
