我的授權狀態(License Status)¶
功能群:系統管理|三層 RBAC 權限模型與角色權限基調先讀 功能群總覽|發照端見 License 授權管理
事實基準:2026-08-10 從 FE / BE code 掃出(
config.tenant_licenses為 FR-062 新建表,schema 直接查 DEV DB 實表;本頁為唯讀詳情頁,唯一 API 為GET /license/status)
變更紀錄
| 日期 | FR | 內容 |
|---|---|---|
| 2026-08-10 | FR-062 | 初版:FR-062 License 控管機制上線(含 62.8/62.9/62.10 三輪追加) |
1. 功能描述¶
我的授權狀態頁是租戶側的唯讀授權詳情頁:告訴使用者「貴租戶買了哪些模組、授權何時到期、現在處在哪個階段」。本頁沒有任何寫入動作——唯一的按鈕是導向另一條 route 的「上傳新授權照」(Host 版限定,見 §1.1 #7)。
三件事讓這頁比「顯示一張表」複雜:
- 照綁頂層客戶租戶、效力涵蓋整棵子樹:子租戶登入看到的是上層那張照,並額外顯示一則「授權繼承自上層租戶」說明——與執法軸讀到的是同一張照,否則會出現「狀態頁說沒授權、功能卻能用」的錯亂。
- root tenant 是設計上永不持照的特權租戶:它顯示「本租戶為系統管理租戶,不受 License 授權管控」,而不是一般租戶的「尚未授權」空狀態——沒有照是「設計如此」不是「還沒買」。
- 人為停權與到期階段是兩個正交軸:被停權的照
status可能還是「正常」,故「現處階段」在停權時顯示「已停權」,並多出停權時間與原因兩格。
本頁本身只是靜態詳情頁,但它背後那張照驅動了全站行為:到期橫幅、寫入按鈕反灰、選單過濾、任務型態選擇器、角色權限矩陣的反灰項(見 §11)。
主要使用者:任何已登入使用者(一般租戶使用者)。BE 端點只掛 @jwt_required(),無角色守門也無 capability 綁定——這是刻意的:使用者必須能查自己的授權狀態,包含在無照或唯讀狀態下。
角色速覽:
| 角色 | 一句話 |
|---|---|
| 一般租戶使用者 | 本頁主要讀者——看自己(或上層)那張照的模組集、到期日、現處階段 |
| 子租戶使用者 | 看到的是上層持照租戶的照,並見「授權繼承自上層租戶」說明;續約要找上層管理員 |
| 平台管理員(root tenant) | 本頁對他顯示豁免說明;他實際使用的是 License 授權管理 |
1.1 功能總覽(本頁全部功能)¶
| # | 功能 | 說明 | 位置 | 詳述 |
|---|---|---|---|---|
| 1 | 現行照資訊顯示 | 六格:方案/授權對象/授權編號/到期日/照型態/部署形態 | 主畫面資訊格 | §6.2 |
| 2 | 現處階段 Tag | 到期管線階段(正常/即將到期/寬限期/唯讀/已鎖定);被停權時改顯示「已停權」 | 資訊格首格 | §4 |
| 3 | 停權說明與明細 | 被停權時:頂部紅色說明訊息 + 資訊格多出「停權時間」「停權原因」兩格 | 條件顯示 | UC-LS-01 |
| 4 | 已授權模組清單 | 照內 modules 值為 true 者,排序後以 Tag 逐項列出(顯示中文模組名,無對照譯文時顯示原 key) |
資訊格底部 | §6.2 |
| 5 | root 租戶豁免說明 | root tenant 顯示「本租戶為系統管理租戶,不受 License 授權管控,全功能可用」,不顯示照內容 | 互斥狀態訊息 | UC-LS-01 |
| 6 | 尚未授權空狀態 | 非 root 且無現行照時顯示「貴租戶尚未取得授權,請聯繫系統管理員」(HTTP 200,非拋錯) | 互斥狀態訊息 | UC-LS-01 |
| 7 | 授權繼承說明 | 照屬上層租戶時顯示「授權繼承自上層租戶『X』」(查不到名稱時走無名稱版文案);與照內容並存,不取代 | 條件顯示 | §12 坑 2 |
| 8 | 上傳新授權照入口 | 導向 /license/activation 開通頁;僅 Host 版顯示(deployment_mode === 'host'),且非 root、非繼承時才出現 |
頁首按鈕 | §12 坑 3 |
| 9 | 全域到期/停權橫幅(非本頁元素) | 掛在 AppLayout 頂端,任何登入頁面皆可見;停權文案優先於任何到期文案 | AppLayout | §11 |
| 10 | 唯讀 gate 連動(非本頁元素) | 唯讀/停權時全站寫入按鈕反灰+原因提示;BE 端 before_request 全域攔截 |
全站 | §11 |
UC(§2)展開 1(檢視授權狀態的完整分支判定)與 2(唯讀/停權連動全站行為)兩條——本頁功能極少且無寫入,其餘項目已於總覽表與 §6 描述完整。
2. Use Case¶
角色速覽:
| 角色 | 一句話 |
|---|---|
| 一般租戶使用者 | 本頁主要讀者——看自己(或上層)那張照的模組集、到期日、現處階段 |
| 子租戶使用者 | 看到的是上層持照租戶的照,並見「授權繼承自上層租戶」說明;續約要找上層管理員 |
| 平台管理員(root tenant) | 本頁對他顯示豁免說明;他實際使用的是 License 授權管理 |
流程圖視覺慣例:菱形 = 判斷、橘底 = 例外/擋下、紅框 = 錯誤(標 error code)、綠框 = 成功終點、虛線 = 可選路徑。
UC-LS-01 檢視自己租戶的授權狀態¶
| 項目 | 內容 |
|---|---|
| 角色 | 任何已登入使用者 |
| 前置條件 | 已登入(僅此而已——無照、唯讀、被停權狀態下都必須能開本頁) |
| 產出 / 後置條件 | 唯讀顯示;不寫入任何資料、不觸發任何背景行為 |
UC-LS-02 唯讀/停權狀態下的全站連動¶
| 項目 | 內容 |
|---|---|
| 角色 | 任何已登入使用者(唯讀狀態影響整個租戶樹) |
| 前置條件 | 該租戶現行照 status ∈ {readonly, locked} 或 suspended_at 有值;且執法總開關與唯讀 gate 子開關皆啟用 |
| 產出 / 後置條件 | 前端寫入按鈕反灰+原因提示、頂端顯示橫幅;BE 全域攔截寫入回 403 LICENSE_403002;/license/ 前綴與登入相關端點豁免(防死鎖) |
3. 權限矩陣¶
| 操作 | FE 判定 | BE 強制 | BE 檢查位置 |
|---|---|---|---|
| 選單 / 頁面可見性 | RBAC 選單(ui_routes.name='license-status',掛於 group-license 群組,sort=16) |
刻意不綁任何 capability——全租戶可見的自己狀態頁,維持 fail-open 可見 | scripts/sql/2026-08-10-fr062-8-license-menu-restructure.sql(該 migration 明載此決定);DEV 實查 route_capabilities 對 license-status 無列 |
| 讀自己的授權狀態 | 無額外前端判斷 | 僅 @jwt_required()——無角色、無 capability、無 license 軸守門 |
api/license/routes/license_route.py::LicenseMyStatusRoute.get |
| 讀機器碼/部署形態(本頁間接用) | 無 | 僅 @jwt_required();無照狀態也要能查(否則抄不到機器碼、開通不了) |
同檔 LicenseActivationMachineCodeRoute.get |
| 「上傳新授權照」按鈕顯示 | !is_root_tenant && !is_inherited && deploymentMode === 'host' |
不適用(僅導頁);開通端點本身另有守門,見開通頁 | LicenseStatus.vue:97-100 |
| 唯讀 gate 對本頁的影響 | 不適用(本頁無寫入) | 本頁與開通頁所在的 /license/ 前綴整段豁免唯讀 gate |
common/middleware/license_readonly_mw.py:55-73 |
「僅 JWT」在此是正確設計而非缺口:把自己的授權狀態鎖在權限後面,會讓最需要看到它的情境(無照、已到期、被停權)反而看不到。本頁完全唯讀、且只回自己租戶樹那張照(RLS + 頂層解析雙重限定),不存在越權讀取他人授權的路徑。
4. 狀態機與前置條件¶
本頁顯示的「現處階段」即照的到期管線狀態,由每日排程(02:00 UTC)推進,定義見 License 授權管理 §4。本頁的顯示對照:
| status | 畫面文字 | Tag 色 | 使用者感受 |
|---|---|---|---|
valid |
正常 | 綠 | 全功能 |
notice |
即將到期 | 黃 | 全功能+頂端提醒橫幅 |
grace |
寬限期 | 黃 | 全功能+醒目警告橫幅 |
readonly |
唯讀 | 紅 | 可登入/可看/可下載匯出,全部寫入被擋(出廠預設為終態) |
locked |
已鎖定 | 紅 | 同上(出廠預設 lockout 停用,實務不會出現) |
(suspended_at 有值) |
已停權 | 紅 | 覆蓋上列任一階段的顯示——使用者實際受到的限制來自停權,不是 status |
本頁的顯示分支前置條件:
| 條件 | 效果 | 出處 |
|---|---|---|
is_root_tenant 為 true |
顯示豁免說明,不顯示照內容(root 永不持照) | api/license/routes/license_route.py:58(viewer_is_platform_admin());LicenseStatus.vue:144-146 |
| 非 root 且解析後的持照租戶無現行照 | 顯示「尚未授權」警告;HTTP 仍為 200、data 只有旗標欄位 |
app/license/service/license_status_service.py:19-35;LicenseStatus.vue:148-150 |
is_inherited 為 true(持照租戶不是自己) |
加一則「授權繼承自上層租戶」說明;刻意放在互斥鏈之外,與照內容並存 | LicenseStatus.vue:152-162 |
| 拿不到持照租戶名稱(RLS 擋祖先查詢) | 走無名稱版文案「本租戶的授權繼承自上層租戶…」 | LicenseService/LicenseStatus.vue:103-109;名稱查詢走 resolver 的繞 RLS 查詢 |
suspended_at 有值 |
頂部紅色停權說明 +「現處階段」顯示「已停權」+ 多出停權時間/原因兩格;同樣與照內容並存 | LicenseStatus.vue:164-200 |
deployment_mode(站台,非照上的欄位)≠ host |
不顯示「上傳新授權照」按鈕(SaaS 客戶沒有自行送照的管道,顯示等於指向死路) | licenseStore.showActivationEntry;LicenseStatus.vue:97-100 |
5. UI 設計¶
📸 實際畫面截圖待補(一律亮色模式拍攝),預計補以下四張到
assets/img/: ① 正常狀態整頁(資訊格六格+模組 Tag 清單)license-status-valid.png② 尚未授權空狀態license-status-no-license.png③ 被停權狀態(紅色說明+停權時間/原因兩格)license-status-suspended.png④ 到期橫幅(AppLayout 頂端,notice 黃/readonly 紅)license-status-banner.png
版面骨架(單欄唯讀詳情頁:Header + 互斥狀態訊息區 + 授權資訊格;無表單、無 Tab、無分頁):
狀態呈現:
- 載入中顯示置中的
ProgressSpinner(整頁替換,不用骨架屏)。 - 三則互斥訊息(root 豁免/尚未授權/正常顯示照內容)走
v-if / v-else-if鏈;兩則補充說明(繼承、停權)刻意放在鏈之外——接進鏈裡會把照內容整段吃掉,變成「有授權卻看不到授權內容」。 - 已授權模組以
Tag severity="info"逐項列出,label 走lang.license_module_names.*三層 fallback(找不到譯文即顯示原resource_typekey);空清單顯示-。 - 本頁不吃全域 store 快取,
onMounted自行重打GET /license/status,故資料一定是當下的(橫幅那份才是 TTL 快取)。 - 無 socket / 無輪詢。
6. API 規格¶
Envelope:成功 {"status": true, "data": …};失敗經 jedi_common.handler.handler.register_error_handlers 轉為 {"error_code": "…", "msg": "…"}。
6.1 總清單(本頁涉及的全部 endpoint)¶
| 分類 | Method + Path | 說明 | 完整規格 |
|---|---|---|---|
| 狀態 | GET /license/status |
查自己(實為頂層持照租戶)的現行照狀態——本頁唯一直接呼叫的 API | §6.2 |
| 站台資訊 | GET /license/activation/machine-code |
本機機器碼與站台部署形態;本頁透過 licenseStore.fetchDeploymentMode() 間接呼叫,只取 deployment_mode 決定續約按鈕是否顯示 |
§6.3 |
開通相關的寫入端點(
POST /license/activation/upload/online)屬開通頁(/license/activation,另一條 route),本 spec 不展開。
6.2 狀態查詢¶
[GET] /license/status¶
無 query 參數。Response data(TenantLicenseResponse + 三個附加旗標;無現行照時仍回 200,data 只有旗標欄位、照欄位缺席):
| 欄位 | 型別 | 說明 |
|---|---|---|
| uid | string | 照的對外識別碼(FE 以「有無 uid」判斷是否有照) |
| tenant_id | integer | 持照租戶內部 id |
| license_id | string|null | 照內編號(畫面「授權編號」) |
| customer_code / order_no | string|null | 客戶代碼/訂單編號(畫面未顯示) |
| issued_to | string|null | 授權對象 |
| license_type | string|null | 照型態(formal/trial/extension → 正式版/試用版/展延) |
| issuer | string|null | 簽發者(畫面未顯示) |
| issued_at / starts_at / expires_at | datetime|null | 簽發/生效/到期時間(畫面只顯示到期日) |
| expiry_policy | object|null | 到期管線四段組態;橫幅的 show_banner 逐段開關讀這裡 |
| modules | object|null | 模組集(key=capability 的 resource_type,值 truthy 才算有);畫面只列 true 的項 |
| plan | string|null | 方案代碼 |
| limits | object|null | 額度(目前只有 max_sub_tenants;畫面未顯示) |
| deployment_mode | string|null | 照上寫的部署形態(≠ 站台的 DEPLOYMENT_MODE,見 §12 坑 3) |
| machine_fingerprint | string|null | 機器指紋(Host 版綁機器;畫面未顯示) |
| kid | string|null | 簽章金鑰 id(畫面未顯示) |
| status | string | 到期管線階段 |
| is_current | boolean | 恆為 true(只回現行照) |
| suspended_at | datetime|null | 人為停權時間戳——與 status 正交,FE 橫幅/狀態卡片必須分辨「被停權」與「自然到期唯讀」(兩者出路不同:前者聯繫系統管理員,後者續約換照) |
| suspend_reason | string|null | 停權原因(由平台管理員在後台填寫,是客戶唯一看得到的說明) |
| created_user / created_at / updated_at | string|datetime | 審計欄(畫面未顯示) |
| is_root_tenant | boolean | 附加旗標:呼叫者是否為 root tenant(豁免說明用);有照/無照兩案都會附 |
| is_inherited | boolean | 附加旗標:本照是否繼承自上層租戶 |
| license_tenant_name | string|null | 持照租戶名(僅 is_inherited 時有值;RLS 擋祖先查詢時可能為 null,FE 有無名稱兩版文案) |
| licensed_job_types | string[] | 附加旗標:當前租戶可用的任務型態(general/survey/detection_tool)——供 FE 任務類型選擇器隱藏未授權項;由 BE licensed_job_types() 算,與 API 守門同一支函式,FE 不自己拿 modules 兜第二套對應表 |
license_raw 絕不回傳(照原文含簽章,非顯示用途;需要重驗證走 BE 內部驗章流程)。
讀的是頂層持照租戶的照,不是當前租戶自己的:LicenseTenantResolver.resolve() 讀 tenants.path 取第二段解析出頂層客戶租戶(照綁頂層、效力涵蓋全樹),與執法軸讀到的是完全同一張照——否則會出現「狀態頁說沒授權、功能卻能用」的錯亂。
出處:api/license/serializers/license.py:4-76(TenantLicenseResponse + dump_my_status)、app/license/service/license_status_service.py、api/license/routes/license_route.py:42-62。
6.3 站台部署形態¶
[GET] /license/activation/machine-code¶
無 query。Response data:
| 欄位 | 型別 | 說明 |
|---|---|---|
| deployment_mode | string | 站台的 DEPLOYMENT_MODE 設定值(saas/host)——這是「這台伺服器是落地版還是雲端版」,與照上的 deployment_mode 不同 |
| machine_fingerprint | string|null | 本機機器碼;SaaS 版恆為 null(不綁機器) |
本頁只取 deployment_mode(決定續約按鈕是否顯示),機器碼是開通頁在用。此端點豁免於唯讀 gate 與 license 軸執法(/license/ 前綴),任何登入使用者皆可查——無照狀態也必須看得到,否則抄不到機器碼、開不了通。出處:api/license/routes/license_route.py:65-82。
7. 前端檔案地圖(compliance-manager-fe/)¶
| 檔案 | 角色 |
|---|---|
src/views/license/LicenseStatus.vue |
本頁唯一檢視檔(248 行;純顯示,無子元件、無表單) |
src/config/router/index.js:632-639 |
route license-status,path /license/status |
src/stores/licenseStore.js |
全站授權狀態快取(Pinia):橫幅顯示判定 showBanner、停權判定 isSuspended、任務型態過濾 jobTypeAllowed、開通入口判定 showActivationEntry;TTL 5 分鐘+路由切換觸發重抓 |
src/composables/useLicenseReadonly.js |
唯讀狀態的寫入 UI 控制(isReadonly / readonlyReason / writeDisabled());全 FE 共 76 個檔案引用 |
src/components/license/LicenseExpiryBanner.vue |
全域到期/停權橫幅(掛在 src/layout/AppLayout.vue:111) |
src/views/license/LicenseActivation.vue |
開通頁(/license/activation,本頁「上傳新授權照」的導向目標,本 spec 不展開) |
src/views/auth/Login.vue:160-172 |
登入成功後偵測無照(非 root 且無 uid)→ 導向開通頁 |
src/service/LicenseService.js |
getMyStatus() / getActivationMachineCode() 兩支供本頁使用 |
src/config/locales/i18n/{zh-tw,en}/license-manage.json |
本頁 i18n 在 lang.license_status.*(與授權管理頁同檔);模組中文名在 lang.license_module_names.*(27 項) |
8. 後端檔案地圖(本頁核心鏈路)¶
| 鏈路 | Route | App Service | 底層 |
|---|---|---|---|
| 查自己的授權狀態 | api/license/routes/license_route.py::LicenseMyStatusRoute(GET) |
app/license/service/license_status_service.py::LicenseStatusService.get_my_license_status(@transaction) |
infra/license/license_tenant_resolver.py::LicenseTenantResolver.resolve(繞 RLS 讀 tenants.path)→ TenantLicenseDomainService.get_current_by_tenant → TenantLicenseRepoImpl(繼承 BaseRepositoryImpl) |
| 附加旗標組裝 | 同上(route 層呼 viewer_is_platform_admin() 與 licensed_job_types()) |
— | common/authz/platform.py、common/enum/grc_job_type_enum.py:25-40 |
| 站台部署形態 | LicenseActivationMachineCodeRoute(GET) |
無(route 直接讀 current_app.config + 呼 compute_machine_fingerprint()) |
common/license/machine_fingerprint.py |
| 唯讀 gate(影響全站,非本頁端點) | before_request hook |
— | common/middleware/license_readonly_mw.py → common/authz/license.py::viewer_is_readonly |
DDD 提醒:LicenseMyStatusRoute.get 在 route 層呼叫了 viewer_is_platform_admin() 與 licensed_job_types() 兩支純 context 判定函式——這兩支不碰 DB、不開 session(前者讀 allowed_tenant_paths,後者讀 per-request 的 license 快照),屬「主體域守門」允許在 route 層表達的範疇(CLAUDE.md「授權守門一律用 common/authz/」雙軌放置規則),不違反「route 層不做 DB 查詢」。
9. DB¶
9.1 資料表總清單(本頁讀寫的全部表)¶
| 表 | 讀/寫 | 說明 | 欄位詳述 |
|---|---|---|---|
| config.tenant_licenses | 讀 | 現行照(is_current=TRUE)——本頁唯一資料來源 |
License 授權管理 §9.3 |
| public.tenants | 讀 | path 解析頂層持照租戶+取持照租戶名稱(皆繞 RLS,見下) |
GAI-SD-03 |
本頁零寫入——不新增、不更新、不刪除任何列,也不寫稽核事件。
9.2 ER 圖(本頁讀寫範圍)¶
本頁讀取範圍是 License 授權管理 §9.2 ER 圖 的唯讀子集(tenant_licenses + tenants),不另繪一張。
9.3 核心表欄位¶
欄位表見 License 授權管理 §9.3(config.tenant_licenses)。本頁特別相關的兩點:
- RLS 與繞 RLS 的分工:
config.tenant_licenses的 policytenant_licenses_tenant_isolation允許app.is_super_admin或tenant_id落在app.allowed_tenant_paths內。子租戶讀上層的照時,該 policy 本身擋不住(tenant_id 不在自己的 path 內),故解析與名稱查詢都走LicenseTenantResolver的獨立 session +SET LOCAL app.is_super_admin樣板。 - 為什麼讀
path不走parent_id鏈:tenants的 RLS SELECT policy 是前綴比對——租戶看得到自己與子孫、看不到祖先(/1/102/不以/1/102/152/起頭),沿parent_id往上走第一步就查無。而path本身就是完整祖先鏈且自己那列一定看得到,一次查詢即可解析。
10. 頁面邏輯與資料對應¶
載入時序:onMounted 平行做兩件事——① fetchStatus() 打 GET /license/status 填入 license(本頁自己重打,不吃 store 快取);② licenseStore.fetchDeploymentMode() 取站台部署形態(已抓過就不重打,進行中共用同一個 promise)。
關鍵欄位對應(API ↔ 畫面):
| 畫面元素 | FE state | API 欄位 |
|---|---|---|
| 是否顯示豁免說明 | license.is_root_tenant |
data.is_root_tenant |
| 是否顯示「尚未授權」 | !license?.uid(且非 root) |
data.uid 缺席 |
| 現處階段 Tag | isSuspended ? '已停權' : t('lang.license_manage.status_' + status) |
data.suspended_at / data.status |
| 停權時間 / 停權原因兩格 | 僅 isSuspended 時渲染 |
data.suspended_at / data.suspend_reason |
| 方案/授權對象/授權編號/到期日 | 直接綁定 | data.plan / issued_to / license_id / expires_at |
| 照型態/部署形態 | typeLabel() / deploymentLabel()(i18n 三層 fallback) |
data.license_type / data.deployment_mode |
| 已授權模組 Tag 清單 | moduleList computed——取 modules 中值為 true 的 key、排序、對照 lang.license_module_names.* |
data.modules |
| 繼承說明文案 | inheritedNotice computed(有名稱/無名稱兩版) |
data.is_inherited / data.license_tenant_name |
| 「上傳新授權照」按鈕 | showRenewAction computed |
data.is_root_tenant、data.is_inherited、licenseStore.deploymentMode |
橫幅與唯讀反灰的資料來源不同:本頁讀的是自己那趟 API 回應;橫幅與全站反灰讀的是 licenseStore(快取),兩者可能短暫不同步(見 §12 坑 4)。
錯誤對應:fetchStatus 失敗只 showError(e) toast,不擋頁面渲染。licenseStore.fetchStatus 的失敗處理更保守——狀態歸 null、licensedJobTypes 維持 null(=不過濾)、suspendedAt 歸 null(fail-open:一次查詢失敗不該讓整站誤判成被停權)。
11. 背景行為與外部依賴¶
| 類型 | 內容 |
|---|---|
| 通知 | 本頁不觸發通知。到期通知信由每日排程的狀態轉換觸發(LicenseExpiryNotificationService,notice/grace/readonly 三段各依照內 send_email 旗標寄給該租戶管理員),寄信冪等性來自 domain 層 transition_status 的 CAS |
| 排程 | core/scheduler.py 每日 02:00 UTC license_expiry_state_machine —— 本頁顯示的「現處階段」就是這支排程推進的結果;被人為停權的照整列跳過 |
| Socket | 無 socket / 無輪詢 |
| 狀態快取 | licenseStore 5 分鐘 TTL + 路由切換 afterEach 觸發重抓(refreshIfStale())。兩者合用的理由:只做 TTL 沒有東西會去踩它(橫幅掛在 AppLayout 只 mount 一次,SPA 內切頁不重新掛載);只看切頁則每切一頁打一發 API。故平台管理員停權後,客戶端最多延遲一個 TTL + 一次切頁才反映(登出/未登入態的路由跳轉刻意跳過重抓,避免拿註銷過的 token 打 API) |
| 全站連動(本頁那張照驅動的) | ① 選單過濾:common/authz/menu_license_filter.py 砍掉能力點全落在未授權模組的葉節點(capabilities 為空的節點 fail-open 保留);② 模組守門:@require_license(resource_type) 軸⑥,未授權回 403 LICENSE_403001;③ 唯讀 gate:before_request 全域攔寫入回 403 LICENSE_403002;④ 任務型態選擇器:吃 licensed_job_types;⑤ 角色權限矩陣:未授權模組的能力點反灰+原因(GET /ui-routes 附 is_licensed),寫入端另擋 403 LICENSE_403004;⑥ 子租戶額度:開子租戶時檢查 limits.max_sub_tenants(未帶時預設 5),超過回 403 LICENSE_403003 |
| jedi-* 套件 | jedi-common(BaseRepositoryImpl / @transaction / RLS session / 錯誤 handler);本頁不依賴 jedi-auth |
| 系統參數 | DEPLOYMENT_MODE(決定續約按鈕與機器碼)、LICENSE_ENFORCEMENT_ENABLED(執法總開關,關閉時模組守門與唯讀 gate 全放行)、LICENSE_READONLY_GATE_ENABLED(唯讀 gate 子開關,只關「過期後全域擋寫入」保留模組守門;總開關關閉時本開關無意義) |
12. 邊界情況與已知坑¶
- 停權與到期是兩個正交軸,五處判斷都必須取聯集(CM-1173/CM-1174):被停權的照
status可能還是valid——只看 status 會整個漏判。本頁、橫幅(LicenseExpiryBanner.vue)、前端反灰(useLicenseReadonly.js)、BE 執法(viewer_is_readonly())、後台總覽 Tag 五處各自都要判兩軸。文案也要分開:停權的出路是「聯繫系統管理員」,到期的出路是「續約換照」,套錯會講出與事實相反的話(曾出現「卡片顯示唯讀、橫幅卻說即將於 X 日到期請續約」)。 - 繼承說明與停權說明刻意放在
v-else-if鏈之外:底下的模組集/到期日就是那張照的內容,要與說明並存;接進互斥鏈裡會把照內容整段吃掉,變成「有授權卻看不到授權內容」。改動這段模板時容易手滑合併回去。 - 照上的
deployment_mode與站台的DEPLOYMENT_MODE是兩回事,別拿錯(CM-1172):續約按鈕的判斷依據必須是站台的部署形態(走GET /license/activation/machine-code),不是照上的deployment_mode——後者在無照時根本不存在,拿來判斷會在最需要判斷的無照情境失效。SaaS 版一律不顯示續約入口:送照的唯一管道是平台後台指派(客戶零動作),開通頁對 SaaS 客戶是死路(點進去只有「請聯繫系統管理員」提示)。 - 本頁與橫幅的資料來源不同,可能短暫不一致:本頁
onMounted自行重打 API(一定是當下值),橫幅與全站反灰讀licenseStore的 5 分鐘 TTL 快取。剛被停權時可能出現「本頁已顯示已停權、橫幅還沒出現」的短暫落差,切一次頁或等 TTL 過即一致。這是刻意取捨(停權不是秒級生效的需求),不是 bug。 - 未授權模組是隱藏、唯讀是反灰——兩種相反的處理,別統一:未授權是常態(沒買就是沒有這個功能),隱藏才符合認知;唯讀是異常態(買了但過期),把按鈕藏掉會讓使用者以為功能不存在/系統壞了,反灰+原因提示才傳達「功能還在,只是現在不能用」。唯一的例外是角色權限矩陣——那裡管理員是在配置權限而非使用功能,未授權項採反灰+原因(業界 show-and-disable 模式)。此例外已在程式碼註解寫明,避免日後有人「照原則修正」改回隱藏。
- 前端反灰不是安全機制:
useLicenseReadonly只是體驗優化(讓使用者在點下去之前就知道)。真正的擋在 BEbefore_request唯讀 gate,繞過前端直打 API 一樣 403。 - 唯讀 gate 不能只看 HTTP method:本專案有大量「POST 形狀的讀」(分頁列表查詢一律 POST 帶 filters/pager,約 70 支)。gate 採兩段判定——PUT/PATCH/DELETE 一律攔;POST 預設視為寫,只有登記在讀取白名單的才放行。白名單逐支列舉而非用路徑形狀推斷(
/detection-tool-profiles、/remote-agents、/flow-engine/flow-templates等都是複數名詞卻做寫入)。新增讀取型 POST 端點時要記得補進白名單,否則唯讀租戶會看不到該清單。 - 無照與唯讀是兩種不同的「不能用」:無照=所有模組皆未授權(fail-closed),由模組守門擋,碼
LICENSE_403001(出路:買);唯讀=有照但到期,由唯讀 gate 擋,碼LICENSE_403002(出路:換照)。viewer_is_readonly()對無照明確回 False,兩者不混。 - root tenant 沒有照不是異常:
is_root_tenant旗標存在的理由就是讓 FE 分辨「設計上永不發照」與「還沒買」。任何 license 執法路徑第一步也都先過viewer_is_platform_admin()豁免——沒有這個分支的話,平台管理員會被自己的執法鎖在門外。 super_admin不是 license 的 break-glass:軸④capability 有帳號層 super admin 旁路,軸⑥license 沒有——子租戶的 super admin 若能旁路,等於任何客戶自己把帳號設成 super admin 就繞過商務授權。license 只認 root tenant。- 模組名 i18n 有缺口就顯示原 key:
lang.license_module_names.*目前 27 項,若照內出現未收錄的resource_type(例如新增模組粒度後未同步 i18n),畫面會直接顯示英文 key。屬顯示層缺口,不影響授權判定。
13. 開發與驗證¶
- 跑起來:BE
python main_app.py(port 8000,log 在log/app.log);FE 在compliance-manager-fe/起 Vite dev server。BE 改 service code 後必須重啟(無 hot reload) - 測試帳號:dev 環境一般租戶帳號(密碼見
.env/ 部署文件);root 帳號登入只會看到豁免說明,驗不到主要顯示邏輯 - 導航路徑:登入 → 側邊選單最下方「授權管理」群組 → 「我的授權狀態」(
license-status,path/license/status,掛於group-license,群組sort=90) - 前置資料:該租戶樹的頂層租戶需有一張現行照(由平台管理員在 License 授權管理 發)。驗各分支的做法:尚未授權=找沒發過照的頂層租戶;繼承=用子租戶帳號登入;停權=在後台按「立即停權」後回本頁重整;到期各階段=發一張短效照或直接改 DB
expires_at(只動 DEV) - 驗唯讀連動:停權或讓照進
readonly後,隨便開一個有寫入按鈕的頁面確認反灰+tooltip,再用瀏覽器開發工具直打一支寫入 API 確認回 403LICENSE_403002;同時確認/license/前綴仍可用(防死鎖) - 排查「執法沒生效」:先查
LICENSE_ENFORCEMENT_ENABLED與LICENSE_READONLY_GATE_ENABLED兩個環境變數,總開關關閉時模組守門與唯讀 gate 全部放行、畫面仍會顯示「已停權」 - 測試政策:碎片 case 預設不寫新 unit test(見 CLAUDE.md 測試政策);本模組既有測試以
license搜尋test/ - E2E:
compliance-manager-test/repo(Cucumber + Playwright);本頁相關 feature 檔以license/授權狀態搜尋 - 相關文件:全案設計
docs/features/FR-062-2608-license-management/design.md(§4.5 狀態機/§4.6 執法設計/§4.7 開通流程/§6 模組分層與 Plan);發照端見 License 授權管理;權限 SOP 見 _overview §2