---
title: "License 控管機制 — 設計文件 (FR-062)"
brand: "Guidant AI · **FR-062** License 控管機制"
eyebrow: "FR-062 · License Management — 設計定稿 · 2026-08-08"
h1: "License 控管機制 — 設計定稿"
lede: "公司內部自建簽發端（Ed25519 離線簽章）、產品端驗章執法、按功能模組授權、可組態到期管線、序號開通與機器指紋綁定。D1–D15 全數定案（含 discussion v2 後的最終修正：**過渡照機制刪除**、**簽發端定案自建**、**Plan 為簽發端 DB 資料**、**升級換發立即生效**）。本文件含完整詳細設計與八階段拆分表（FR-062.1–.8 × 28 子任務，含驗收回饋期追加），供 Notion 開卡派工。"
chips: [
  {text: "設計定稿 · D1–D15 全數定案", kind: ok},
  {text: "照型態只有 formal / trial / extension", kind: accent},
  {text: "簽發端自建（不採 Keygen）", kind: accent},
  {text: "8 子需求 × 28 子任務", kind: plain}
]
footer: "FR-062 · License 控管機制 — 設計定稿 · 2026-08-08 v1 · 上游：discussion.md v2 ＋ 決策者最終修正五項"
---

> 狀態：**設計定稿**｜建立日期：2026-08-08｜討論稿：[`discussion.html`](./discussion.html)（v2，含市場調查與完整推理過程）

## 變更紀錄 {#changelog nav="變更紀錄"}

| 日期 | 變更 | 對應 |
|------|------|------|
| 2026-08-10 | FR-062.10 T-4：§4.7 圖 4「路徑 0」修正——LC 與產品 root 後台拆成兩個獨立 participant（原圖誤畫成同一個內部直通），改畫「admin 按『向 LC 請照』→ BE 帶 API token 打 LC 內部簽發 API → 簽章回照 → BE 驗章落地」新流程；新增設計摘要段（API token 白名單、三支 LC 內部 API、產品端三支端點、不做 trial 強制的理由） | FR-062.10 T-4 |
| 2026-08-10 | T-8.5：License 選單結構調整三事——①`/license/manage` 改綁 `license.read`（`is_platform=true`）能力點，讓「平台專屬頁」由 DB 表達，取代 BE `menu_license_filter` 與 FE `RoleForm` 兩份硬編 url 清單；②新增 `group-license` 群組（sort=45）收納 License 兩頁；③關閉未完成功能「自定義儀表板」入口（`pid=0` 舊批唯一還開著的一支）。另 T-8.2 驗收後推翻「已授予但未生效」打勾設計，改為未授權一律反灰空框、差異只留 tooltip；§5 拆分表加 T-8.5 一列，統計數字校正為 8 子需求 × 28 子任務 | FR-062.8 T-8.5／T-8.2 |
| 2026-08-10 | FR-062.8 新增（驗收回饋）：角色權限矩陣缺 license 維度——`GET /ui-routes` 無授權標記、`POST/PUT /roles` 寫入端零檢查、provisioning 未扣未授權模組，導致未購買模組的能力點看得到也勾得下去。採業界 show-and-disable 模式（反灰＋原因），存量資料 **evaluate-time 抑制不刪 `role_capabilities`**；§5 拆分表加 FR-062.8 × 4 子任務，統計數字校正為 8 子需求 × 27 子任務 | FR-062.8 T-8.1～T-8.4 |
| 2026-08-09 | 三項追加卡回寫（此前漏排文件回寫排程）：T-5.2 §4.7／§5——「等雲端」改為「local 落地已驗證通過」；T-2.4 §4.10 新增 root tenant 授權狀態頁顯示修正說明；T-6.4 §4.8／§5 新增 LC 總覽詳情頁＋操作收 Menu 說明 | FR-062 T-2.4／T-5.2／T-6.4 |
| 2026-08-09 | T-0.2 三文件同步批次：新增 §8「設計理由（Why）」九條 What→Why；§3 `TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES` 改註為「不退役」（實作期查證推翻原定案，裁決見 CM-1126） | FR-062 T-0.2 |
| 2026-08-09 | T-7.3：`expiry_policy` 格式升級 v2.1——grace／readonly 段各補 `send_email: bool = True`（出廠開，from_dict 對舊照容忍缺鍵）；到期通知信接線擴到 grace（最後通牒語氣＋轉唯讀日期）與 readonly（已轉唯讀＋自救指引）兩段，沿用 T-7.1 收件人／冪等 pattern；§4.1 欄位表同步 | FR-062.7 T-7.3 |
| 2026-08-08 | 驗收回饋期累積裁示批次回寫（T-0.1）：§4.8 簽發站 Web 設計新增（Tabler UI／passphrase 環境變數化／顯示明文交付信封）、金鑰生命週期段補 passphrase 開發期存放方式、§4.1 補 `tenant_id` 代稱說明、§5 拆分表追加 T-1.3／T-1.4／T-1.5／T-6.3 四列＋統計數字修正、顯示層 i18n 一行 | FR-062 T-0.1 |
| 2026-08-08 | T-1.5：照檔外皮 v3——副檔名改 `.license`、內容包成 PEM 風格文字塊（`-----BEGIN/END GUIDANT LICENSE-----` 包住 v2 信封整包 JSON base64）；純外皮層，內層信封／簽章邏輯零改動；簽發端（CLI／Web）一律產出 armor，產品端上傳／CLI verify 兩式（armor＋裸 JSON）皆收 | FR-062.1 T-1.5 |
| 2026-08-08 | T-1.3：照檔格式升級 v2「payload 打包」——信封改為 `{format_version, kid, payload, signature}`，`payload` 為簽章原文 zlib 壓縮＋base64（防客戶隨手翻閱，**非加密**）；簽章仍對 unwrap 後的原文簽/驗，防竄改保證不變；兩 repo 驗章引擎同步支援 v2、並向下相容 v1 明文舊照 | FR-062.1 T-1.3 |
| 2026-08-08 | 初版設計定稿。承接 discussion v2 全部內容，並套入拍板晚於 v2 的五項最終修正：①過渡照（transitional）機制整個刪除，照型態只餘 `formal / trial / extension`，上線 migration 改為直接為既有租戶發正常格式照；②簽發端定案**自建**（不引入 Keygen 等外部 license server），獨立極簡 Flask 應用＋獨立 repo 獨立 DB；③Plan 與模組分層皆為簽發端 DB 資料（`plans` 表），非寫死；④升級 Plan＝換發新照、立即生效（不重啟、機器未變不重跑開通）；⑤數字定案：子租戶上限預設 5、expiry_policy 出廠預設 notify 30／grace 14／readonly 終態／lockout 關閉 | FR-062 母案 |

---

## 1. 需求背景與端到端流程 {#background nav="背景與流程"}

Guidant AI 即將以兩種模式對外銷售：**雲端 SaaS**（公司自營多租戶環境，授權後台指派、即改即生效）與**落地部署 Host 版**（整套系統進客戶機房，授權必須以不可竄改的憑證檔隨產品交付、可離線驗證）。兩種模式共用同一套 License 控管機制，核心需求：

1. **功能管控**——root tenant 以外的所有租戶，可用功能均受 license 管控；未授權模組不出現在選單、API 拒絕存取。
2. **簽發能力**——公司能產生 license 交付客戶，客戶開通後生效。
3. **有效期管理**——支援起訖日與訂閱到期，到期後行為明確、漸進、可預期（提醒 → 寬限 → 唯讀），不直接鎖死。

### 端到端流程

```{.mermaid cap="圖 1 — 端到端：簽約 → 簽發 → 交付 → 開通 → 使用 → 到期 → 續約換發（D6 replace 制；到期段依出廠預設，終態＝永久唯讀）"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
  A["① 簽約<br/>訂單成立<br/>（訂單編號·客戶代碼）"] --> B["② 簽發<br/>Web 後台選 Plan 產照＋序號<br/>寫入發照紀錄表"]
  B --> C["③ 交付<br/>Host：照＝合約隨貨<br/>SaaS：後台直接指派"]
  C --> D["④ 開通（Host）<br/>線上或離線<br/>綁定機器指紋"]
  D --> E["⑤ 使用<br/>模組守門·子租戶額度<br/>每日狀態檢查"]
  E --> F["⑥ 到期<br/>notice → grace → readonly<br/>（出廠預設終態＝永久唯讀）"]
  F --> G["⑦ 續約換發<br/>重簽整張新照替換<br/>舊照留歷史"]
  G --> E
  F -.->|"訂單未回簽<br/>但服務不可中斷"| H["臨時展延照<br/>type: extension<br/>只改到期日"]
  H --> E
```

### 整體架構

```{.mermaid cap="圖 2 — 整體架構：公司簽發端（獨立 Flask 應用＋CLI 引擎）與產品端的分工與交付物"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
  subgraph issuer["公司簽發端（獨立 repo·獨立 DB·內部部署，持有私鑰）"]
    WEB["極簡 Flask Web 後台<br/>客戶總覽頁＋簽發/換發表單<br/>Plan 套餐一鍵帶入"]
    CLI["簽章引擎＋CLI<br/>（Ed25519 私鑰簽章）"]
    LEDGER[("license_issuance 發照紀錄表<br/>＋plans 套餐表<br/>訂單編號／客戶代碼／序號／型態<br/>經銷商·抽佣欄位預留")]
    ACT["線上開通伺服器<br/>（掛雲端環境）"]
    WEB --> CLI
    CLI --> LEDGER
    ACT --> LEDGER
  end

  subgraph deliver["交付物"]
    LIC["license 檔<br/>（簽章 JSON）"]
    SN["開通序號<br/>（短碼，Host 版）"]
  end

  subgraph product["產品端（SaaS ／ Host 版共用）"]
    ADMIN["root tenant 系統管理後台<br/>上傳／驗章／指派／狀態總覽"]
    ENGINE["License 驗證引擎<br/>公鑰列表（kid 查表）編譯進後端"]
    STORE[("tenant_licenses<br/>現行照＋歷史照")]
    GUARD["授權守門<br/>common/authz 第六軸<br/>＋唯讀 gate＋選單/任務型態過濾"]
    CRON["每日排程<br/>到期狀態機檢查"]
    ADMIN --> ENGINE
    ENGINE --> STORE
    STORE --> GUARD
    CRON --> STORE
  end

  CLI --> LIC
  CLI --> SN
  LIC --> ADMIN
  SN -->|"線上開通（Host）"| ACT
  ACT -->|"自動下載照＋回傳機器指紋"| ADMIN
```

要點：**私鑰只存在於左側**——產品端只有公鑰列表與驗證邏輯，沒有任何簽發材料。SaaS 與 Host 版是同一個右側：SaaS 的「指派」在公司自營環境的 root tenant 後台完成（內部同樣走簽發產照），Host 版由客戶上傳照——驗證引擎、守門、狀態機完全共用。

---

## 2. 決策定案表（D1–D15） {#decisions nav="決策定案"}

十五項決策全數定案。標示「最終修正」者為 discussion v2 之後由決策者再拍板覆蓋的內容，**以本表為準**。

| 編號 | 決策 | 定案內容與理由 | 被排除方案 |
|---|---|---|---|
| **D1**（最終修正） | 簽發端形態：**自建** | 簽發端＝**獨立極簡 Flask 小應用**（與主產品同技術棧）、**獨立 repo、獨立 DB**、部署公司內部不對外；兩頁（客戶總覽／簽發換發表單）＋ CLI，**共用同一簽章引擎**（Web 是殼、CLI 是引擎，air-gapped 簽發與緊急搶修仍可純命令列）。license file 格式設計參考 Keygen 公開做法（Ed25519 簽章、機器指紋派生），但**不引入 Keygen 等外部 license server**。理由：①產品端執法工作（authz 第六軸、唯讀 gate、選單過濾）無論買不買現成方案都省不掉；②簽章引擎本身極小（Python `cryptography` 庫即可）；③特殊需求——綁租戶樹、`expiry_policy` 簽進照內、Plan 展開、經銷商抽佣欄位——全在現成方案的資料模型之外，硬套等於兩邊都要客製。管理端＝產品內 root tenant 系統管理後台（上傳、驗章、指派、狀態總覽），不另建管理網站（理由見 D15） | **Keygen / Cryptlex 等商用 license 服務**：授權鏈依賴外部廠商、特殊需求全在其模型外、產品端工作照樣省不掉；**純 CLI 無 Web**：非工程師無法自行發照與掌握到期全貌；**簽發功能放主產品**：私鑰進產品＝授權體系瓦解 |
| **D2** | 授權對象 | License 綁「頂層客戶租戶」，**不限使用者人數（seat）**；格式預留 quota 欄位（`max_users`、`max_projects`），初版一律填不限。現階段以模組計價，seat 計價留為未來商業槓桿，格式先預留可免日後換版 | seat 計價（現階段商業模式不採）；綁個別使用者（與租戶樹模型錯位） |
| **D3** | 授權粒度 | 按**功能模組**授權，粒度沿用既有權限系統的 `capability.resource_type`；商業打包採「基礎包＋加值包」分層（§10）。執法時 license 與 capability 兩層守門對齊同一套語彙，無需另建對照 | 另建 license 專屬模組語彙（雙真相、對照表維護成本）；按 API 端點逐一授權（粒度過細不可營運） |
| **D4** | 簽章與驗證 | **Ed25519 離線簽章** license 檔；公鑰**編譯進後端程式**（不放設定檔、不放資料庫）；採 **kid＋公鑰列表**機制支援金鑰輪替（§8）。SaaS 與 Host 版共用同一驗證引擎，僅以 `deployment_mode` 欄位區分行為 | 公鑰放設定檔／DB（落地客戶可自行替換＝防線歸零）；對稱金鑰（產品內含可簽發材料）；每版單一公鑰（換鑰＝逼全部客戶同時升級） |
| **D5** | 到期政策 | **可組態到期管線**：notify → grace → readonly → lockout 四段，各帶 `{enabled, days}` 與橫幅／email 旗標，**整組 `expiry_policy` 簽進照內**；產品端**無任何天數設定**。**出廠預設（定案）：notify 30 天、grace 14 天、readonly 啟用且為終態、lockout 關閉**——到期 → 14 天寬限 → 永久唯讀（可登入、可看、可下載匯出，擋全部寫入），不鎖登入。改預設值只影響之後簽的新照，舊照按自己照裡的政策跑完自然收斂 | 天數放產品端設定（落地客戶可竄改＋雙真相）；寫死四段不可組態（政策調整要改版）；到期直接鎖死（業界反例，製造「資料被扣押」糾紛） |
| **D6** | 換發模式 | **Replace 換發制**：一個租戶任一時刻只有一張現行照；續約、加購、展延一律重簽整張新照替換，舊照留歷史（稽核可查） | Append 疊加制：混合到期日（模組 A 三月到、B 六月到）逼狀態機 per-module 化，客服與客戶理解成本失控 |
| **D7** | 簽發權集中 | 只有公司內部能簽發（私鑰不出門）；未來經銷商也透過公司簽發——發照紀錄表**預留經銷商與抽佣欄位**，流程日後再議 | 經銷商持鑰分簽（私鑰外流風險＝授權體系瓦解） |
| **D8** | 租戶樹效力 | 照綁**頂層客戶租戶**，效力涵蓋全部子孫租戶；照內帶 `max_sub_tenants` 子租戶數上限，**預設值 5**（定案）。平行頂層租戶只有 root tenant 能開；客戶可在額度內自行開子租戶 | 逐子租戶各發一張照（授權管理量爆炸、與組織樹語意錯位） |
| **D9** | 機器綁定 | Host 版開通時綁**機器指紋**；續約換照機器不變即不必重跑開通。照檔遺失與換機走補發／換綁流程（§7）。SaaS 不綁（照存平台 DB） | 不綁機器（一張照可複製多套部署）；綁硬體 dongle（交付與維運成本過高） |
| **D10** | 開通方式 | **序號開通制，線上與離線雙路都做**（實作各開 case，**離線先行**）：簽發產出「license 檔＋開通序號」→ 首次登入偵測無照導向開通頁 → 線上路：輸入序號打公司開通伺服器（**掛雲端環境**，定案；雲端仍在開發不擋本案）自動下載照並回傳指紋；離線路：開通頁顯示機器碼，客戶交序號＋機器碼給公司，公司簽綁定照回寄上傳。**序號／機器碼流程僅 Host 版適用**；SaaS 送照管道是後台指派、零客戶動作 | 只做線上開通（落地客戶常在封閉網路，離線不可省）；只做離線（有網路客戶體驗差） |
| **D11**（最終修正） | 照型分類 | License 帶 `type` 欄位，**只有三種**：`formal`（正式）／`trial`（試用）／`extension`（臨時展延）。extension 應對「訂單行政流程未回簽但服務不可中斷」——抓原照只改到期日快速簽發；試用與展延不混入營收認列。**原第四種 `transitional`（過渡照）整個刪除**——未來若有「給既有租戶墊檔」需求，以 trial 照處理，不做專屬機制 | `transitional` 過渡照專屬型態（產品尚未上線、不存在需保護的存量客戶，專屬機制是多餘的第四型態；既有內部租戶直接發 formal 照即可，見 D14） |
| **D12**（最終修正） | 可販售模組分層＝**簽發端 DB 資料** | 以 BE 程式碼相依盤點為據，模組分為**基礎包**（21 模組）與**四個加值包**（問卷／檢測工具／雲端整合／AI 儀表板），死模組與舊架構殘留移除、平台級模組不販售（§10）。**分層與 Plan 都存簽發端 `plans` 表，後台可增改調整、非寫死**；既有 35 個 capability 粒度內重組零工程成本，只有新增新粒度才需產品端補守門。改 Plan 只影響之後發的照。現行硬編停用清單 `TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES` 由 license 機制取代 | 逐模組圈選販售（工程上不成立——多數模組 hard 相依，拆開賣得到不能動的系統）；分層寫死在程式碼（商務調整要改版發版） |
| **D13** | 到期通知 | **系統內橫幅＋email 通知租戶管理員兩者都做**；均為 `expiry_policy` 各段旗標、簽進照內。橫幅保證登入必見，email 覆蓋不常登入的管理員 | 只做其一（到期是收入事件，通知不嫌多） |
| **D14**（最終修正） | 既有租戶處置：**直接發正常照** | 上線 migration **直接為既有租戶（root tenant 除外）發正常格式的照**：全模組、效期約一年、`type: formal`，供測試 license 功能使用。**執法邏輯第一天即全量生效，無任何特例分支**——「既有租戶暫時豁免」的 if 分支是技術債溫床且測不到執法路徑。理由：產品尚未上線，現存租戶皆內部／測試性質，不存在需保護的存量客戶，「過渡」概念無對象 | 原 D14「過渡照 90 天」機制（整個刪除，理由同上）；執法豁免分支（測不到、拆不掉） |
| **D15**（併入 D1） | 管理功能放主站 | License 管理頁放產品內 root tenant 系統管理後台，不另建站：①Host 版客戶機器上只有產品本身，別無他處可放；②SaaS 復用同一批頁面成本最低；③root 後台（platform-admin 軸）本就是特權維運功能的家 | 獨立管理網站（Host 版無處部署、SaaS 重複建設） |

補充定案（原 discussion「待確認」清單的落點）：**升級 Plan＝換發新照、立即生效**——SaaS 後台改指派即生效；Host 版上傳新照當下生效，不重啟服務、機器未變不重跑開通。Plan 套餐組合與命名（Basic／Professional／Enterprise 草案）為簽發端 `plans` 表的 seed 起點，後台隨時可調。

---

## 3. 現況接入點盤點 {#touchpoints nav="接入點盤點"}

本案掛進既有骨架，逐點盤點：

| 元件 | 現況 | 本案動作 |
|------|------|------|
| `common/authz/` | FR-048 統一授權守門，現有五軸（platform-admin ／ super-admin ／ project-role ／ capability ／ signed-token） | **新增第六軸 `license.py`**：照抄 `capability.py` 的 guard＋decorator＋`flask.g` memoize 樣板；root tenant 經 `viewer_is_platform_admin()` 無條件豁免 |
| `capability.resource_type` | 權限系統資源分類（35 個，見 §10） | 作為 license 模組粒度來源（D3） |
| `api/grc` ／ `api/project` | **目前零 capability 守門**（技術債） | license 執法前**必須補守門**，否則「沒買 project」API 層擋不住（納入階段 4） |
| `GrcJobType` enum | 寫死（`common/enum/grc_job_type_enum.py`＋`api/grc/serializers/job.py:173-177,209-213`） | 任務型態下拉**按租戶授權動態過濾**——沒買問卷包的租戶不可選到問卷任務（選了會卡死在 `SURVEY_INCOMPLETE`） |
| `job_import_service.py:31` | `VALID_JOB_TYPES` 與 enum 不同步（缺 `detection_tool`），既有小 bug | 動態過濾接線時**順手同步修** |
| ui_route 選單 | 選單項由 `ui_routes` 動態組出（`api/auth/routes/ui_route_route.py` 為樣板） | 未授權模組的選單項**直接隱藏** |
| `core/scheduler.py` | 既有 APScheduler，每日 cron 有前例（`framework_parse_job_cleanup`） | 新增每日 cron 檢查各租戶照的到期狀態轉換 |
| 唯讀執法 | 無 | readonly 狀態下**全域攔截 POST／PUT／PATCH／DELETE 回 403**；License 上傳端點與登入端點豁免（防死鎖）；下載匯出類 GET 不受影響 |
| `TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES` | 硬編租戶停用清單（`app/auth/service/tenant_provisioning_service.py`） | **不退役**（實作期修正，詳見 §8-⑨）：此清單管的是 provisioning-time 預設授予，license 管的是 request-time 商務授權，兩個維度不同、license 取代不了。裁決見 CM-1126 |
| config | ENV class 只有基礎設施參數，無部署形態語意 | 新增 **`DEPLOYMENT_MODE`**（值 `saas` ／ `host`），供驗證引擎區分行為（如機器指紋是否必綁） |

---

## 4. 詳細設計 {#design nav="詳細設計"}

### 4.1 License 檔格式

License 檔為簽章 JSON，欄位分五組：

| 分組 | 欄位 | 說明 |
|---|---|---|
| **識別** | `license_id` | 照的唯一識別碼 |
| | `customer_code` | 客戶代碼（對齊發照紀錄表） |
| | `order_no` | 訂單編號（財務對帳鏈結） |
| | `issued_to` | 客戶名稱 |
| | tenant 綁定 | 綁定的頂層客戶租戶識別 |
| | `type` | `formal` ／ `trial` ／ `extension`（**三種，無 transitional**） |
| | `issuer` | 發照人 |
| **時間** | `issued_at` | 簽發時間（不可在未來，驗證時拒收） |
| | `starts_at` ／ `expires_at` | 效期起訖 |
| | `expiry_policy.notify` | `{enabled, days_before, show_banner, send_email}`——出廠預設：啟用、30 天、橫幅＋email |
| | `expiry_policy.grace` | `{enabled, days, show_banner, send_email}`（v2.1 新增 `send_email`）——出廠預設：啟用、14 天、橫幅＋email |
| | `expiry_policy.readonly` | `{enabled, days?, send_email}`（v2.1 新增 `send_email`）——`days` 省略＝終態無期限；出廠預設：啟用、終態、email |
| | `expiry_policy.lockout` | `{enabled}`——出廠預設：**停用**；格式保留，政策變嚴時發照打開即可，不必改版 |
| **功能** | `modules` | `{resource_type: bool}` 對映，**展開後的模組清單**（產品端只認這個） |
| | `plan` | Plan 名稱標籤（如 `professional`）；純標示，產品端不據此執法 |
| | `limits` | `{max_sub_tenants（預設 5）, max_users（預留）, max_projects（預留）}` |
| **綁定** | `machine_fingerprint` | 機器指紋（Host 開通後綁定；SaaS 留空） |
| | `deployment_mode` | `saas` ／ `host`——同一 schema 同一引擎，僅以此欄位區分（host 有指紋＋序號、saas 空） |
| **完整性** | `kid` | 簽章金鑰識別碼，驗章時按 kid 查產品端公鑰列表 |
| | `signature` ＋ `alg` | Ed25519 簽章與演算法標記 |

上表為照的**內容**欄位（簽章原文 payload 的組成）。落地成 `.json` 檔的**信封格式**見下方 v2 說明。

- **`tenant_id`（`tenant 綁定` 欄）是簽發端的客戶識別代稱，產品端不對此欄位做強比對**——照的實質綁定對象是 SaaS 版的 root 指派動作（後台把照寫入哪個租戶就綁哪個）或 Host 版的機器指紋（§4.7 開通時寫入開通紀錄供稽核），`tenant_id` 只是簽發紀錄與照內容用來對照客戶的標籤，不是產品端驗證邏輯的比對鍵。

#### 檔案格式 v2：payload 打包（T-1.3，2026-08-08 起生效）

現行照檔（v1）是明文 JSON＋Ed25519 簽章——防竄改已足，但內文（模組清單、到期日、expiry_policy 等）客戶打開文字編輯器即可讀。決策者裁示：不讓客戶隨手看到內文。**這不是加密**——Host 版客戶持有整包程式碼，解碼邏輯終究可逆向，此設計本就不承諾密碼學保密；目的僅是「嚇阻一般翻閱」，故採壓縮＋編碼打包、不採對稱加密（金鑰反正藏不住，加密只多金鑰管理成本無真實安全增益）。

落地檔案信封改為：

```json
{
  "format_version": 2,
  "kid": "<簽章金鑰識別碼>",
  "payload": "<zlib 壓縮＋base64 之 license JSON>",
  "signature": "<Ed25519 簽章>",
  "alg": "Ed25519"
}
```

- `payload` 是上表全部欄位（識別／時間／功能／綁定／完整性五組，`kid` 除外置於信封層）組成的 canonical JSON（key 排序、無多餘空白）先 zlib 壓縮、再 base64 編碼的字串。
- **簽章對象是 unwrap 後的 canonical bytes 原文，不是打包後的字串**——驗章時先 base64 解碼＋zlib 解壓還原出原文，再用原文驗 Ed25519 簽章。這個順序保證：①換掉壓縮實作（如改用 gzip）不會讓既有簽好的照失效；②竄改 `payload` 任一 byte，解壓會失敗或還原出的 JSON 對不上簽章，兩者皆判定驗章失敗——防竄改能力與 v1 完全等價。
- **v1／v2 並存**：兩 repo（`license_center` 簽發端、`compliance-manager-be` 驗證端）的驗章引擎皆以有無 `format_version` 鍵區分版本，v1 明文舊照（無此鍵）沿用舊路徑直接對整個 dict 驗章，v2 信封先 unwrap 再驗。簽發端（CLI／Web）自 T-1.3 起一律只產出 v2；驗證端兩版皆收，理由是 FR-062 尚未正式出貨、DEV 環境已有 v1 測試照（T-2.3 手測用 `lic-test-0001`），拒收 v1 會讓既有驗收資料失效且無實際安全收益（v1/v2 差異只在「是否好讀」，簽章防竄改保證兩版一致）。待 v2 全面出貨後續一輪視需要再收緊為只收 v2，非本階段範圍。

#### 檔案外皮 v3：`.license` ＋ PEM 風格 armor（T-1.5，2026-08-08 起生效）

user 驗收回饋：裸 `.json` 交付不專業。對齊業界慣例（JetBrains `.key`／GitLab `.gitlab-license`／Keygen PEM 風格 license file）定案改用自有副檔名＋PEM 風格文字塊包裝：

```plain text
-----BEGIN GUIDANT LICENSE-----
<v2 信封整包 JSON，base64 編碼，64 字元換行>
-----END GUIDANT LICENSE-----
```

- **純外皮層，非格式重簽**：armor 包裹的是 v2 信封（`format_version/kid/payload/signature/alg`）**整包** JSON 的 base64 編碼，內層信封結構與簽章邏輯完全不動——`armor_license`／`dearmor_license` 只是「json.dumps → base64 → 加頭尾包行」與其逆操作，不碰 `payload` 打包或簽章演算法。
- **兩 repo 各自獨立實作**（`license_center.crypto.engine` 與 `common.license.engine`），維持既有「零依賴、僅透過簽好的照檔交互」原則，不新增套件依賴。
- **產出端**：`license_center` CLI（`issue`／`extend`／`reissue`）與 Web 下載（`/issuance/download/<license_id>`）一律輸出 `<license_id>.license`（armor 內容）；CLI 未指定 `--output` 時預設檔名也改用 `.license`。
- **驗證端兩式皆收**：`compliance-manager-be` 上傳解析（`LicenseVerificationService.upload_license`）與 `license_center` CLI `verify` 皆先偵測是否含 `-----BEGIN GUIDANT LICENSE-----`，是則 `dearmor_license` 還原成 dict 再走既有 v2/v1 驗章路徑；不含 BEGIN 行（裸 JSON）照舊直接解析——同一批既有測試照與手測資料不受影響。
- **容錯**：`dearmor_license` 對常見文字污染（CRLF、行尾空白、email 轉寄插入的引用空行）逐行 strip 後再 join 解 base64，避免客戶轉寄/複製貼上破壞格式。
- **FE**：上傳元件 `accept` 屬性加 `.license`，判斷邏輯以副檔名為準（非標準副檔名瀏覽器 MIME type 判定不可靠）；`FileReader.readAsText` 流程不變，讀出文字後偵測 BEGIN 行——armor 內容原樣以字串送到 BE（不在前端解包），裸 JSON 則沿用既有 `JSON.parse`。

### 4.2 簽章與驗證

- **Ed25519 離線簽章**（Python `cryptography` 庫），格式設計參考 Keygen 公開做法。
- **公鑰列表編譯進後端程式**：`[{kid, pubkey}]`，不放設定檔、不放 DB；照內帶 `kid`，驗章時按 kid 查表。
- **時鐘回撥防護**：系統持續記錄「見過的最大時間戳」，偵測到時間倒退判定異常；`issued_at` 晚於當下系統時間即拒收。
- **竄改處置**：驗章失敗＝整張照無效，系統進入鎖定（「無有效照」的鎖定，與到期管線可組態終態無關）；本地留證（事件時間＋檔案指紋），有網路則回報公司端點。
- **防線分層**：簽章防竄改 → 公鑰編譯進執行檔防替換 → 時鐘回撥偵測 → 蓄意破解（patch 產品程式碼）靠合約與稽核約束，技術上留存竄改證據作舉證材料。破解管理員帳號也無法自行簽發——產品內沒有私鑰、沒有簽發程式碼，只有「收照櫃台」。

### 4.3 簽發站設計（獨立 repo）

**形態**：極簡 Flask 應用（與主產品同技術棧）、獨立 repo、獨立 DB、部署公司內部不對外。Web 是殼、CLI 是引擎，**兩者共用同一簽章引擎模組**；air-gapped 簽發、Web 掛掉時仍可純命令列操作。

**兩頁**：

::: grid2
::: {.card .ok}
#### 客戶總覽頁
每個客戶一列：客戶代碼／名稱／訂單編號／照型態／Plan／模組集／到期日／**推算狀態**／開通狀態。依臨期排序上色。Host 客戶顯示的是「按合約推算」狀態（照在客戶機房，簽發端按到期日與 `expiry_policy` 推算現處階段）；SaaS 租戶為真即時。高頻換綁客戶標記警訊（防複製部署）。
:::
::: {.card .ok}
#### 簽發／換發表單頁
選 Plan 一鍵帶入模組集，可逐模組微調；填效期產照＋序號；展延（extension）一鍵——抓原照只改到期日；補發一鍵重出同一張照；換綁人工受理。
:::
:::

**獨立 DB schema 草案**（兩張表起步）：

`license_issuance` 發照紀錄表——財務對帳與稽核的權威紀錄、客戶總覽頁資料源：

| 欄位群 | 欄位 | 說明 |
|---|---|---|
| 商務識別 | **`order_no`**、**`customer_code`**、客戶名 | 決策層明確要求必含前兩欄 |
| 授權內容 | tenant 識別、開通序號、`type`、`deployment_mode`、Plan 標籤、模組集、`expiry_policy`、到期日 | 簽進照裡的內容留檔；補發即據此重出同一張照 |
| 開通狀態 | 機器指紋（開通後回寫）、開通時間、換綁歷史（舊指紋作廢紀錄） | 線上路徑自動回寫、離線路徑人工回寫 |
| 通路（預留） | 經銷商欄位、抽佣欄位 | D7 |
| 稽核 | 發照人、時間戳、事件類別（新簽／換發／展延／補發／換綁） | |

`plans` 套餐表——**Plan 與模組分層是 DB 資料，後台可增改，非寫死**：

| 欄位 | 說明 |
|---|---|
| `plan_code` ／ `plan_name` | 套餐識別與顯示名 |
| `modules` | 該套餐展開的 resource_type 集合（發照時一鍵帶入，可再微調） |
| `is_active`、排序、備註 | 營運維護欄位 |

seed 起點：**Basic**＝基礎包；**Professional**＝基礎包＋問卷包＋雲端整合包；**Enterprise**＝全模組。改 Plan 只影響之後發的照（照內存展開後清單，範本改動不回溯存量）。

### 4.4 產品端資料模型：`tenant_licenses`

| 欄位群 | 欄位 | 說明 |
|---|---|---|
| 綁定 | `tenant_id` | 頂層客戶租戶 |
| 照本體 | license 原文 | 逐次驗章依據（不信任解析快取） |
| 快取 | 解析後欄位快取 | 模組集、到期日、`expiry_policy` 等，供守門快速讀取 |
| 狀態 | `valid` ／ `notice` ／ `grace` ／ `readonly` ／ `locked` | 每日排程更新；哪些狀態可達由照內 `expiry_policy` 決定 |
| 歷史 | `is_current` 旗標（或歷史表） | D6 replace 制，舊照留存供稽核 |
| 稽核 | 上傳人、上傳時間 | |

### 4.5 狀態機與排程

```{.mermaid cap="圖 3 — 可組態到期管線（D5）：實線為出廠預設路徑（終態＝永久唯讀），lockout 段預設停用"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
    [*] --> valid: 照生效
    valid --> notice: 距到期 ≤ notify.days_before（預設 30）
    notice --> grace: 到期日過（grace 啟用）
    notice --> readonly: 到期日過（grace 停用時直接唯讀）
    grace --> readonly: 寬限天數用盡（預設 14）
    readonly --> locked: lockout 啟用且 readonly.days 用盡<br/>（出廠預設 lockout 停用，此轉換不會發生）

    notice --> valid: 換發新照
    grace --> valid: 換發新照
    readonly --> valid: 換發新照
    locked --> valid: 管理員上傳新照

    valid: valid — 正常服務
    notice: notice — 全功能＋到期提醒（橫幅／email 依照內旗標）
    grace: grace — 全功能＋醒目警告
    readonly: readonly — 可登入·可看·可下載匯出，擋全部寫入（預設為終態）
    locked: locked — 鎖登入，僅管理員可進上傳頁（預設停用，可組態）
```

各段開關語意：

| 組態 | 行為語意 |
|---|---|
| 關閉 `grace` | 到期日一過直接進唯讀 |
| 關閉 `lockout`（**出廠預設**） | 唯讀為終態——客戶永遠可登入、查看、下載匯出，只是不能寫 |
| 關閉 `readonly`（且 lockout 開） | 寬限用盡直接鎖定（嚴格政策組合） |
| `readonly.days` 省略 | 唯讀無期限（readonly 為終態的自然寫法） |
| 全部關閉 | 形同永久照——簽發工具**警示**（防誤簽）但不禁止（內部長效照即此組態） |
| 各段 `show_banner` ／ notify 的 `send_email` | 橫幅與 email 逐段可控，簽進照內 |

- 狀態轉換由**每日排程**驅動（複用 `core/scheduler.py` APScheduler，比照 `framework_parse_job_cleanup` 寫法）；任何狀態換發新照即回 valid。
- **locked（若啟用）不是死路**：管理員永遠可進 License 上傳頁上傳新照，登入與上傳端點在唯讀與鎖定狀態均豁免（防死鎖）。

### 4.6 執法設計

| 執法點 | 設計 |
|---|---|
| **第六軸 `require_license`** | `common/authz/license.py`，照抄 `capability.py` 樣板（guard service＋decorator＋`flask.g` 每請求 memoize）；判「該租戶現行照的 `modules` 是否含此 resource_type」；root tenant 經 `viewer_is_platform_admin()` 無條件豁免 |
| **唯讀 gate** | readonly／locked 狀態下全域攔截寫入方法（POST／PUT／PATCH／DELETE）回 403；**豁免清單：License 上傳端點、登入端點**；下載匯出類 GET 不受影響 |
| **任務型態過濾** | `GrcJobType` 選單按租戶授權動態過濾（serializers 兩處＋`job_import_service.VALID_JOB_TYPES` 順手同步修） |
| **選單過濾** | ui_route 組選單時濾掉未授權模組項，使用者不會看到點了才報錯的按鈕 |
| **子租戶額度** | 開子租戶時檢查 `limits.max_sub_tenants`（預設 5） |
| **環境開關** | 執法整體可由環境開關關閉（階段 4 上線保險絲）；`DEPLOYMENT_MODE`（`saas`／`host`）決定機器指紋是否必驗 |

### 4.7 開通流程（三路）

序號／機器碼開通**僅 Host 版適用**；SaaS 送照管道是**產品 root 後台向 License Center 請照**（FR-062.10：帶 API token 打 LC 內部簽發 API，回照驗章後直接寫入租戶，客戶零動作、即改即生效）——LC 與產品 root 後台是**兩個獨立系統獨立 DB**，中間僅透過此內部 API 交互，不共用資料庫也不互相直寫。

```{.mermaid cap="圖 4 — 送照時序：SaaS 向 LC 請照＋Host 版線上／離線開通（D10／FR-062.10）"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    participant C as 客戶（管理員）
    participant R as 產品 root 後台（BE，獨立 DB）
    participant P as 產品端（開通頁）
    participant S as 公司開通伺服器
    participant I as License Center（簽發引擎，獨立 repo／獨立 DB）

    rect rgb(226, 240, 241)
    Note over R,I: 路徑 0 — SaaS：root 後台向 LC 請照（客戶零動作）
    R->>R: admin 按「向 LC 請照」（選租戶／方案／天數）
    R->>I: 帶 API token 打 LC 內部簽發 API（/issue 或 /extend）
    I->>I: token 白名單驗證 → 呼叫 issue_license／extend_license 簽出照
    I-->>R: 回傳簽章後 license（armor）
    R->>R: 驗章 → 落地寫入該租戶（event_type=lc_issue／lc_extend，即改即生效）
    end

    Note over I: Host 版：簽約後簽發產出 license 檔＋開通序號
    I->>C: 交付開通序號（短碼）

    rect rgb(238, 242, 243)
    Note over C,S: 路徑 A — Host 線上開通
    C->>P: 首次登入，系統偵測無照 → 導向開通頁
    C->>P: 輸入開通序號
    P->>S: 序號＋本機機器指紋
    S->>I: 核對序號 → 簽出綁定該機的照
    S-->>P: 回傳 license 檔（自動下載安裝）
    P->>P: 驗章 → 生效
    S->>I: 回寫紀錄：已開通＋機器指紋＋時間
    end

    rect rgb(251, 252, 252)
    Note over C,I: 路徑 B — Host 離線開通（封閉網路）
    C->>P: 首次登入 → 開通頁顯示本機機器碼
    C->>I: 將序號＋機器碼交付公司（郵件等既有管道）
    I->>I: 簽出綁定該機器碼的照
    I->>C: 回寄 license 檔
    C->>P: 上傳 license 檔
    P->>P: 驗章＋核對機器指紋 → 生效
    Note over I: 人工回寫紀錄：已開通＋機器指紋＋時間
    end
```

兩路共用同一張照格式與同一套驗證引擎，差別只在交付方式。機器綁定在開通當下完成；日後續約換照機器不變即不必重跑開通。線上開通伺服器**掛雲端環境**（定案不變）；但「開發＋全流程驗證」與「公網部署」已拆開處理——**線上開通伺服器端點已在 local 落地並全流程驗證通過**（BE 代打 LC 側公開 API，序號存在／未用／未過期三查＋防濫用鎖定），公網曝露／TLS／網域／防火牆等純部署動作留待雲端環境就緒後補驗（T-5.2，見 §5）。

#### FR-062.10：SaaS 續約免手動搬檔（產品後台直接向 LC 請照）

**背景**：原設計圖 4「路徑 0」把 LC 與產品 root 後台畫成同一個 participant，讀起來像「產品後台按一下、後面自動去簽發引擎拿照」，但兩者是**獨立系統獨立 DB**——實際上 SaaS 續約／加購／展延時，admin 必須先在 LC 端簽發下載 `.license` 檔，再切回產品後台上傳，兩趟手動搬檔。本設計補上一條產品端直接打 LC 內部 API 的線，取代人工搬檔，圖 4 已同步修正。

**認證：API token 白名單機制**（不複用 Host 版 activation 的一次性序號，因為簽發 API 沒有對應憑證）：
- LC 端新增 `api_tokens` 白名單表：`token_name`（來源名稱，如 product-dev／product-prod）、`environment`、`token_hash`（不存明文）、`is_active`（撤銷用停用不刪列）
- 每個產品環境（DEV／STG／正式站）各發一把 token；LC 端發照紀錄 `issuer` 欄位寫 token_name，帳本上每張照看得出由哪個環境請發
- 產品端請求帶 token（header），LC 端驗證比對白名單後才呼叫簽發邏輯

**三支 LC 內部 API**（新 blueprint，比照既有 `activation` 無 session＋passphrase 環境變數自動解鎖 pattern，直接呼叫既有純函式 `issue_license`／`extend_license`，不另抽 service）：
- `POST /issue`：新簽（customer_code／order_no／issued_to／tenant_id／plan_code／days／deployment_mode…）
- `POST /extend`：展延（license_id + days）
- `GET /plans`：回傳 active plans 供產品端 UI 下拉選擇

**產品端三支對應端點**（`license_admin_route.py` 同組，掛 `@require_platform_admin_route`）：
- `lc-issue`：呼叫 LC `/issue` → 回照走既有落地漏斗（`_verify_signed_license` + `_store_verified_license`，第五個進入口，傳 `event_type=lc_issue`）
- `lc-extend`：呼叫 LC `/extend` → 落地 `event_type=lc_extend`
- `lc/plans`：取得可選方案清單

失敗路徑照抄既有 `activate_license_online()` pattern：打 LC → 逐狀態碼轉 error code → 連線失敗回專屬碼 → 回照仍走本地完整驗章＋落地（BE 是唯一信任邊界，不因來源是 LC 就跳過驗章）；手動上傳路徑完全不動，作為 LC 不可達時的天然 fallback。

**決策：不做 trial 強制**——曾考慮「非 production 環境只能簽 trial 照」的防呆規則，最終不採：主站管理員即決策者本人，LC 環境未來也會與產品環境分開部署，白名單機制的目的是**管理便利與可追溯性**（可撤銷、可查來源），不是防範內部誤用；未來若接入多個 SaaS 平台，同一白名單機制可統一控管各平台來源。

Host 版序號開通流程零接觸（`DEPLOYMENT_MODE` 為產品實例層級 config，本案只在 SaaS 實例的 root 後台加按鈕）。完整拆棒與待調查六項逐條結論見母案 Notion CM-1175。

### 4.8 補發與換綁

::: grid2
::: {.card .ok}
#### 情境 A：照檔弄丟（機器沒變）
發照紀錄表存有完整照內容，一鍵重出同一張照（同內容、同指紋）交付。紀錄「補發」事件，不影響營收報表。
:::
::: {.card .warn}
#### 情境 B：換機（指紋變了）
新機走一次開通流程取新機器碼 → 公司簽出同內容、改綁新機的照 → 紀錄「換綁」事件，舊指紋留歷史並作廢。
:::
:::

防複製政策：換綁**人工受理**（受理時確認舊機已停用，不提供自助換綁）；同一客戶頻繁換綁是警訊，簽發端總覽頁標記提示。

#### 簽發站 Web 設計（驗收回饋補充）

- **UI 框架**：採 Tabler（MIT，Bootstrap 5 基礎）。靜態資產 pin 版下載後放入 `static/vendor/`，不依賴外部 CDN、不引入前端 build 鏈——與簽發站「極簡 Flask、獨立部署」定位一致。
- **簽發 passphrase 自動解鎖**：私鑰解密優先讀 `.env` 的 `LICENSE_CENTER_KEY_PASSPHRASE` 環境變數，設定後簽發／展延／補發表單免手動輸入。表單 passphrase 欄位**僅於未設環境變數時顯示**（air-gapped 備援：私鑰與簽發站不同機、或刻意不落地環境變數的場景）；輸入的 passphrase 只在單次請求記憶體內用於解密，不落地、不存 session。
- **「顯示用明文、交付用信封」原則**：簽發結果頁與總覽頁的畫面呈現皆為解包後的明文欄位（照型態、部署模式、模組集、到期日等），方便內部核對；但**照下載一律走完整簽章信封**（v3 armor 或 v2 JSON），畫面明文不等於下載內容，避免內部人員誤把顯示用文字當交付檔轉發客戶。
- **照下載**：結果頁下載走 `armor_license`（`.license` armor 格式，§4.1 v3）；總覽頁重下載走 `get_if_current` 守門（僅現行有效紀錄可重下載，作廢舊照不提供），輸出完整 v2 信封原文（不重簽）。
- **總覽詳情頁＋操作 Menu**（T-6.4，驗收回饋追加）：總覽頁每列客戶代碼／名稱可點入詳情頁（`/licenses/<license_id>`，重用簽發結果頁樣板），非現行照（已被展延／補發／換綁取代）也可查詢供稽核，但畫面顯示「已被取代」提示＋接替筆連結、**不顯示下載鈕**（下載守門規則不變）；列尾原本平鋪的動作按鈕（下載／展延／補發／換綁）收成單一 Tabler dropdown Menu，新增「查看詳情」項。

### 4.9 金鑰生命週期

- **保管**：私鑰檔以 passphrase 加密，加密備份**至少兩處**（密碼管理器／保險庫＋離線媒介）；`license_issuance` 同步備份。**私鑰遺失且無備份＝無法再簽發與續約**——備份紀律是營運前提，不是建議。
- **passphrase 保管方式**：開發／內部部署階段得直接存放簽發站 `.env` 的 `LICENSE_CENTER_KEY_PASSPHRASE`（與私鑰檔同機），供 Web 自動解鎖（§4.8）；**正式對外簽發仍須密碼管理器保管＋兩處站外加密備份**，`.env` 存放僅限私鑰與簽發站同機、風險可控的內部場景，不可作為正式營運的唯一保管方式。
- **簽發機不綁定**：私鑰是檔案，換機還原備份即恢復；照的驗證完全不看簽發機器。
- **kid＋公鑰列表（初版就做）**：只編譯一把公鑰的話，日後換鑰＝逼全部客戶同時升級，商業上不可行。
- **計畫性輪替**（每 2–3 年）：產新 kid → 新版產品公鑰列表加入新鑰（保留舊鑰）→ 新照用新鑰簽 → 存量照到期換發自然轉新鑰 → 舊鑰無存量照後下一版移除。
- **私鑰洩漏（急件）**：立即產新鑰；緊急出**只帶新鑰**的產品版本（＝吊銷舊鑰）；換發全部現行照並通知客戶升級——發照紀錄表就是換發清單。

### 4.10 SaaS 後台操作

root tenant 後台對 SaaS 租戶可執行：

| 操作 | 說明 |
|---|---|
| 指派／變更方案 | 選 Plan 或逐模組調整，內部產照直接寫入，**即改即生效**（升級 Plan＝換發新照，同樣立即生效） |
| 展延／縮短效期 | 重簽新照替換（D6 replace 制） |
| **手動立即停權** | SaaS 獨有——欠費等情況直接切唯讀或停用，不等到期日（Host 版做不到，照在客戶手上只能等到期） |
| 檢視單一租戶狀態 | 現處階段、下一階段時點、模組集 |

**顯示層 i18n（驗收回饋補充）**：產品端照型態／部署模式／模組集顯示文字補齊 FE i18n（`zh-tw`／`en` 兩語系）；簽發端 LC 後台介面中文化。

**root tenant 顯示修正**（T-2.4，驗收回饋追加）：root tenant 開「我的授權狀態」頁原本顯示與一般租戶相同的「尚未授權」空狀態，語意錯誤——root tenant 是設計上無條件豁免 license 執法、永不發照的特權租戶，沒有照是「設計如此」不是「還沒買」。`GET /license/status` 補 `is_root_tenant` 旗標（沿用既有 platform-admin 軸判定），FE 依旗標顯示「本租戶為系統管理租戶，不受 License 授權管控」；此旗標同時供全域到期橫幅判斷是否對該租戶顯示。

---

## 5. 拆分表（FR-062.1 ~ FR-062.8 × 28 子任務） {#breakdown nav="拆分表"}

::: {.callout .decided}
**拆分原則（決策者明確要求）**

每階段自帶可觀察產出與人工測試素材（測試資料由前一階段產出，不依賴未完成的後續階段）；**階段 1–3 對現有系統零侵入**，中途暫停不留風險；**執法最後進場且帶環境開關**。子任務只准依賴前面階段。階段 5 開通依 user 明示拆兩張 case（**離線先、線上後**）。
:::

依賴鏈：**.1 → .2 → .3 → .4**；**.5 依賴 .1＋.2**（與 .3/.4 可並行）；**.6 依賴 .1**；**.7 依賴 .3（＋.2 的表）、migration 為上版前最後動作**；**.8 依賴 .4**。

### FR-062.1 簽發端地基（階段 1）— 依賴：無 · 對現有系統零侵入

| # | 子任務 | 實作內容 | Repo | 驗收（人工測法） | 依賴 |
|---|--------|----------|------|------|------|
| T-1.1 | 簽章引擎＋license 檔 schema | 新開簽發站 repo；Ed25519 產鑰／簽章／驗章引擎（Python `cryptography`）；license 檔完整 schema（§4.1 全欄位：`expiry_policy` 四段、`kid`、`plan`、`deployment_mode`、`machine_fingerprint`、`type` 三值、`limits`）；私鑰 passphrase 加密保存 | 簽發站（新 repo） | 引擎可產鑰、簽出照、驗章通過；改照內任一 byte 驗章失敗 | — |
| T-1.2 | CLI＋簽發站 DB | CLI 命令（產照／驗章／展延／補發）；獨立 DB：`license_issuance` 發照紀錄表（含訂單編號、客戶代碼、序號、事件類別、經銷商／抽佣預留欄）＋ `plans` 套餐表（seed Basic／Professional／Enterprise）；`expiry_policy` 全關時 CLI 警示 | 簽發站 | 跑 CLI 產一張 Professional 照（Plan 自 `plans` 表帶入展開）、驗章、查紀錄表有紀錄；**完全不碰主產品** | T-1.1 |
| T-1.3 | 照檔格式 v2 payload 打包（驗收回饋追加） | 落地信封改 `{format_version, kid, payload, signature}`（§4.1 v2 說明）；兩 repo 驗章引擎同步支援 v2、向下相容 v1 明文舊照 | 簽發站＋BE | 產 v2 照驗章通過；上傳既有 v1 測試照仍可解析；竄改 `payload` 任一 byte 驗章失敗 | T-1.2 |
| T-1.4 | passphrase 環境變數化＋新公鑰替換（驗收回饋追加） | 簽發站私鑰解密改讀 `.env` `LICENSE_CENTER_KEY_PASSPHRASE` 自動解鎖（§4.8）；配合重產金鑰對，BE 端 `common/license/public_keys.py` 換新公鑰；總覽回應補 `license_type`／`deployment_mode` 兩欄位供 FE 顯示 | 簽發站＋BE | 免手動輸入 passphrase 即可簽照；BE 用新公鑰驗新照通過；總覽 API 回應含型態與部署模式 | T-1.3 |
| T-1.5 | 照檔外皮 v3 armor（驗收回饋追加） | `.license` 副檔名＋PEM 風格 armor 包裝（§4.1 v3 說明）；簽發端一律產出 armor，產品端上傳／CLI verify 兩式（armor＋裸 JSON）皆收 | 簽發站＋BE | 下載 `.license` 檔可正確 dearmor 驗章；裸 JSON 舊照仍可上傳解析 | T-1.4 |

### FR-062.2 產品端驗照與狀態顯示（階段 2，只讀不執法）— 依賴：FR-062.1

| # | 子任務 | 實作內容 | Repo | 驗收 | 依賴 |
|---|--------|----------|------|------|------|
| T-2.1 | 驗證引擎＋`tenant_licenses` | migration 建 `tenant_licenses`（照原文＋解析快取＋狀態＋`is_current` 歷史）；驗證引擎（公鑰列表 kid 查表、`issued_at` 不可在未來、時鐘回撥防護——記錄見過的最大時間戳）；上傳／驗章 API；config 新增 `DEPLOYMENT_MODE`（`saas`／`host`） | BE | 上傳階段 1 簽的照 → 解析欄位與狀態正確入庫；竄改照上傳被拒且留證 | T-1.1／T-1.2 |
| T-2.2 | root 後台授權管理 API | 各租戶授權狀態總覽、單租戶詳情（現處階段／模組集／到期日）、SaaS 指派寫入（換發 replace、舊照轉歷史）、手動立即停權；守門走 platform-admin 軸 | BE | root 帳號可查全租戶授權狀態；指派新照即改即生效（升級 Plan 立即生效） | T-2.1 |
| T-2.3 | FE License 管理頁＋授權狀態頁 | root 後台 License 管理頁（總覽列表＋上傳＋指派表單）；租戶側授權狀態頁（自己的模組集／到期日／現處階段） | FE | 頁面完整呈現階段 1 的照內容；上傳流程可走通 | T-2.1／T-2.2 |
| T-2.4 | root tenant 授權狀態頁顯示修正（驗收回饋追加） | `GET /license/status` 回應補 `is_root_tenant` 旗標（沿用既有 platform-admin 軸判定，非硬編租戶 id）；「我的授權狀態」頁對 root tenant 顯示「本租戶為系統管理租戶，不受 License 授權管控」，不再顯示誤導性的「尚未授權」空狀態；此旗標同時供 T-3.2 全域橫幅判斷是否對該租戶顯示到期橫幅 | BE＋FE | root 帳號查頁面／API 見旗標為 true 且顯示特權租戶說明；一般租戶行為不變 | T-2.2／T-2.3 |

### FR-062.3 狀態機排程與橫幅（階段 3，仍不擋操作）— 依賴：FR-062.1＋.2

| # | 子任務 | 實作內容 | Repo | 驗收 | 依賴 |
|---|--------|----------|------|------|------|
| T-3.1 | 每日 cron 狀態機 | `core/scheduler.py` 新增每日 cron：按各租戶照內 `expiry_policy` 推轉 `valid → notice → grace → readonly →（若啟用）locked`；換發回 valid；狀態查詢 API 供 FE 橫幅取用 | BE | 簽一張短效照（2 天）觀察逐日狀態轉換正確、各段開關組合（關 grace／關 lockout）行為符合 §4.5 語意表 | T-2.1 |
| T-3.2 | FE 到期橫幅 | 依現處階段與照內 `show_banner` 旗標顯示：notice 提醒、grace 醒目警告、readonly 唯讀說明；逐段可關 | FE | 短效照走到各階段時橫幅樣式與文案正確；`show_banner: false` 的段不顯示 | T-3.1 |

### FR-062.4 執法進場（階段 4，帶環境開關可整體關閉）— 依賴：FR-062.1–.3

| # | 子任務 | 實作內容 | Repo | 驗收 | 依賴 |
|---|--------|----------|------|------|------|
| T-4.1 | authz 第六軸＋環境開關 | `common/authz/license.py`（guard＋decorator＋`flask.g` memoize，照抄 `capability.py` 樣板）；root tenant 無條件豁免；執法總開關（環境變數）；子租戶額度 `max_sub_tenants` 檢查 | BE | 測試租戶未授權模組 API 403、授權模組通；開關關閉全放行；root 不受限 | T-3.1 |
| T-4.2 | `api/grc`／`api/project` 補守門 | 兩模組現況零 capability 守門（技術債），補上 capability＋license 雙層守門 | BE | 「沒買 project」的租戶在 API 層被擋（非只有選單看不到） | T-4.1 |
| T-4.3 | 唯讀 gate＋動態過濾＋清單退役 | readonly／locked 全域攔寫入回 403＋豁免清單（License 上傳、登入）；`GrcJobType` 任務型態按授權動態過濾（serializers 兩處）＋`job_import_service.VALID_JOB_TYPES` 同步修（補 `detection_tool`）；ui_route 選單過濾；`TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES` 退役（⚠️ 不可誤關 flow engine 引擎） | BE | readonly 租戶所有寫入 403、可登入可下載匯出、可上傳新照；沒買問卷包的租戶任務型態下拉無問卷；未授權模組選單消失 | T-4.1 |
| T-4.4 | FE 唯讀 UI | readonly 狀態下寫入類按鈕反灰＋原因提示（不藏、標 disabled）；未授權模組入口隱藏（吃 BE 選單過濾為主，FE 補殘餘入口） | FE | 唯讀租戶操作介面全反灰但可瀏覽下載；灰化原因可見 | T-4.3 |

### FR-062.5 開通流程（階段 5，兩張 case：離線先行）— 依賴：FR-062.1＋.2（可與 .3/.4 並行）

| # | 子任務 | 實作內容 | Repo | 驗收 | 依賴 |
|---|--------|----------|------|------|------|
| T-5.1 | **case A：離線開通（先行）** | 機器指紋計算模組；首次登入偵測無照導向開通頁；開通頁顯示本機機器碼；上傳綁機照時驗章＋核對指紋；FE 開通頁 | BE＋FE | 走完整離線一輪：抄機器碼 → 簽發端簽綁定照 → 上傳 → 驗指紋生效；指紋不符的照被拒 | T-2.1 |
| T-5.2 | **case B：線上開通**（首腦裁示改「不等雲端，local 落地」，見下方說明） | LC 側新增公開端點 `POST /api/activation/activate`（不掛後台 session）：序號存在／未用／未過期三查＋防濫用鎖定，呼叫簽章引擎簽出綁機照並回寫發照紀錄；BE 新端點 `POST /license/activation/online`（讀 `LICENSE_ACTIVATION_SERVER_URL` 環境變數代打 LC，收到照仍走自己完整驗章，不因來源是開通伺服器就信任）；FE 開通頁加序號輸入路徑，與離線路同頁並存 | 簽發站＋BE＋FE | 全 local 驗證通過：有效序號一鍵開通成功／錯誤序號 404／已用序號 409／LC 斷網 fallback 導向離線開通 | T-5.1（共用開通頁與指紋模組） |

### FR-062.6 簽發 Web 後台（階段 6）— 依賴：FR-062.1

| # | 子任務 | 實作內容 | Repo | 驗收 | 依賴 |
|---|--------|----------|------|------|------|
| T-6.1 | 客戶總覽頁 | Flask 頁：每客戶一列（代碼／名稱／訂單／型態／Plan／模組集／到期日／推算狀態／開通狀態），臨期排序上色；高頻換綁警訊標記 | 簽發站 | 總覽頁核對既有發照紀錄一致；臨期客戶排前且上色 | T-1.2 |
| T-6.2 | 簽發／換發表單頁 | 選 Plan（自 `plans` 表）一鍵帶入可微調 → 產照＋序號；展延一鍵（原照改到期日、`type: extension`）；補發一鍵（重出同照）；換綁受理（舊指紋作廢留歷史）；Plan 維護（增改 `plans` 表） | 簽發站 | 表單簽一張照與 CLI 簽出結果等價（同引擎）；展延照只有到期日不同；改 Plan 後新發照吃新內容、舊照不動 | T-6.1 |
| T-6.3 | 簽發後台照檔下載（驗收回饋追加） | 結果頁下載按鈕（`armor_license`／`.license`）；總覽頁重下載（`get_if_current` 守門，僅現行有效紀錄可下載）；Tabler UI 套版（§4.8） | 簽發站 | 結果頁下載可正確驗章；總覽頁對已作廢紀錄下載被拒並提示 | T-6.2 |
| T-6.4 | 總覽詳情頁＋列操作收 Menu（驗收回饋追加） | 新路由 `/licenses/<license_id>`（重用簽發結果頁樣板，非現行照也可查詢供稽核，但下載鈕僅現行照顯示、歷史筆顯示「已被取代」標記＋接替筆連結）；總覽頁每列客戶代碼／名稱做成詳情頁連結；列尾動作（下載／展延／補發／換綁）收成 Tabler dropdown Menu，新增「查看詳情」項 | 簽發站 | 總覽點列進詳情頁；歷史筆無下載鈕且顯示已被取代提示；Menu 五項可用；下載守門邏輯未變（仍走 `get_if_current`） | T-6.3 |

### FR-062.7 通知與既有租戶發照 migration（階段 7，migration 為上版前最後動作）— 依賴：FR-062.3

| # | 子任務 | 實作內容 | Repo | 驗收 | 依賴 |
|---|--------|----------|------|------|------|
| T-7.1 | email 到期通知 | notify 段 `send_email` 旗標接既有 email 機制，通知租戶管理員；逐段旗標控制 | BE | 短效照進 notice 段觸發通知信；`send_email: false` 不發 | T-3.1 |
| T-7.2 | **既有租戶發照 migration** | 上線 migration 為既有租戶（**root tenant 除外**）**發正常格式的照**：全模組、效期約一年、`type: formal`（D14 最終修正——**無過渡照、無豁免分支**，執法第一天全量生效）；DEV／STG 內部環境發長效內部照 | BE＋簽發站 | DEV 先跑 migration：全部非 root 租戶各有一張現行照、驗章通過、功能照常；無照租戶路徑（新開租戶未指派）行為正確 | T-7.1（順序上最後）；**套 STG／POC 需決策者當次放行** |

### FR-062.8 角色權限矩陣 license 對齊（階段 8，驗收回饋追加）— 依賴：FR-062.4

FR-062.4 執法上線後，選單、任務型態選擇器、API 端點三處都已受 license 控管，但**角色權限矩陣完全沒有 license 維度**：只買基礎包的租戶，管理員在角色編輯頁仍看得到且勾得下去問卷／AI 儀表板／檢測工具的能力點，勾了會真的寫進 `role_capabilities`，但選單不顯示、API 照樣 403——形成「可以授予一個不存在的權限」的困惑（與 Jira 可把 unlicensed user 加進 project role 的知名困惑同型）。

業界對照（2026-08-10 調查）三種模式：**A 反灰＋原因**（Frontegg／Schematic 建議做法，Salesforce 為極致版——permission 勾不到時會明確報缺哪張 license）／**B 整功能隱藏**（GitLab custom roles 為 Ultimate 專屬）／**C 讓你勾但無效**（Jira，公認的困惑來源）。**本案採 A**。

既有可借鏡樣板：`is_platform` 已是同形狀的雙層縱深（`ui_route_route.py` UI 層過濾 ＋ DB trigger `trg_role_capabilities_platform_guard`）。license 軸照抄同一形狀，判定條件從「是不是平台能力點」換成「租戶買了沒」。

::: {.callout .decided}
**存量資料策略：evaluate-time 抑制（決策者 2026-08-10 拍板）**

已授予但現在未授權的能力點**保留在 DB**，執法在 request time 由軸⑥完成，四張子任務一律不做存量資料清理。理由：業界共識（Frontegg／Schematic 的降級模型、Salesforce PSL 移除後 permission 記錄仍在）是不做破壞性移除——客戶降級後又升回、換照、展延照時，破壞性移除會讓權限一去不復返、得靠客服手動重建；evaluate-time 抑制非破壞性，重新買回來當下即恢復。

被排除方案：上傳新照時實際清掉未授權的 `role_capabilities`（破壞性、不可逆、升級回來要人工重建）。
:::

::: {.callout .decided}
**UI 採反灰不隱藏——既有 license UI 原則的一個例外**

`useLicenseReadonly.js` 現行定調為「未授權＝隱藏（沒買就是沒這功能）、唯讀＝反灰（買了但過期）」。**角色矩陣是這條原則的例外**：管理員在此是配置權限而非使用功能，反灰＋原因比憑空消失有用（業界 show-and-disable 模式的理由）。此例外需在程式碼註解寫明，避免日後有人「照原則修正」改回隱藏。
:::

::: {.callout .decided}
**驗收回饋修正：「已授予但未生效」不打勾（2026-08-10）**

T-8.2 初版把「未授權但 DB 已有存量授予」渲染成**打勾＋反灰＋「已授予但未生效」標籤**，決策者驗收後推翻。理由：矩陣的勾勾在管理員認知裡回答的是「這個角色現在有沒有這個權限」，答案是否就不該打勾——打了再貼標籤解釋「其實用不了」，等於讓勾勾說謊再貼紙條道歉，**與本子需求要解決的 Jira 困惑同型**（只是把疑問從「為什麼我勾得下去」搬到「為什麼它勾著卻沒用」）。

且該狀態實為**邊緣狀態**：新租戶被塞未授權能力點已由 T-8.4 修掉、手動勾選已由 T-8.3 擋住，剩下的只有舊 bug 髒資料與降級殘留兩種來源，初版把它放大成顯眼的主要狀態，比例失衡。

定案：未授權一律**反灰空框**，與從未授予過的項目外觀完全相同；DB 裡留著資料是**系統的內部承諾**（買回該模組即自動恢復），不需要用一個假的勾勾表達，講在 tooltip 就夠（存量者多一句「先前的設定已保留，購買後會自動恢復」）。文案一併去除「授權照」等內部術語，統一講「購買此模組」。**evaluate-time 抑制機制與存量原樣送回 BE 的行為完全不動**，純渲染層調整。
:::

| # | 子任務 | 實作內容 | Repo | 驗收 | 依賴 |
|---|--------|----------|------|------|------|
| T-8.1 | `GET /ui-routes` 附 license 標記 | `api/auth/routes/ui_route_route.py` 每個 capability 附 `is_licensed`（比對 `viewer_licensed_modules()`）；**不移除任何 route 或 capability**，移除與否是 FE 的顯示決策；⚠️`viewer_licensed_modules()` 回 `None` 代表不過濾（root／總開關關閉），此時一律 `true`——**寫成 `in (licensed or set())` 會讓 root 全變 false，是本支最易踩的坑**；serializer `api/auth/serializers/ui_route.py` 同步 | BE | root 全 `true`；只買基礎包的租戶 survey／ai-dashboard／detection 三件組／cloud_integration 為 `false`；總開關關閉時全 `true`；route 數量與現況一致 | FR-062.4 T-4.1 |
| T-8.2 | FE 角色矩陣未授權項反灰＋原因 | 吃 T-8.1 的 `is_licensed`：未授權且未勾 → disabled ＋ tooltip 說明原因；**未授權但 DB 已有（存量）→ 標「已授予但未生效」且不可取消勾選**（單純反灰成未勾會讓管理員以為資料掉了；可取消則等同做了破壞性移除）；`is_licensed` 缺席時 fail-open 當 `true`；i18n 兩語系 | FE | 基礎包租戶見反灰＋原因；DB 手塞未授權能力點時顯示「已授予但未生效」且不可取消；root／總開關關閉時全可勾；暗色主題下可辨識 | T-8.1 |
| T-8.3 | `POST/PUT /roles` 寫入端 license 守門 | `api/auth/routes/role_route.py` 兩支現況對 payload `capabilities` 零檢查（FE 反灰繞得過）；含未授權模組 → `ForbiddenError` ＋ 專屬 error code（`common/code/license_error_code.py`，語意須區分於 `LICENSE_MODULE_NOT_LICENSED`）＋ FE `error-code.json` 兩語系同步；**只擋本次新帶進來的未授權能力點**，角色原本就有、本次沒動的不可因此整個請求被拒（否則連改角色名字都做不到）；解析 capability→resource_type 走 domain service 不用 `CapabilityService`（後者 DTO 缺欄位，`tenant_provisioning_service` 已踩過）；DB trigger 縱深自行評估（傾向不加，理由見卡） | BE | 帶未授權能力點 → 403 ＋ 新碼；更新原本就含未授權能力點的角色只改名字 → 成功；已授權模組正常；root／總開關關閉時全成功 | FR-062.4 T-4.1 |
| T-8.4 | 開通租戶預設角色扣未授權模組 | `app/auth/service/tenant_provisioning_service.py` 現況扣 `TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES` ＋ `is_platform` 兩道，加第三道扣未授權模組；**⚠️雞生蛋**：開通當下該租戶通常無照，而 `viewer_licensed_modules()` 無照 fail-closed 回空集，照抄會讓新租戶管理員角色一個能力點都拿不到；兩解擇一——**A 無照＝不扣**（fail-open，provisioning-time 非執法點、真正的擋在軸⑥，**建議**）／B 時機後移到上傳照之後重算；建子租戶時 context 為父租戶管理員、讀到頂層持照租戶的照，此為正確（CM-1156）；**不做既有租戶資料回溯** | BE | 父租戶只買基礎包 → 子租戶新角色不含加值包能力點；父買問卷包 → 含 survey；root 建頂層租戶行為不變；總開關關閉行為不變；**無照租戶建子租戶不被扣成空權限**（最重要回歸）；建租戶流程不炸 | FR-062.4 T-4.1 |
| T-8.5 | License 選單結構調整（驗收回饋追加） | 三件事一支 migration（`2026-08-10-fr062-8-license-menu-restructure.sql`）：①`/license/manage` 原本**不綁任何 capability**（FR-062.2 階段守門走 platform-admin route decorator），導致「這是平台專屬頁」在 DB 層沒有表達、只能靠 BE `menu_license_filter.PLATFORM_ADMIN_ONLY_MENU_URLS` 與 FE `RoleForm.PLATFORM_ROUTE_URLS` 兩份硬編清單各擋一次——而 FE 那份**本來就漏了本頁**，證明硬編會持續漏第三處；改新增 `license.read`（`resource_type='license'`／`is_platform=true`／requirement=ALL）綁 route 並授予 root tenant 角色，兩條既有正規路徑（選單的 ALL 規則、角色矩陣的 `get_platform_only_route_ids()`）自動生效，兩份硬編同步移除。**只加單一 read 不加 CRUD 四件組**（比照 `log.read` 先例）：寫入端已由 `require_platform_admin_route` 擋，多造三支沒有 `@require_capability` 引用的能力點即死碼。②新增 `group-license`（sort=45，夾在 `group-tenant-org` 40 與 `group-system-admin` 50 之間），License 兩頁 `pid` 改掛之；非 root 群組底下只有「我的授權狀態」一項，屬已接受結果。③`customer-dashboard-editor`（未完成功能，`pid=0` 那批舊路由中唯一還 `enable=1` 的一支）入口關閉，`role_capabilities` 既有授予刻意保留不動（同全案 evaluate-time 抑制精神） | BE＋FE | 非 root 角色矩陣與選單皆不見 license-manage；root 兩者皆見且可勾；選單出現「授權管理」群組於正確位置；建子租戶不炸（`is_platform` trigger 未誤傷 provisioning）；自定義儀表板入口消失 | FR-062.8 T-8.1 |

**整體驗收**：以只買基礎包的測試租戶驗——矩陣頁反灰＋原因可見、存量項標「已授予但未生效」、繞過 FE 直打 API 得 403、新開子租戶預設角色不含未授權模組、上傳含 survey 的新照後重整即可勾選（無需重啟或資料修復）、root 與總開關關閉時行為與現況完全一致。

**統計**：8 子需求 × 28 子任務（初版 17 ＋ 驗收回饋期追加 11：T-1.3／T-1.4／T-1.5／T-6.3／T-2.4／T-6.4，以及 FR-062.8 五支 T-8.1～T-8.5（角色矩陣 license 對齊，2026-08-10 驗收回饋），另 `expiry_policy` v2.1 格式升級併入 T-7.1 未另列）；BE 19、FE 8、簽發站新 repo 11（部分子任務跨 repo，重複計入）。

---

## 6. 模組分層與 Plan 預設資料 {#modules nav="模組分層"}

分層邊界依 BE 程式碼相依盤點（2026-08-08 唯讀分析）實證劃定：**基礎包缺一系統不成立，加值包關掉系統仍完整**。本節內容為簽發站 `plans` 表與模組分層的 **seed 資料**——上線後屬簽發端商務設定，後台可調，非產品端寫死。

### 基礎包（隨基本授權附，21 模組）

`project`、`module-frame`、`compliance-framework`、`workflow`、`flow_template`、`audit`、`device`、`information-system`、`dashboard`、`report`、`project-summary-report`、`user`、`role`、`department`、`tenant`、`bulletin`、`bulletin-list`、`feedback`、`feedback-view`、`notify_config`、`storage-config`

關鍵相依證據：開專案 hard 依賴 module-frame 三件組 clone（`app/project/service/project_start_app_service.py:335-344`）；flow engine 是任務執行骨幹（`app/grc/service/prep_job_generation_service.py`）；device／information-system 是 SSP 受評範圍支撐資料（`app/grc/service/project_service.py:299-316`）；dashboard SQL 串 6 模組資料（`infra/grc/repository/grc_dashboard_repo_impl.py:185-201`）。

### 加值包（4 包）

| 加值包 | 內含 resource_type | 邊界實證 |
|---|---|---|
| **問卷包** | `survey` | 未授權時把任務型態選單過濾掉即可，不影響其他任務型態 |
| **檢測工具包** | `remote-agent-manage`＋`detection-profile`＋`plugin`（**三合一不拆賣**） | detection 任務鏈三者 hard 綁定（`app/grc/service/job_service.py:131-135`、`detection_orchestration_service.py`） |
| **雲端整合包** | `cloud_integration` | 邊界最乾淨：Drive 為 best-effort 同步層，證據上傳有本地保底（`app/flow_engine/service/job_evidence_service.py:85-88`） |
| **AI 儀表板包** | `ai-dashboard` | 技術獨立；未授權時選單／入口過濾 |

### 不販售

- **移除**：`resource`（死模組）、`cruise-project`（舊架構殘留）。
- **平台級**（僅 root 使用、不販售）：`issue-integrate-config`、`ldap-config`、`smtp-config`、`log`、`system-menu`、`system_config`。

既有 35 個 capability 粒度內的重組（把某模組換包、Plan 增減模組）**零工程成本**——只是簽發端資料異動；新增**新粒度**（例如把某模組再拆細）才需要產品端補守門。

---

## 7. 端到端驗收清單 {#acceptance nav="端到端驗收"}

以虛構客戶「宏遠科技」（`HY-001`）為素材，逐項可勾稽：

1. **Host 版發照與離線開通**：簽發後台選 Plan「Professional」產照（照內含展開模組集＋出廠預設 `expiry_policy`）＋序號、紀錄表新增一筆（含訂單編號／客戶代碼）→ 客戶首次登入被導向開通頁 → 抄機器碼交公司 → 簽綁定照回寄 → 上傳驗章通過全功能生效 → 紀錄表回寫「已開通＋指紋＋時間」。
2. **模組守門**：Professional 租戶看不到 AI 儀表板選單、打 AI 儀表板 API 403；已授權模組全部正常；任務型態下拉含問卷（有問卷包）；另一 Basic 租戶下拉無問卷。
3. **到期全過程（出廠預設）**：短效照驗證 到期前 30 天進 notice（橫幅＋email）→ 到期進 grace 14 天（醒目警告、全功能）→ 進 readonly：可登入、可看、可下載匯出，所有寫入 403；lockout 停用**不會**進一步鎖登入；期間任一天上傳新照立即回 valid。
4. **升級即生效**：SaaS 後台把租戶從 Basic 改指派 Professional → 不重啟、重新整理頁面即見問卷包生效；Host 版上傳升級新照當下生效、機器未變不重跑開通。
5. **政策改版不回溯**：簽發端把 grace 預設改 7 天 → 之後新照帶 7 天；存量照仍按各自照內 14 天跑完。
6. **臨時展延**：展延一鍵簽出 `type: extension` 照（只有到期日不同）→ 上傳後服務不中斷；紀錄表標展延、不計營收。
7. **防竄改**：直接編輯照檔改到期日 → 驗章失敗整張照無效、系統鎖定、本地留證（時間＋檔案指紋）；重新提供正確照恢復。
8. **時鐘回撥**：把系統時間倒轉 → 偵測異常；`issued_at` 在未來的照被拒收。
9. **子租戶額度**：開到第 6 個子租戶被擋（`max_sub_tenants: 5`）。
10. **補發／換綁**：照檔遺失一鍵補發同照可用；換機取新機器碼 → 換綁照生效、舊指紋作廢；紀錄表事件類別正確。
11. **既有租戶 migration**（DEV 驗）：跑完後全部非 root 租戶各持一張 formal 全模組照（效期約一年）、功能照常、執法路徑全量生效無特例分支。
12. **root tenant 永不受限**：root 在任何狀態下不受 license 守門與唯讀 gate 影響。
13. **執法總開關**：環境開關關閉後，未授權模組 API 放行（上線保險絲驗證）。

---

## 8. 設計理由（Why） {#why nav="設計理由"}

九項核心設計決策的「為什麼這樣做」，供日後回頭質疑或延伸設計時對照——每項是取捨後的結論，不是唯一可行解，但都經過明確理由篩選過。

1. **簽發端獨立系統**：私鑰不進產品，落地版整包程式碼在客戶手上也偽造不了照——這是整套授權體系的信任根基。私鑰若進了產品，客戶反組譯就能自己簽發，執法形同虛設。
2. **簽章不加密**：防竄改是數學保證（Ed25519 簽章比對），這件事「加密」做不到也不需要——Host 版客戶持有整包程式碼，解碼邏輯終究可逆向，加密只多一層金鑰管理成本、換不到真實安全增益，反而給人「內容保密」的假安全感。防止客戶隨手翻閱內容改採**打包**（v2 payload zlib＋base64），跟簽章防竄改是兩件獨立的事。
3. **`.license` armor 外皮**：純外觀對齊業界慣例（JetBrains `.key`／GitLab `.gitlab-license`／Keygen PEM 風格），裸 `.json` 檔顯得不夠專業；PEM 文字塊格式的附帶好處是 email 轉寄不容易被郵件系統破壞格式。
4. **Replace 換發制**：一個租戶任一時刻只有一張現行照，續約／升級／展延一律重簽整張新照替換。好處是狀態永遠是單一真相——不用處理「模組 A 到期日跟模組 B 不同」這種疊加計算的複雜度，客服與客戶理解成本都低。
5. **到期漸進管線（notify → grace → readonly）**：終態設計成永久唯讀、可登入可查看可匯出，不鎖登入。理由：續約談判時客戶資料仍在手上、公司有底氣好好談而不是被指控「扣押資料」；客戶資料不被綁架也降低法務糾紛風險——業界（GitLab／Atlassian／Elastic）做法一致，不是我們獨創的冒進設計。
6. **執法帶總開關**：整組執法邏輯可由一個環境變數整體關閉。理由是上線初期若誤擋到真實客戶（例如模組分層盤點漏了什麼），可以立刻整體熄火止血，不需要走回滾版本這種慢且風險更高的路徑。
7. **root tenant 永久豁免＋開通端點豁免唯讀**：兩個豁免防的是兩種不同的死鎖——root 若受 license 管控，公司自己都可能被鎖出系統；唯讀狀態若連 License 上傳端點都擋，租戶會陷入「唯讀了就再也無法上傳新照解除唯讀」的無法自救狀態。
8. **機器指紋只在 Host 版綁**：SaaS 環境系統本身就在公司自己機房，不需要額外綁機器；Host 版照跟著客戶硬體走，綁指紋是為了防止「一張照複製到多套部署」。
9. **模組授權沿用既有 capability 粒度**：license 的模組語彙直接對齊既有權限系統的 `capability.resource_type`（35 個），不另建一套 license 專屬語彙。好處是商務重組（把某模組換包、調整 Plan 內容）零工程成本，只有新增全新的授權粒度時才需要產品端補守門。實作期發現這個原則有個例外：`TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES`（見 §3）管的是「開通租戶時預設給不給這個能力點」，跟 license 管的「這個租戶商務上買了沒買」是不同維度的兩件事，不能簡單合併退役（詳見 CM-1126 裁決）。
