# S6 掃描結果：jedi-iam 租戶與組織單位

> **掃描日期**：2026-09-06 ~ 2026-09-07（重跑；首輪失敗記錄見文末附錄）
> **掃描版本**：jedi-python-package `feature/FR-075` @ `4984e1e7a5beb607bef22be14d64eae429d0db9b`（工作樹有未 commit 變更，stamp 標記 `-dirty`）
> **工具**：Claude Code `claude-security` plugin v0.10.2.3（`claude-security:scan` workflow）
> **範圍（指定）**：租戶（tenant）與組織單位（org_unit）模組 route→serializer→DTO→entity→app service→domain service→repository→model→mapper 全鏈路，外加 `root_admin_domain_service` 與 `tenant_provisioning` port，共 **47 個受版控檔案**（啟動前以 `git ls-files` 驗證，數字吻合：47）
> **effort**：`low`，**focus**：`attack-surface`
> **狀態**：✅ **流程完整跑完並經驗證面板**（`verification.status: verified`）——26 個 agent 全數回報、0 錯誤、0 空回，8 條去重後候選全部投完票，1 條存活
> **對應卡片**：CM-1555（母卡 CM-1546）

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

**1 條發現，MEDIUM，confidence HIGH：組織單位（部門）的「讀」端點完全沒有權限檢查。**

任何登入者——包含完全沒有任何權限的最低權限帳號——都能撈出整棵部門樹：部門名稱、描述、無界限的 `metadata_json`、階層路徑、連帶的租戶資料，以及建立者／修改者的登入帳號。系統自己的設定說這該是管理員專屬：`department.read` 這個能力點存在、只發給 `Administrator` 角色、前端 `department-manage` 頁面也要它——**但 API 從來沒檢查過**。

**沒有 CRITICAL、沒有 HIGH。** 這一棒問的核心問題（租戶歸屬怎麼被寫入、tenant path 怎麼組、org_unit 樹能不能被弄出迴圈或跨租戶掛載、root_admin 判定條件、tenant_provisioning 的權限與副作用）**都被實際檢查過了**，面板逐一討論後認為那些路徑上沒有可跨越信任邊界的漏洞——理由詳見下方「被面板刪掉的七條」，那一節本身比留下來的一條更有資訊量。

**與 S5 的關係**：這條和 S5 的 F1（角色讀取端點無守門）是同一個病灶在不同模組的複製——**寫有守門、讀沒有**。S5 洩漏的是「誰是管理員」，S6 洩漏的是「組織長什麼樣」。修法也相同：把 `@capability_required` 補到讀端點上。**建議兩張卡合併考慮，因為這很可能是全 jedi-iam 的系統性模式，值得一次盤查所有 route 檔的讀端點。**

## 掃描執行狀況

| 項目 | 數值 |
|---|---|
| 派出 agent | 26（研究員 2、面板 24） |
| 回報 agent | **26（100%，0 錯誤、0 空回、0 被砍）** |
| 研究員 | 2 名派出 / **2 名回報**（漏洞研究員 1、密鑰專項 1） |
| 候選發現 | 9 條 → 去重後 **8 條** |
| 面板票數 | **24 票（8 條 × 3 票），全部投完** |
| 未經審查的候選 | **0** |
| 存活 | **1 條（3/3 全票）** |
| 總 token | 3,324,940 |
| 總 tool call | 1,612 |
| 耗時 | 約 4 小時 27 分（15,998 秒） |

**模型設定**：主 session 與所有 agent 皆為 **Opus 5 (1M context)**。首輪用 Sonnet 5 時主研究員連續 6 次 stalled、零產出；改 1M 後**一輪跑完、零中斷、零重試**，與 S5 的結果一致。卡片 2026-09-06 修正段的判斷（研究員讀到 20 幾萬 token 撞 200K 上限後思考變慢、觸發 180 秒無進展判定）**再次被驗證**。

**面板表決明細**：

| 候選 | 票數 | 結果 |
|---|---|---|
| F1 org-unit 讀端點無 `department.read` 守門 | **3/3** | ✅ 存活 |
| F8 org-unit／tenant 列表端點無守門（與 F1 重疊）| 1/2 | ❌ 歸併入 F1 |
| tenant 讀端點無 `tenant.read` 守門 | 1/2 | ❌ 刪除（理由見下）|
| 平台管理員判定僅憑租戶歸屬 | 0/3 | ❌ 刪除 |
| `PUT /tenant/<uid>` 部分更新清空 `parent_id`（MEDIUM 版）| 0/3 | ❌ 刪除 |
| `PUT /tenant/<uid>` 部分更新清空 `parent_id`（LOW 版）| 0/3 | ❌ 刪除 |
| `PUT /org-unit/<uid>` 部分更新清空 `parent_id` | 1/2 | ❌ 刪除 |
| org_unit 建立時 `created_user` 未寫入 | 1/2 | ❌ 刪除 |
| `jedi-common/.env` 版控內含明文密碼 | 0/3 | ❌ 刪除 |

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

### F1（MEDIUM, confidence HIGH）— 組織單位讀取端點無權限守門

**面板**：3/3 全票 ｜ **CWE-862** ｜ `jedi-iam/jedi_iam/api/routes/org_unit_route.py:43`（另 `:25`、`:77` 同病）

> **核對結果：✅ 屬實。MEDIUM 這個級別我同意——沒有誇大（RLS 確實有界），也沒有低估（界限比工具說的更鬆，見第 4 點）。**
> 我把 route → serializer → DTO → service → RLS policy → seed 資料整條讀完確認的，其中 RLS policy 是連上 DEV 資料庫實際查出來的，不是照抄工具輸出。

**白話說明**：部門清單、部門選單、單筆部門這三個「讀」的 API，只要求「你登入了」，不要求「你有權限看部門」。系統自己明明定義了「看部門」這個權限、也明明只發給管理員，但 API 那一關沒接上。回傳的內容不只是部門名字——還有描述、自由格式的 metadata、階層路徑、所屬租戶的完整資料，以及誰建的、誰改的（登入帳號）。

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

1. **同一支檔案裡寫有守門、讀沒有** — 對比極明顯。`OrgUnitCreateRoute.post`（:59）掛 `@capability_required("department.create")`、`OrgUnitRoute.put`（:83）掛 `department.update`、`delete`（:95）掛 `department.delete`。而列表 `OrgUnitsRoute.post`（:33）、選單 `OrgUnitMenuRoute.get`（:22）、單筆 `OrgUnitRoute.get`（:74）**只有 `@auth_required`**。這是同一個檔案內的不一致，不像是刻意的政策。

2. **`@auth_required` 確實只是「登入了沒」** — 主專案 `core/iam_wiring.py:203` 把它接成 `lambda fn: jwt_required()(fn)`，純認證、無授權。

3. **權限點存在且只發給管理員** — 我連上 DEV 查了資料庫：`capabilities` 表確實有 `department.read`（id 19），`scripts/init/04-seed-core.sql:226` 定義它、`:490` 只發給 role_id=2（`Administrator`）。前端 `department-manage` 頁面（`/department/department-manage`）在 `route_capabilities` 裡要求 `department.read`（requirement=`ALL`）。**也就是說：畫面看得到需要管理員權限，但 API 直接打就不用。**

4. **RLS 有界，但比工具說的更鬆** — 這是我特別去查的一點。DEV 上 `org_units_select` policy 的實際內容是：
   `(COALESCE(current_setting('app.is_super_admin', true), 'f') = 't') OR app_tenant_allowed_for_session(tenant_id)`
   **只有租戶維度，完全沒有 `app.allowed_org_paths` 這一項**——即使 session 有設這個變數（`jedi-common/.../db.py:111-113`）。所以一個正常子租戶使用者看到的不是「自己那一支部門」，而是**整個租戶子樹的全部部門**。工具的描述在這點上是對的，值得記下來。

5. **`is_super_admin` 是看路徑深度不是看權限** — `jedi-common/.../db.py:102`：`is_super = (not tenant_id) or _session_paths_have_root(allowed_tenant_paths)`，而 `_session_paths_have_root`（:69-82）只判斷 path 是不是單層 `/N/`。**所以「租戶掛在根」等同「super admin」，與這個人是什麼角色無關。** seed 的 `Guidant.AI`（id 1，path `/1/`）就是根租戶，原廠 admin 掛在那裡；setup wizard 建客戶租戶時 `parent_id=SYSTEM_ROOT_TENANT_ID`（`app/setup/service/setup_wizard_service.py:244`），所以客戶帳號是兩層路徑、拿不到 super admin。**結論：一般客戶帳號的外洩範圍限於自己租戶子樹；但任何被放進根租戶的帳號（不論角色）讀得到全部署的部門樹。**

**攻擊價值**：這是組織地圖。搭配 S5 的 F1（角色矩陣＋成員名冊含 email／電話／職稱），攻擊者能拼出「這家公司有哪些部門、誰在哪個部門、誰是管理員、他的 email 是什麼」——釣魚與橫向移動的前置情報全齊。**單獨看是 MEDIUM，和 S5 F1 合看是同一條攻擊鏈的兩段。**

**建議修法**：
1. 把 `@capability_required("department.read")` 補到 `OrgUnitsRoute.post`（:34）、`OrgUnitMenuRoute.get`（:23）、`OrgUnitRoute.get`（:75），與寫端點一致。
2. **順手修一個契約缺口**：`OrgUnitRoute.get`（:77）是全檔唯一沒有 `.dump()` 過 serializer 就丟進 `c.reply` 的 handler——直接把 dataclass DTO 交給 Flask 的預設 JSON provider 序列化。**我逐欄比對過 `OrgUnitDTO` 與 `OrgUnitResponse`，目前欄位一致，所以今天沒有多洩漏任何欄位**；但這代表 response schema 在這支端點上不是契約，DTO 之後新增任何欄位都會自動外流，且日期格式與其他端點不一致（其他端點有 `format="%Y-%m-%d %H:%M:%S"`）。
3. **中期考慮**：`org_units_select` policy 補上 `app.allowed_org_paths` 條件，讓部門可見範圍符合 session 變數已經暗示的語意。這一項牽涉 RLS 異動，影響面較大，建議另案評估。

## 被面板刪掉的七條（這一節請不要跳過）

**面板刪掉的七條裡，有三條是「真的 bug、但不是資安問題」，值得另開非資安卡處理。** 這正是驗證面板的價值——把「理論上成立但前提湊不齊」的東西擋下來，同時保留了它們作為程式碼缺陷的事實。

| 候選 | 面板為什麼刪 | 我的看法 |
|---|---|---|
| **tenant 讀端點無 `tenant.read` 守門**（1/2）| 守門確實缺，但 `tenants` 的 RLS 把資料限縮在呼叫者自己的子樹內，而那份資料在登入時就已透過 `UserResponse.tenants` 給過使用者了 | **同意刪，但這是「同一個病灶」的另一個實例**。RLS 這次擋住了不代表守門不該補——它和 F1 應該一起修。註：F1 之所以沒被同樣理由刪掉，是因為部門資料**不是**登入時就給過的 |
| **平台管理員判定僅憑租戶歸屬**（0/3）| `is_super_admin` 就是用同一個判準設的，通過這個守門的人本來就已經有 RLS 全域 bypass；這是刻意的設計（CM-1459 收斂、`test_root_tenant_convergence.py` 鎖定），不是提權 | 同意刪。但**第 5 點核對的那個事實仍然重要**：根租戶成員身分等於 super admin、與角色無關，這是產品的設計選擇，部署時要知道 |
| **`PUT /tenant/<uid>` 清空 `parent_id`**（兩個版本，0/3 + 0/3）| 唯一入口掛 `@require_platform_admin_route`，那個人本來就能直接建根租戶；而且提權那一半根本不成立——真正決定權限的是 `path`，只有 `BEFORE INSERT` trigger 會維護它，清空 `parent_id` 之後 `path` 是舊的 | 同意刪。**但這是貨真價實的資料完整性 bug**（`infra/repository/tenant_repo_impl.py:44` 無條件寫入 + serializer 沒有 `load_default`）：部分更新會把子租戶的 `parent_id` 打成 NULL，留下 `parent_id` 與 `path` 互相矛盾的資料列。**建議開一張非資安的 bug 卡** |
| **`PUT /org-unit/<uid>` 清空 `parent_id`**（1/2）| 同上，掛 `department.update`，而且 RLS 效果是 fail-closed（部門 path 變短、可見範圍縮小），不是提權 | 同意刪。**同樣是真 bug**（`org_unit_repo_impl.py:52`），併入上一條一起修 |
| **org_unit 建立時 `created_user` 沒寫入**（1/2）| 資安框架被駁回：`api_logs` middleware 對每個請求都記錄操作者 login_name／nickname／IP／body，追溯得到 | 同意刪資安框架。**但程式碼缺陷是確認過的**：`app/service/org_unit_service.py:74-75` 連續兩行都寫 `entity.updated_user = user`，`created_user` 從未被設定，於是 `org_unit_repo_impl.py:34-35` 把兩個稽核欄位都寫成 NULL。**一行的 typo，順手修** |
| **`jedi-common/.env` 明文密碼**（0/3）| 檔案確實入版控，但值是 PostgreSQL 標準預設 `postgres`/`postgres`/`localhost`，沒有任何程式碼載入它，wheel 與 sdist 也沒打包進去 | 同意刪。真的沒有洩漏任何東西 |

## 這份清單的可信度

- **本輪掃描完整跑完並經驗證面板**：26 個 agent 全數回報、0 錯誤、0 空回、0 被 cap 截斷，8 條候選全部投完 24 票，`render_report.py` 蓋的 `verification.status: verified` 在這一輪**是有實質票數支撐的**——與首輪那個「0 候選、0 未審」的空殼 verified 完全不同。
- **F1 已由我自行開檔核對**，含連上 DEV 資料庫查 RLS policy 實際內容與 capability 種子資料，不是照抄工具輸出。
- **沒有執行任何程式碼**：所有結論都來自閱讀原始碼、seed SQL 與資料庫 schema。沒有實際發出攻擊請求驗證，也沒有跑任何測試。
- **範圍限制**：只涵蓋指定的 47 個檔案。`middleware/context.py`、`jedi-common` 的 `session_scope`、RLS policy 本身、以及主專案 `compliance-manager-be`，都只被當作旁證閱讀，**不是本輪的稽核目標**——那些地方沒有發現不代表乾淨。
- **原始產物**：`/Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/CLAUDE-SECURITY-20260906-115218/`（`CLAUDE-SECURITY-RESULTS.md` / `.jsonl` / `.sarif` / revision stamp `CLAUDE-SECURITY-REVISION-4984e1e7a5be-dirty.json`）。該目錄有自己的 `.gitignore` 排除版控。

## 附錄：首輪失敗記錄（2026-09-05 ~ 09-06，Sonnet 5）

保留供方法論參照，**其結論已被本輪重跑取代**。

首輪在 `67cb0759` 上以同樣的 47 檔範圍、同樣的 `effort: low` 執行，主研究員 `research:repository:all` **連續 6 次嘗試全部在「180 秒無 tool call 進展」判定下被砍**，重試間隔 4037s／11263s／3313s／2167s／4065s 不等，最終正式失敗，累計約 26,251 秒（7.3 小時）、664 次 tool call、約 119 萬 token，**零候選產出**。唯一跑完的是密鑰專項掃描（167 秒、0 發現）。因為 0 候選，面板從未執行（`panel_votes: 0`），而 `render_report.py` 仍機械蓋出 `verified`——**那是「0 候選、0 未審」滿足判定邏輯的技術結果，不代表 47 個檔案被檢查過**。首輪產物在 `CLAUDE-SECURITY-20260905-141821/`。

**死因與修法**：S4～S7 首輪全滅於同一原因——研究員繼承主 session 的 Sonnet 5（200K 上限），而 plugin 把研究員寫死成 `effort: xhigh` 並要求「讀完每個檔、追每個 caller」，40 檔以上的範圍讀到 20 幾萬 token 時每一步思考變慢，超過 180 秒門檻即被判 stalled 從零重派，永遠讀不完。S1～S3 能成功是因為範圍只有 8～34 檔。修法是**主 session 改用 Opus 5 (1M context)**，其他參數全部不動；S5 與本輪 S6 都一次跑完，驗證有效。
