---
title: U1 檢查結果：權限檢查共用層＋共用小工具（全產品守門地基）
---

# U1 檢查結果：權限檢查共用層＋共用小工具（全產品守門地基）

> 檢查日期 2026-09-25｜對應卡片 CM-2153（母卡 CM-2152）｜檢查範圍 15 個檔案 1,132 行｜工具 run ID `wf_444cc623-9d2`

## 🔴 一句話結論

**地基是穩的。前面 40 棒一直假設「權限檢查共用層沒問題」，這次逐支查過，這個假設大致成立。** 範圍內找不到能讓人越權、跨客戶、或繞過商務授權的洞。卡片最擔心的三件事查核結果如下：

- **授權檔（`license.py`）在「讀不到照／過期／被竄改」時會擋下還是放行？** 會擋下。讀不到照就當成「什麼模組都沒買」，這個機制叫 fail-closed（出錯時預設拒絕）。過期的照只能讀、不能寫。被竄改的照在上傳那一刻驗簽章就被擋掉。快取只活在同一個請求之內，換一個請求、換一家客戶就重查，不會拿到上一家的結果。
- **決策表（`__init__.py`）的五支轉接檔有沒有指到舊地方？** 沒有。我逐一載入確認，五支全部指到身分套件 `jedi_iam.authz` 的正牌實作。四條舊的守門路徑也已經從程式庫裡刪光，沒有人在用。
- **簽章短期憑證的「`tenant_id=None` 換成最高管理員」到底是不是刻意設計？** 這個說法已經過時。那條路 9 月 14 日已經被拆掉（commit `9a0b4893`，CM-1787），目前出貨釘住的身分套件 1.3.0 已經包含這個修正。現在的行為是：憑證換回的是發憑證那個人自己的身分，查不到人一律拒絕，沒有任何一條路會把「缺值」推成「超級管理員」。

**掃描工具這一棒只報了一條：Redis 快取連線打開加密時不驗證對方憑證**（中等，三位檢查員 3:0 通過）。**這一條總表已經登記過了**（第 19 項，FR-085 C1-4，同一個檔、同一行），**本棒不另外計數**。

我依卡片逐支開檔，另外記下**三件低度或資訊級的觀察**，都不需要緊急處理：

1. **授權的「總開關」只是一個環境變數。** 把 `LICENSE_ENFORCEMENT_ENABLED` 改成 `false`，商務授權就整個熄火。這是刻意的上線保險絲；對能改部署設定的人來說，要不要把它視為風險是產品決策。
2. **到期狀態只靠每天凌晨 2 點的排程往前推。** 排程如果停了（例如服務只開即時通訊模式、或 license 模組沒掛上），過期的照會一直停在「正常」，不會轉成唯讀。停掉排程時 log 會記 ERROR，不是靜默失效。
3. **稽核輪次的「過階段就唯讀」檢查，本身不問「這一輪是不是你的專案」。** 目前唯一的呼叫端在同一支方法裡緊接著檢查了角色，所以沒有漏洞。但這支檢查的名字容易讓後來的人以為它包含歸屬檢查。

## 這一棒在檢查什麼

整個產品「誰能做什麼」的判斷，大部分都經過 `common/authz/` 這個目錄。後面每一棒的研究員都會追進來讀，但**只有這一棒會報它本身的問題**，所以它是地基。

這一層把權限檢查分成六「軸」，每一軸回答不同的問題：

| 軸 | 回答什麼 | 實作在哪 |
|---|---|---|
| ① 平台管理員 | 你是不是原廠總部的人 | 身分套件（本棒只看轉接） |
| ② 超級管理員 | 你的帳號有沒有被標成超級管理員 | 身分套件（本棒只看轉接） |
| ③ 專案角色 | 你在這個專案／SSP／流程裡是什麼角色 | 主專案 `project.py`／`ssp.py`／`workflow.py`（**不在本棒範圍**，別棒讀過） |
| ④ 功能權限 | 你的角色有沒有被授予這個功能點 | 身分套件（本棒只看轉接） |
| ⑤ 簽章短期憑證 | 瀏覽器直接開的網址（圖片、下載）帶的憑證是不是真的 | 身分套件（本棒只看轉接） |
| ⑥ 商務授權 | 你們公司有沒有買這個模組、照是不是過期了 | **主專案 `license.py`，本棒重點** |

範圍內 15 支檔案：

| 檔案 | 行數 | 角色 |
|------|-----:|------|
| `common/authz/license.py` | 367 | 第⑥軸：模組授權檢查、過期唯讀判斷、子公司數量上限 |
| `common/authz/__init__.py` | 180 | 決策表＋全站唯一的權限檢查匯入口 |
| `common/iam_ports.py` | 100 | 身分套件要的三個零件：讀設定、寄信、組驗證碼信 |
| `common/util/redis_client_util.py` | 79 | 連 Redis 快取的共用工具 |
| `common/authz/menu_license_filter.py` | 76 | 依授權把沒買的模組從側邊選單藏起來 |
| `common/util/common_util.py` | 65 | 雜項工具（台北時間等） |
| `common/util/ssp_resource_name_dup.py` | 56 | SSP 資源同名檢查 |
| `common/util/resource_path.py` | 50 | 打包後找隨版資料檔的路徑 |
| `common/util/round_guard.py` | 46 | 稽核輪次「過階段就唯讀」 |
| `common/authz/sharing.py` | 35 | 母公司分享資源給子公司時，「誰能改分享設定」 |
| `common/authz/capability.py`／`signed_token.py` | 22／22 | 轉接檔（→ 身分套件） |
| `common/authz/admin.py`／`decorators.py`／`platform.py` | 12／11／11 | 轉接檔（→ 身分套件） |

## 掃到什麼（總覽）

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 該補檢查的位置 | 嚴重度＋為什麼 | 來源 |
|---|---|---|---|---|---|---|
| F1 | Redis 快取的加密連線不檢查「對方是不是真的伺服器」 | 在網路中間攔截的人可以拿到 Redis 帳密，並偷看或竄改 AI 聊天紀錄、問卷共編名單 | ① 部署時把 `REDIS_SSL` 打開（預設是關的）② 攻擊者站在應用程式和 Redis 之間的網路上 | `common/util/redis_client_util.py:32`（改成強制驗證憑證並檢查主機名稱） | 🟡 中：要同時滿足兩個不常見的前提 | 工具，三位檢查員 3:0 通過；**＝總表第 19 項，不另計** |
| U1-1 | 商務授權的總開關只是一個環境變數 | 能改部署設定的人把它改成 `false`，所有模組都不再檢查有沒有買、過期也不再轉唯讀 | 能改伺服器上的 `.env` 或容器設定（等於已經是主機管理員） | `common/authz/license.py:177-179`（產品決策：要不要把這個開關納入防竄改檢查，或讓它的狀態回報給原廠） | ⚪ 資訊：這是刻意設計的上線保險絲，而且門檻是主機管理員權限；列出來讓決策者知道有這回事 | runner 自行開檔（未經三人面板） |
| U1-2 | 過期轉唯讀只靠每天一次的排程，排程停了照就不會過期 | 照已經過期，客戶照樣可以新增、修改資料，直到排程恢復 | 排程沒在跑。例如只開即時通訊模式的部署，或 license 模組沒掛上 | 讀照的時候順便用當下時間算一次狀態（`common/authz/license.py:280-283`），不要只信資料庫裡存的狀態 | 🟢 低：正常部署排程一定會跑；停掉時 log 有 ERROR，不是靜默失效；延遲最多一天 | runner 自行開檔（未經三人面板） |
| U1-3 | 「過階段就唯讀」這支檢查只看狀態，不看這一輪屬於誰 | 目前沒有後果。以後有人只呼叫這一支、以為它也檢查了歸屬，就會漏掉 | 目前沒有可打的路徑 | `common/util/round_guard.py:31`（在說明裡寫明「不含歸屬檢查，呼叫端要另外檢查角色」） | ⚪ 資訊：唯一的呼叫端緊接著就檢查了角色 | runner 自行開檔（未經三人面板） |

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 上表 F1（Redis 憑證）＝總表第 19 項，✅ 已修（FR-114.3-1／CM-2043，commit `d61d5342d`，主專案那份複製品已刪）。
> - U1-1（授權總開關）＝總表第 182 項，✅ 已修（CM-2205，commit `c8aa7d026`，1.21.0 出貨）。
> - U1-2（過期只靠排程）＝總表第 181 項，✅ 已修（CM-2217，commit `3ed47c7a9`，1.21.0 出貨）。
> - U1-3（round_guard 只看狀態）：資訊項，本來就只登記、不開卡，無修正卡。

## 工具報的逐條

### F1 — Redis 加密連線不驗證憑證（中等，3:0）

**這是什麼問題。** 部署方把 `REDIS_SSL` 打開，表示「連 Redis 要走加密連線」。但 `common/util/redis_client_util.py:32` 把 `ssl_cert_reqs` 寫死成 `False`，意思是「對方拿什麼憑證出來都接受」，也沒有檢查主機名稱。加密還是加密了，只是**不確定對面是誰**。

**出事會怎樣。** 在應用程式和 Redis 之間攔截的人可以冒充 Redis，應用程式會把 Redis 的帳號密碼送給他，之後讀寫的 AI 聊天紀錄（`core/plugins/ai_bot.py` 的 `RedisChatHistoryStore`）、問卷共編線上名單（`infra/survey/adapters.py` 的 `RedisPresenceStoreAdapter`）也都經過他。拿到帳密之後，他還能直接登入真正的 Redis。

**要先有什麼才打得到。**
- `REDIS_SSL=true`（`config/config.py:146` 預設是 `false`，出貨的落地版也沒打開）
- 攻擊者要在應用程式和 Redis 之間的網路上

**修法。** 打開加密時改成 `ssl_cert_reqs="required"` 加上 `ssl_check_hostname=True`，並提供自帶 CA 憑證的設定。**身分套件裡同名的那支已經修好了**（`jedi-iam/jedi_iam/common/utils/redis_client_util.py:44`），而且附了現成的測試，照抄就可以。

**和總表的關係。** 這條就是**總表第 19 項**（FR-085 C1-4，當時是研究員越界讀到，沒有經過面板）。這次是它第一次在自己的範圍內被正式掃到、並經過三人面板 3:0 確認。**不另外計數**，只是讓第 19 項多了一次面板背書。總表第 19 項另外提到的 `config/config.py:157` 的 `REDIS_URL` 不理會 `REDIS_SSL` 開關，不在本棒範圍，這次沒有重新核對。

**我自己開檔核對過**：`redis_client_util.py:25-34` 整段、兩個呼叫端、身分套件的對照組都看過，跟工具說的一致。

## 卡片點名的疑點逐條回答

### ① `license.py`：授權檔讀不到、過期、被竄改時是擋下還是放行？快取多久？換客戶會不會拿到上一家的結果？——✅ 擋下，不成立

逐種情況：

| 情況 | 結果 | 依據 |
|---|---|---|
| 這家客戶沒有照 | **擋下**。當成「什麼模組都沒買」，掛了授權檢查的端點一律 403 | `license.py:163-164` 回傳「無照」快照，`:245` 無照的模組清單是空的 |
| 請求裡沒有身分（還沒登入） | **擋下**，同上 | `license.py:221-226` 沒有 `tenant_id` 就回「無照」快照 |
| 子公司自己沒照、上層有照 | 用上層的照（刻意設計：一張照涵蓋整棵公司樹） | `license.py:161` 先換算成頂層持照公司，再查照 |
| 子公司讀上層的照，會不會被資料庫的隔離規則擋掉、變成「沒照」？ | 不會。隔離規則允許讀自己路徑上的祖先公司 | DEV 唯讀查 `tenant_licenses_tenant_isolation`：`tenant_id = ANY(路徑上每一段)`，涵蓋祖先 |
| 照已過期 | 依設定轉成寬限／唯讀／鎖定。唯讀和鎖定時，所有寫入請求都擋 | `license.py:69`、`:263-283`；全域寫入閘門 `common/middleware/license_readonly_mw.py:208` |
| 被原廠手動停權 | 唯讀（停權和過期是兩個獨立來源，取聯集） | `license.py:283` |
| 照被竄改 | 上傳那一刻驗簽章，改過的照進不來；上傳入口還順便跑一次防竄改檢查 | 套件 `license_verification_service.py:74-89`；接線 `core/plugins/license.py:339` |
| 快取活多久 | **只活在同一個請求之內**（存在 Flask 的 `g`，每個請求都是新的） | `license.py:217-228` |
| 換客戶會不會拿到上一家的結果 | 不會。每個請求重新查；背景工作每次都開新的 app context，也不會共用 | 同上；全專案查過 `set_user_context` 的呼叫點，沒有「請求中途換身分」的情況 |
| 超級管理員能不能繞過 | **不能**。只有原廠總部（root 公司）能豁免，這是刻意的：否則客戶把自己設成超級管理員就能不買照 | `license.py:19-22`、`:241` 只認 `viewer_is_platform_admin()` |

**子公司數量上限**（`assert_sub_tenant_quota`，`license.py:326-367`）：算的是整棵樹的子孫總數，不是直屬子公司數。算不出來時**擋下**，不放行（`:355-360`）。唯一呼叫端是身分套件開新公司的流程（`core/plugins/identity.py:329`），只在「建子公司」時檢查；建最上層公司要平台管理員（`jedi_iam/api/routes/tenant_route.py:73-74`）。

**附帶觀察（不成問題）**：「開新公司時要不要扣掉沒買的模組權限」這條路，無照時刻意放行（`core/plugins/identity.py:283`）。理由寫得很清楚：開通當下通常還沒照，這裡多給的權限在無照期間打不動任何一支端點。這是「給得寬、判得嚴」，**不是 fail-open 漏洞**。

→ 另外兩件相關觀察見 U1-1（總開關）、U1-2（排程）。

### ② `__init__.py` 決策表：五支轉接檔有沒有哪一支名字對了、實作卻指到舊路徑？——✅ 沒有，不成立

我在本機直接載入每一支轉接檔，印出每個名字實際來自哪個模組：

| 轉接檔 | 轉出的名字 | 實際來自 |
|---|---|---|
| `admin.py` | `require_super_admin`、`viewer_is_super_admin` | `jedi_iam.authz.admin` |
| `capability.py` | `CapabilityGuard`、`require_capability`、`set_guard_resolver`、`viewer_has_capability` | `jedi_iam.authz.capability` |
| `decorators.py` | `require_platform_admin_route`、`require_super_admin_route` | `jedi_iam.authz.decorators` |
| `platform.py` | `require_platform_admin`、`viewer_is_platform_admin` | `jedi_iam.authz.platform` |
| `signed_token.py` | `issue_signed_token`、`verify_signed_token`、`signed_token_required`、`signed_token_or_jwt` | `jedi_iam.authz.signed_token` |

`__init__.py` 自己轉出的 35 個名字也全部核對過：主體域（軸①②④⑤）全部來自 `jedi_iam.authz`，資源域（軸③）和商務授權（軸⑥）來自主專案自己的 `common.authz.*`，**跟決策表寫的分工一致**。

**舊路徑**：決策表說有四條舊路徑（`common/util/permission`、`common/util/participant_guard`、`common/middleware/permission/ssp_permission`、`common/util/workflow_project_guard`）改成了相容轉接。我查了：**四條都已經從程式庫刪掉**，全專案沒有任何一處還在引用它們。決策表第 3-6 行的這段描述已經過時，但不影響安全。

### ③ `sharing.py`：「分享給子公司」會不會被反過來用——子公司改到母公司的資源？——✅ 不會，兩道防線，不成立

分享的設計是：母公司把自己的範本標成「分享」（SHARED），子公司就看得到。問題是子公司看得到之後，能不能改。

**第一道：資料庫。** 我在 DEV 唯讀查了四張有「分享」的表（`module_frames`、`flow_templates`、`workflow_templates`、`detection_profiles`）的修改和刪除規則：**分享分支只加在「讀」的規則上**，改和刪的規則仍是 `app_tenant_allowed_for_session(tenant_id)`，意思是「資料要在我自己或我底下」。母公司的資料在子公司的上游，所以子公司**看得到、改不了**。migration 檔頭（`scripts/sql/2026-09-14-fr094-cm1790-shared-scope-subtree.sql`）也明寫了這個設計。

**第二道：程式。** `assert_scope_writable`（`sharing.py:25-35`）檢查「現在操作的公司」是不是「資源擁有的公司」，完全相等才准改分享設定；而且不准把任何東西改成「原廠公版」（SYSTEM）。

**要特別講清楚的一點**：這支檢查**只管「改分享設定」這件事**，不管「改內容」。四個呼叫端（`module_frame_service.py:112`、`module_frame_item_service.py:65`、`flow_template_app_service.py:89`、`core/plugins/detection.py:188`）都把它放在「分享設定有變」的 if 裡面。**總表已經登記過好幾條「只改內容、不改分享設定，就繞過這道檢查」**（第 120、134、137 項，以及第 67 項）。這些是**呼叫端放錯位置**，不是 `sharing.py` 本身寫錯，本棒不重複計數。

### ④ `round_guard.py`：只看狀態，還是也看「這一輪屬於你的專案」？——🟡 只看狀態，但目前沒有漏洞 → U1-3

`assert_round_phase`（`round_guard.py:31-46`）只看輪次的狀態，**不問這一輪屬於誰**。

目前主專案只有一個呼叫端：`app/flow_control/service/assessment_plan_app_service.py:207`。它在同一支方法裡**緊接著**呼叫 `self._check_auditor(rnd.project_id, curr_user_id)`（`:208`），檢查「你是不是這個專案的稽核員」。所以現在沒有漏洞。

套件 `jedi-compliance-audit` 有一份內容一模一樣的複製品（只有匯入路徑不同），給稽核結果和改善計畫用。那兩處的呼叫端不在本棒範圍，沒有核對。

**另外兩個小觀察**（不列條）：狀態值是空的（資料異常）時**放行**（`:42`）；輪次找不到時也直接放行，讓呼叫端去處理「找不到」。這兩個設計都有寫明理由，而目前的呼叫端在前面就先處理了「找不到」。

### ⑤ `redis_client_util.py`：鍵名有沒有帶客戶編號？跨客戶會不會讀到彼此的快取？——✅ 不會跨客戶（但有 F1）

這支工具本身不決定鍵名，是呼叫端決定的。我追了兩個呼叫端：

| 誰在用 | 鍵名長什麼樣 | 會不會跨客戶 |
|---|---|---|
| AI 聊天紀錄（`jedi-ai-bot` 套件） | `chat_history:{使用者編號}:{對話編號}` | 不會。使用者編號是伺服器從登入身分取的（`ai_bot_route.py:59` 的 `current_user_id()`），每個人全系統唯一 |
| 問卷共編線上名單（`jedi-survey` 套件） | `fill-survey:{房間編號}:users` | 鍵名本身不帶客戶編號，但房間編號是問卷的唯一編號，本來就不會撞。真正的問題是「任何人都能進任何房間」，**總表已記**（FR-109 V2 F7；線上名單用前端自填的名字，FR-115 W1-1） |

所以「鍵名沒帶客戶編號導致跨客戶讀到彼此快取」**不成立**。這支工具真正的問題是 F1（不驗證憑證）。

### ⑥ `signed_token.py` 的 `tenant_id=None` 換 super admin（S2 那棒 docstring 說刻意設計，但沒人驗過）——✅ 這條路已經被拆掉，不成立

**先講結論**：現在的程式碼裡，**沒有任何一條路會把 `tenant_id=None` 變成超級管理員**。

**這條路以前是怎麼走的。** 簽章短期憑證（例如 `<img src="...?st=xxx">`）只帶使用者編號，不帶公司。以前的做法是把公司留空，而資料庫那一層「公司是空的就當超級管理員」，於是**一張憑證就能換到全資料庫的可見範圍**。

**什麼時候拆掉的。** jedi 套件 commit `9a0b4893`（2026-09-14，CM-1787，「拔掉『tenant_id 空值＝超級管理員』」）。我確認過這個 commit 已經包含在主專案目前釘住的 `jedi-iam==1.3.0` 裡（1.3.0 的版號 commit `573e0b7d` 是它的後代）。

**現在怎麼走**（`jedi_iam/authz/signed_token.py:85-133`）：
1. 驗簽章、驗用途（scope）、驗是否過期（預設 120 秒）
2. 用憑證裡的使用者編號，開一個**只能查這一句、查完就關**的提權連線，把那個人查出來
3. 查不到人 → 401；資料庫還沒準備好 → 401；**不會退回「沒有公司」的身分**
4. 用那個人**自己的預設公司**組出完整身分（`tenant_id_override=None` 在這裡的意思是「不讓網址指定公司」，而不是「公司留空」）

**資料庫那一層也改了**（`jedi_common/session/database/db.py:188-218`）：「要不要繞過隔離」現在只看兩個明確訊號：系統排程身分，或身分本身就在原廠總部的路徑上。**完全沒有身分時，什麼都看不到**。

**S2 那棒 docstring 說的「刻意設計」**，指的是「憑證不帶公司、由伺服器查人決定」這個設計，不是「空值換超管」。後者已經被當成漏洞拔掉了。

**附帶一個小觀察（不列條）**：用簽章憑證進來時，不會檢查帳號是不是已經被停權。一般登入路徑會檢查（`jedi_iam/middleware/core.py:90`），憑證路徑直接用 `build_user_context` 組身分，沒有這一步。影響窗口只有憑證的 120 秒有效期，而發憑證本身需要當時有效的登入。這個在身分套件裡、不在本棒範圍，列給 FR-114 修正線參考。

### ⑦「有人守了一半」共通疑點

| 疑點樣式 | 本棒結果 |
|---|---|
| 只驗「你是誰」沒驗「這筆是不是你的」 | 🟡 **兩處屬於這種形狀，但不是本棒的漏洞**：`sharing.py` 只管分享設定（呼叫端放錯位置已登記在總表）；`round_guard.py` 只看狀態（唯一的呼叫端有補上，見 U1-3） |
| 列表有守、單筆沒守 | 不適用：這一層是被呼叫的零件，沒有列表／單筆端點 |
| 守門寫在 route 層，但有第二支路由沒掛 | 不在本棒範圍：要看每支 route 有沒有掛 `@require_license`，要逐支盤點。主專案有 69 處掛授權檢查、68 處掛功能權限，套件裡另有 35 支檔案掛授權檢查 |
| 守門條件用 `or` 串、其中一個恆真 | ❌ 不成立：`viewer_module_licensed` 的四個放行條件（總開關關閉／原廠總部／基礎設施類／照裡有）逐一看過，沒有恆真的 |
| 「查不到」跟「沒權限」混成同一個回應 | 刻意分開：沒買回 `LICENSE_403001`、過期唯讀回 `LICENSE_403002`，兩者出路不同（`license.py:263-268`） |
| 背景排程／系統身分假設呼叫者是自己人 | ❌ 不成立：到期排程用 `system_context()` 明確宣告要繞隔離，只更新狀態、不擋任何操作 |
| 防重放／計次只在單一進程內有效 | ❌ 不成立：快取是單一請求範圍；到期狀態轉換用資料庫的條件更新（`WHERE status = 原狀態`），多台機器同時跑也只會生效一次 |
| 註解寫「這裡刻意不檢查」但上一層沒檢查 | 🟡 **一處描述過時**：決策表說四條舊路徑「已改相容轉接」，實際已經刪光。不影響安全 |

### ⑧ 範圍內其他檔案通讀結果

| 檔案 | 結論 |
|---|---|
| `menu_license_filter.py` | 只是把沒買的模組從選單藏起來，**不是防線**。真正擋人的是 API 層的 `@require_license`。沒有掛功能權限的頁面一律保留，這是刻意的：藏錯了比露出來更糟，而且露出來也打不動 API |
| `iam_ports.py` | 三個零件：讀環境變數、非同步寄信、組驗證碼信的文字。寄信失敗只寫 log、不讓登入失敗（刻意取捨，有寫理由）。沒有安全問題 |
| `common_util.py` | 大部分是轉接到 `jedi_common`；留下的是台北時間工具和問卷選項文字組合。`domain_regex` 全專案沒有人用。沒有安全問題 |
| `ssp_resource_name_dup.py` | 純比對，由呼叫端傳入「同一份 SSP」的清單，本身不查資料庫。沒有安全問題 |
| `resource_path.py` | `GUIDANT_RESOURCE_ROOT` 環境變數可以換掉資料檔根目錄，**總表已記**（第 15 項），不另計 |

## 各條詳細

### U1-1 — 商務授權的總開關只是一個環境變數（資訊）

**這是什麼。** `license.py:177-179` 讀 `LICENSE_ENFORCEMENT_ENABLED`。只要是 `false`，模組授權、過期唯讀、子公司數量上限**全部放行**。第二個開關 `LICENSE_READONLY_GATE_ENABLED` 只關過期唯讀。

**為什麼存在。** 檔頭寫得很清楚：上線後如果誤擋了真實客戶，改一個環境變數就能整個熄火，不用回滾程式碼。安裝程式出貨時兩個都寫 `true`（`scripts/installer/install.sh:1420-1421`）。

**為什麼還是列出來。** 落地版是裝在客戶自己的機器上。能改 `.env` 的人（客戶的主機管理員），不必動任何程式碼、也不會觸發防竄改檢查，就能把授權整個關掉。防竄改（FR-064）驗的是程式檔，不驗環境變數；FR-064 的 design 也寫明「主機層審計是客戶責任範疇」。唯一留下的痕跡是系統診斷包會回報這兩個開關的狀態（`app/support/service/diag_bundle_app_service.py:209-215`）。

**要不要處理是產品決策**，不是程式錯誤。可以考慮的方向：開關關閉時在畫面或送回原廠的資料上留下明顯標記；或者落地版的關閉需要一張原廠簽發的解鎖憑證（FR-064 已經有「unlock」這種憑證類型）。

### U1-2 — 過期轉唯讀只靠每天一次的排程（低）

**這是什麼。** 照是不是過期、該不該唯讀，看的是資料庫裡存的 `status` 欄位（`license.py:283`）。這個欄位只有兩個時間點會更新：
1. **上傳照的那一刻**：用當下時間算一次（套件 `license_verification_service.py:192-198`）
2. **每天凌晨 2 點（UTC）的排程**（`core/scheduler.py:275-323`）

讀照的時候**不會**用當下時間重算。

**出事會怎樣。** 排程沒在跑的期間，過期的照會一直停在「正常」，客戶照常新增、修改資料。

**什麼情況下排程會沒在跑。**
- 服務只開即時通訊模式（`RUN_MODE=socketio`）：這個模式本來就不起排程（`main.py:142`），但正常部署會另外有一個 API 模式的服務在跑排程
- license 模組沒掛上：排程會跳過，但會記 **ERROR**（`core/scheduler.py:296-304`），不是靜默
- 排程本身出錯：記 `exception`

**為什麼是低。** 正常部署一定有排程在跑，延遲最多一天；停掉時 log 有 ERROR。**修法**是在讀照時用 `compute_target_status(now, expires_at, expiry_policy)` 當場算一次，取「存的狀態」和「當場算的狀態」中比較嚴格的那個。這個函式是純函式、不查資料庫，成本很低。

**附帶：改系統時間能不能延長照？** 上傳照時有「時鐘回撥」防護（用浮水印記住見過的最大簽發時間），但排程推算用的是機器當下時間，沒有對照浮水印。能改系統時間的人等同主機管理員，跟 U1-1 同一個門檻，一併列在這裡、不另列一條。

### U1-3 — 「過階段就唯讀」只看狀態、不看歸屬（資訊）

見上方 ④。建議在 `round_guard.py` 的說明裡加一句：「本函式只判斷階段，不判斷這一輪屬於誰；呼叫端必須另外檢查專案角色」。

## 可信度（分兩層看）

**第一層：工具正式報告（經過三人面板投票）**

- 驗證章 `verified`：1 條候選、3 票全數投出、1 條成立（3:0；一位評低、兩位評中，取中等）、零漏投、零中斷。
- 研究員 2 派出／2 交回（全範圍 1 位＋找寫死密碼的專掃 1 位），`failed` 0。
- 工具只報了 F1。**工具對「權限邏輯」這一層零候選，不代表這層乾淨**。這一層大部分是轉接和分工設計，真正的判斷要跨到身分套件、授權套件、資料庫規則才看得出來，工具的研究員在低 effort 下不會追那麼遠。

**第二層：runner 自行開檔核對（未經三人面板投票）**

- 範圍 15 支逐支通讀；卡片「重點看什麼」五條＋交辦時點名的 `signed_token` 疑點＋「有人守了一半」八種樣式逐條答完。
- 跨讀了範圍外的相關程式（只讀不報）：身分套件的 `signed_token.py`、`platform.py`、`middleware/context.py`、`middleware/core.py`、`tenant_route.py`、`tenant_provisioning_service.py`；授權套件 `jedi_license_runtime` 的讀照、驗章、到期狀態機、頂層公司解析、三張表的隔離規則；`jedi_common` 的 `session_scope`；主專案的全域唯讀閘門、到期排程、分享和輪次檢查的所有呼叫端、Redis 的兩個呼叫端。
- 用本機 Python 載入每一支轉接檔，印出實際來源模組（見 ②）。
- DEV 資料庫唯讀查證（2026-09-25 約 11:50 +08）：四張分享表與兩張授權表的修改／刪除隔離規則（`pg_policies`）、最上層公司清單。**全程只有 `SELECT`**。
- 用 git 確認 CM-1787 修正包含在主專案釘住的 `jedi-iam==1.3.0` 裡。
- **打折處**：
  - 本機讀到的 jedi 套件原始碼來自**修正線的 worktree**（`.claude/worktrees/jedi-wt-fix-security`），因為 BE 的虛擬環境是用開發模式指向那裡。版號是 1.3.0，和 `pyproject.toml` 釘的一致，但修正線可能已經有還沒發版的改動。對照 git 歷史，本報告引用的 `signed_token.py`、`platform.py` 跟 main checkout 內容一致；`jedi_common/db.py` 有差異，差的是一行已知的無用設定（總表第 20 項），不影響本報告結論。
  - U1-2 沒有實際停掉排程來驗證，是讀程式推論的。
  - 軸③（`project.py`／`ssp.py`／`workflow.py`）不在範圍內，本棒沒有逐行核對，只順帶看了 `workflow.py` 的「管理員或沒有身分直接放行」段落。

## 執行概況

| 項目 | 值 |
|---|---|
| 掃描目標 | BE repo `compliance-manager-be`，branch `feature/review` |
| 版本 | `3f6290400`（工作區有平行線未 commit 的改動，範圍 15 支檔案本身沒有改動） |
| 工具 | Claude Code 官方 `claude-security` plugin 0.11.0 |
| 參數 | mode `scan`／effort `low`／focus `attack-surface`／scope 15 檔 |
| run ID | `wf_444cc623-9d2` |
| 報告目錄 | `CLAUDE-SECURITY-20260925-032449/`（不入版控） |
| 耗時 | 約 17.5 分鐘（1,054 秒） |
| 研究員 | 2 派出／2 交回（全範圍 1＋找寫死密碼專掃 1），`failed` 0 |
| 候選／面板票 | 1／3（3:0） |
| 驗證章 | `verified` |
| 工具發現 | 1 條中等（＝總表第 19 項，不另計） |
| runner 自行發現 | 3 條（低 1、資訊 2），都沒有經過面板 |
| 淨新增 | 0 條中等以上；低／資訊 3 條，建議不開修正卡，只登記 |
