# S5 掃描結果：jedi-iam 角色與權限能力

> **掃描日期**：2026-09-06
> **掃描版本**：jedi-python-package `feature/FR-075` @ `67cb075941bd6fd57f1e05a76bc56d5fed984c70`（乾淨，無未 commit 變更）
> **工具**：Claude Code `claude-security` plugin v0.10.2.3（`claude-security:scan` workflow）
> **範圍（指定）**：角色與權限能力 route→serializer→DTO→service→domain→repository→model→mapper 全鏈路，共 **49 個受版控檔案**（啟動前以 `git ls-files` 驗證，數字吻合：49）
> **effort**：`low`，**focus**：`attack-surface`
> **狀態**：✅ **流程完整跑完並經驗證面板**（`verification.status: verified`）——14 個 agent 全數回報、0 錯誤、0 空回，4 條去重後候選全部投完票，2 條存活
> **對應卡片**：CM-1554（母卡 CM-1546）

## 摘要：這一棒找到什麼

**2 條發現，都在同一個主題上：權限資料讀得到、也算得不對。**

- **F1**：角色清單、單筆角色、角色選單這些「讀」的端點**完全沒有權限檢查**，任何登入者都能拿到整份「誰是管理員、管理員有哪些能力」的對照表——**還附帶每個角色的成員名單**（含 email、電話、職稱、最後登入時間）。
- **F2**：權限守門在算「你有哪些能力」時，**把已停用、已刪除、已過期、以及別的租戶的角色也算進去**。停用一個角色只讓它從畫面消失，API 權限照舊。

兩條都是 MEDIUM、confidence HIGH，**都是本輪新發現**（這一塊第一輪未涵蓋，無舊發現可對照）。**沒有 CRITICAL**，也**沒有越界**——兩條都在本棒指派的模組脈絡內。

**與 S4 的關係值得一提**：S4 找到的自助提權（F3）是「攻擊者怎麼把自己變成管理員」，本棒 F1 則是「攻擊者怎麼知道該把自己變成哪個角色、該打誰的帳號」。兩者相加即完整攻擊鏈的前後段。

## 掃描執行狀況

| 項目 | 數值 |
|---|---|
| 派出 agent | 14（研究員 2、面板 12） |
| 回報 agent | **14（100%，0 錯誤、0 空回、0 被砍）** |
| 研究員 | 2 名派出 / **2 名回報**（分別交 2 條與 3 條） |
| 候選發現 | 5 條 → 去重後 **4 條** |
| 面板票數 | **12 票（4 條 × 3 票），全部投完** |
| 未經審查的候選 | **0** |
| 總 token | 1,910,922 |
| 總 tool call | 941 |
| 耗時 | 約 2 小時 58 分（10,702 秒） |

**模型設定**：主 session 與所有 agent 皆為 **Opus 5 (1M context)**（agent frontmatter `model: inherit`，繼承主 session）。**一輪跑完、零中斷、零重試**——卡片 2026-09-06 修正段指定的設定再次驗證有效，S4～S7 首輪用 Sonnet 5 的 stalled 死法未再出現。

**面板表決明細**（這次不像 S4 全票，分歧本身有資訊）：

| 候選 | 票數 | 結果 |
|---|---|---|
| F1 角色讀取端點無守門 | **3/3** | ✅ 存活 |
| F2 權限守門採計無效角色指派 | **2/1** | ✅ 存活（有一票反對）|
| F3 角色清單端點無守門（與 F1 重疊）| 1/2 | ❌ 刪除 |
| F4 單筆角色端點無守門（與 F1 重疊）| 0/3 | ❌ 刪除 |

**F3、F4 被刪不代表那些端點沒問題**——它們講的正是 F1 已涵蓋的同一批端點（另一名研究員拆成三條報），面板判定為重複而歸併到 F1。**F1 的建議修法已含這兩支端點。**

## 逐條發現（均已自行開檔核對）

### F1（MEDIUM, confidence HIGH）— 角色讀取端點無權限守門，外洩權限矩陣與使用者名冊

**面板**：3/3 全票 ｜ **CWE-862** ｜ `api/routes/role_route.py:46`

> **核對結果：✅ 屬實，MEDIUM 這個級別我同意，沒有誇大也沒有低估。**
> 我把 route → serializer → mapper → repository 整條讀完確認的，不是照抄工具輸出。

**白話說明**：系統裡「誰能做什麼」這份對照表，任何登入者都看得到——連只有唯讀權限的最低權限帳號也一樣。更麻煩的是回應裡**還夾帶每個角色的成員清單**，等於順手提供一份「管理員是誰、他的 email 和電話是什麼」的名單。

**核對過程（四個環節逐一確認）**：

1. **寫有守門、讀沒有** — 同一支 `role_route.py` 裡對比極明顯：`RoleCreateRoute.post`（:57）掛 `@capability_required("role.create")`、`RoleRoute.put`（:83）掛 `role.update`、`delete`（:96）掛 `role.delete`、`RoleStatusRoute.put`（:108）掛 `role.update`。而 `RolesRoute.post` 列表（:35）、`RoleRoute.get` 單筆（:74）、`RolesMenuRoute.get` 選單（:25）**只有 `@auth_required`**。`capability_route.py` 的選單端點同樣如此。
2. **回應確實含使用者名冊** — `api/serializers/role.py:42`：
   `users = fields.List(fields.Nested("UserResponse", exclude=['roles', 'salt', 'password']))`。
   **排除的只有 `roles` / `salt` / `password`** — `login_name`、`email`、`tel`、`job_title`、`status`、最後登入成功/失敗時間全部留著。
3. **名冊預設就會被填** — 這是我特別去追的關鍵環節，因為若預設不填則此條大幅降級。`RoleMapper.to_entity(role, include_user=True)`（`role_mapper.py:8`）**預設值就是 True**；全 codebase grep `include_user` 只有兩處傳 `False`（`user_role_mapper.py:25`、`user_mapper.py:34`），**都不是角色列表路徑**。列表走 `BaseRepositoryImpl` 的 `self.mapper.to_list_entity(results)`（`base_repository_impl.py:121` 等處），**不帶參數 → 吃預設 True**。名冊確實會出現在回應裡。
4. **RLS 有界但不解決問題** — 資料被限制在呼叫者的租戶子樹內，所以這不是跨租戶外洩；但**租戶內的權限矩陣與人員名冊外洩仍然成立**，對單租戶落地部署而言就是全系統。

**攻擊價值（為什麼這比「只是列表沒擋」嚴重）**：這是一張**攻擊地圖**——告訴攻擊者哪個帳號值得打、要奪取哪個角色、以及該帳號的 email（可接續釣魚，或接 S4 的忘記密碼洞）。

**觸發前提**：① 持有任一有效 token（任何角色，含唯讀）；② 套件以預設 `mount_api=True` 掛載。**不需要任何特殊權限。**

**建議修法**：讀的端點補上既有的讀取能力點——`RolesRoute.post`、`RoleRoute.get`、`RolesMenuRoute.get` 掛 `@capability_required("role.read")`，`CapabilityMenuRoute.get` 掛對應能力點，位置依既有慣例放在 `@auth_required` 之內。另建議把 `users` 欄位從列表 serializer 拿掉，避免角色列表兼作使用者名錄（需確認 FE 是否依賴此欄位）。

---

### F2（MEDIUM, confidence HIGH）— 權限守門把停用／已刪除／過期／他租戶的角色都算進來

**面板**：2/3（一票反對）｜ **CWE-863** ｜ `jedi_iam/authz/capability.py:57`

> **核對結果：✅ 機制屬實，四種情境的程式碼證據我都逐一核對過。**
> 面板有一票反對，我認為反對票可能著眼於「RLS 仍會擋掉資料」——這點是對的（影響有界），但**擋的是資料範圍、不是動作權限**：寫入類端點若僅靠此守門，動作本身仍會被放行。故我維持「屬實」。

**白話說明**：管理員在畫面上把某個角色「停用」，直覺是那個角色的人就不能用那些功能了。實際上**只有選單消失，API 照樣放行**——因為守門在算能力點時，沒有去看這個角色是否還有效。同樣的問題也發生在「已軟刪除的角色」「已過期的指派」「屬於別的租戶的角色」。

**核對過程（四種情境逐一驗證）**：

1. **守門確實沒做任何過濾** — `capability.py:57` 的迴圈就是
   `for role in getattr(user, "roles", None) or []:` → `for cap in role.capabilities`。
   從頭到尾沒有任何條件判斷。
2. **`user.roles` 確實是無過濾的直通** — `infra/models/user.py:74`：
   `roles = association_proxy("user_roles", "role")`，底層 `user_roles` 是普通 relationship，不帶 filter。
3. **被忽略的欄位確實都存在** — `infra/models/user_role.py` 有 `tenant_id`（:61）、`starts_at`（:79）、`ends_at`（:82）；`infra/models/role.py` 有 `enable`（:40）、`is_delete`（:42）。**五個欄位全部存在、全部沒被讀。**
4. **停用/刪除的語意確實如描述** — `role_domain_service.py:118-125`：`status >= 0` 時設 `role.enable = status`（即 0＝停用），`status < 0` 時設 `role.is_delete = 1`。所以「停用」與「刪除」都只是改旗標，而守門不看旗標。

**同一份資料在別處查得是對的（這是本條最有力的證據）**：
`infra/repository/ui_route_repo_impl.py:get_viewable_by_user_uid`（:67-97）查同樣的 user→role→capability，**條件寫得完整**：
```python
UserRole.starts_at <= func.now(),
or_(UserRole.ends_at.is_(None), UserRole.ends_at >= func.now()),
# 且 tenant_id 不為 None 時再加 UserRole.tenant_id == tenant_id
```
**選單看得到什麼、API 允許做什麼，兩條路徑對同一份資料的判讀已經分岔。** 這正是 [[feedback_shared_template_not_buildtime_hardcode]] 同源的問題形狀：同一個事實兩套實作，各自演化。

**跨租戶那一項的前提我特別追過**（因為它最像「理論上成立但湊不齊」）：
`middleware/context.py:114-118` 的 `build_user_context` 確實會擋 `X-Tenant-ID`——但它擋的是**「你不隸屬這個租戶」**（`check(user, requested)`）。若使用者**真的同時隸屬 A、B 兩個租戶**（在 A 是管理員、在 B 只是檢視者），membership 檢查會通過，接著守門把 A 的管理員能力一併算入 B 的作業情境。**前提湊得齊，成立。**

**額外注意**：`viewer_is_super_admin()` 會直接放行（break-glass，設計如此），此路徑不受影響；本條談的是一般使用者。

**觸發前提**：① 持有有效 token 的帳號曾被指派角色；② 管理員停用/軟刪除了某角色、或指派設了 `ends_at`、或該帳號跨多租戶且各租戶角色不同；③ 目標端點的伺服器端唯一檢查就是此守門。

**建議修法**：改為迭代 `user.user_roles`（而非 proxy），逐列過濾：`starts_at` 未到、`ends_at` 已過、`tenant_id` 與 `get_user_context()` 的作業租戶不符者略過；角色 `enable != 1` 或 `is_delete != 0` 者略過。**更好的做法是把 `get_viewable_by_user_uid` 那組條件抽成共用述詞**，讓選單與 API 守門共用同一份判定，避免再次分岔。

**範圍註記（誠實標明）**：F2 的**落點檔案 `jedi_iam/authz/capability.py` 不在本棒指派的 49 檔內**。但它**不算越界**——問題的資料源（`user_roles`、`role_capabilities` 的 model 與 mapper）在範圍內，研究員是從範圍內的資料結構追到這個消費端才發現判讀不一致。這正是卡片「按業務模組垂直切、權限漏洞常活在層與層接縫」的預期產出形狀。開修正卡時需注意此檔屬 `jedi-iam` 套件的 authz 模組（FR-069 P8 上移的主體域守門）。

## 密鑰專項掃描

`sweep:secrets` 已執行完成。研究員在範圍外注意到 `jedi-common/.env` 為受版控檔案（LOW），但**該檔不在本棒指派範圍內**，依卡片紀律**標為越界、不列入本棒發現**——面板也未讓它存活。若要處理應另開卡（屬 repo 層憑證管理議題，非本模組）。

## 與舊發現的對照

卡片載明：**第一輪不完整掃描未涵蓋這一塊**，故無舊發現可對照。

**本輪兩條均為全新發現。** 卡片要求說明「舊的沒被找到是面板刪掉還是根本沒掃到」——本棒不適用（舊清單為空）。

## 建議開卡

| 建議卡 | 內容 | 嚴重度 |
|---|---|---|
| 1 | F1：角色與能力點的**讀取**端點補 `@capability_required`（列表／單筆／兩支選單共 4 支）；併評估列表 serializer 是否移除 `users` 欄位 | 🟡 MEDIUM |
| 2 | F2：權限守門過濾無效角色指派（停用／軟刪除／過期／他租戶），並與 `get_viewable_by_user_uid` 共用述詞 | 🟡 MEDIUM |

兩條均屬 `jedi-iam` 套件異動，依 CLAUDE.md「外部套件異動規範」，動手前需向決策者說明影響範圍與其他 consumer 風險。

**F1 需留意的相容性**：補上讀取守門後，既有前端頁面若以無 `role.read` 能力點的帳號呼叫角色清單，會開始收到 403。開卡時應含「確認 seed 資料中哪些角色已持有 `role.read`」的前置查核。

**F2 需留意的行為變更**：修正後，原本靠停用角色「以為已收回權限」的情境會真正生效——這是修正目的，但若有既存使用者實際依賴目前的寬鬆行為，會表現為「突然沒權限」。建議修正前先查 DB 有多少 `user_roles` 落在 `ends_at` 已過或角色 `enable=0` 的狀態。

## 掃描產物

`jedi-python-package/CLAUDE-SECURITY-20260906-064811/`（`.gitignore` 已就位，不進版控）：
`CLAUDE-SECURITY-RESULTS.md` / `.jsonl` / `.sarif` ＋ 版本戳記 `CLAUDE-SECURITY-REVISION-67cb075941bd.json`（`verification.status: verified`）。
