FR-062 · License Management — 設計定稿 · 2026-08-08

License 控管機制 — 設計定稿

公司內部自建簽發端(Ed25519 離線簽章)、產品端驗章執法、按功能模組授權、可組態到期管線、序號開通與機器指紋綁定。D1–D15 全數定案(含 discussion v2 後的最終修正:過渡照機制刪除簽發端定案自建Plan 為簽發端 DB 資料升級換發立即生效)。本文件含完整詳細設計與八階段拆分表(FR-062.1–.8 × 28 子任務,含驗收回饋期追加),供 Notion 開卡派工。

設計定稿 · D1–D15 全數定案 照型態只有 formal / trial / extension 簽發端自建(不採 Keygen) 8 子需求 × 28 子任務

狀態:設計定稿|建立日期:2026-08-08|討論稿:discussion.html(v2,含市場調查與完整推理過程)

變更紀錄

日期 變更 對應
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.readis_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. 需求背景與端到端流程

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

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

端到端流程

%%{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
圖 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
  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
圖 2 — 整體架構:公司簽發端(獨立 Flask 應用+CLI 引擎)與產品端的分工與交付物

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


2. 決策定案表(D1–D15)

十五項決策全數定案。標示「最終修正」者為 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_usersmax_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. 現況接入點盤點

本案掛進既有骨架,逐點盤點:

元件 現況 本案動作
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/grcapi/project 目前零 capability 守門(技術債) license 執法前必須補守門,否則「沒買 project」API 層擋不住(納入階段 4)
GrcJobType enum 寫死(common/enum/grc_job_type_enum.pyapi/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(值 saashost),供驗證引擎區分行為(如機器指紋是否必綁)

4. 詳細設計

4.1 License 檔格式

License 檔為簽章 JSON,欄位分五組:

分組 欄位 說明
識別 license_id 照的唯一識別碼
customer_code 客戶代碼(對齊發照紀錄表)
order_no 訂單編號(財務對帳鏈結)
issued_to 客戶名稱
tenant 綁定 綁定的頂層客戶租戶識別
type formaltrialextension三種,無 transitional
issuer 發照人
時間 issued_at 簽發時間(不可在未來,驗證時拒收)
starts_atexpires_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 saashost——同一 schema 同一引擎,僅以此欄位區分(host 有指紋+序號、saas 空)
完整性 kid 簽章金鑰識別碼,驗章時按 kid 查產品端公鑰列表
signaturealg Ed25519 簽章與演算法標記

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

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

檔案格式 v2:payload 打包(T-1.3,2026-08-08 起生效)

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

落地檔案信封改為:

{
  "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 風格文字塊包裝:

-----BEGIN GUIDANT LICENSE-----
<v2 信封整包 JSON,base64 編碼,64 字元換行>
-----END GUIDANT LICENSE-----
  • 純外皮層,非格式重簽:armor 包裹的是 v2 信封(format_version/kid/payload/signature/alg整包 JSON 的 base64 編碼,內層信封結構與簽章邏輯完全不動——armor_licensedearmor_license 只是「json.dumps → base64 → 加頭尾包行」與其逆操作,不碰 payload 打包或簽章演算法。
  • 兩 repo 各自獨立實作license_center.crypto.enginecommon.license.engine),維持既有「零依賴、僅透過簽好的照檔交互」原則,不新增套件依賴。
  • 產出端license_center CLI(issueextendreissue)與 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 掛掉時仍可純命令列操作。

兩頁

客戶總覽頁

每個客戶一列:客戶代碼/名稱/訂單編號/照型態/Plan/模組集/到期日/推算狀態/開通狀態。依臨期排序上色。Host 客戶顯示的是「按合約推算」狀態(照在客戶機房,簽發端按到期日與 expiry_policy 推算現處階段);SaaS 租戶為真即時。高頻換綁客戶標記警訊(防複製部署)。

簽發/換發表單頁

選 Plan 一鍵帶入模組集,可逐模組微調;填效期產照+序號;展延(extension)一鍵——抓原照只改到期日;補發一鍵重出同一張照;換綁人工受理。

獨立 DB schema 草案(兩張表起步):

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

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

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

欄位 說明
plan_codeplan_name 套餐識別與顯示名
modules 該套餐展開的 resource_type 集合(發照時一鍵帶入,可再微調)
is_active、排序、備註 營運維護欄位

seed 起點:Basic=基礎包;Professional=基礎包+問卷包+雲端整合包;Enterprise=全模組。改 Plan 只影響之後發的照(照內存展開後清單,範本改動不回溯存量)。

4.4 產品端資料模型:tenant_licenses

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

4.5 狀態機與排程

%%{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 — 鎖登入,僅管理員可進上傳頁(預設停用,可組態)
圖 3 — 可組態到期管線(D5):實線為出廠預設路徑(終態=永久唯讀),lockout 段預設停用

各段開關語意:

組態 行為語意
關閉 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_MODEsaashost)決定機器指紋是否必驗

4.7 開通流程(三路)

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

%%{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
圖 4 — 送照時序:SaaS 向 LC 請照+Host 版線上/離線開通(D10/FR-062.10)

兩路共用同一張照格式與同一套驗證引擎,差別只在交付方式。機器綁定在開通當下完成;日後續約換照機器不變即不必重跑開通。線上開通伺服器掛雲端環境(定案不變);但「開發+全流程驗證」與「公網部署」已拆開處理——線上開通伺服器端點已在 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)、environmenttoken_hash(不存明文)、is_active(撤銷用停用不刪列)
  • 每個產品環境(DEV/STG/正式站)各發一把 token;LC 端發照紀錄 issuer 欄位寫 token_name,帳本上每張照看得出由哪個環境請發
  • 產品端請求帶 token(header),LC 端驗證比對白名單後才呼叫簽發邏輯

三支 LC 內部 API(新 blueprint,比照既有 activation 無 session+passphrase 環境變數自動解鎖 pattern,直接呼叫既有純函式 issue_licenseextend_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 補發與換綁

情境 A:照檔弄丟(機器沒變)

發照紀錄表存有完整照內容,一鍵重出同一張照(同內容、同指紋)交付。紀錄「補發」事件,不影響營收報表。

情境 B:換機(指紋變了)

新機走一次開通流程取新機器碼 → 公司簽出同內容、改綁新機的照 → 紀錄「換綁」事件,舊指紋留歷史並作廢。

防複製政策:換綁人工受理(受理時確認舊機已停用,不提供自助換綁);同一客戶頻繁換綁是警訊,簽發端總覽頁標記提示。

簽發站 Web 設計(驗收回饋補充)

  • UI 框架:採 Tabler(MIT,Bootstrap 5 基礎)。靜態資產 pin 版下載後放入 static/vendor/,不依賴外部 CDN、不引入前端 build 鏈——與簽發站「極簡 Flask、獨立部署」定位一致。
  • 簽發 passphrase 自動解鎖:私鑰解密優先讀 .envLICENSE_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 保管方式:開發/內部部署階段得直接存放簽發站 .envLICENSE_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-twen 兩語系);簽發端 LC 後台介面中文化。

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


5. 拆分表(FR-062.1 ~ FR-062.8 × 28 子任務)

拆分原則(決策者明確要求)

每階段自帶可觀察產出與人工測試素材(測試資料由前一階段產出,不依賴未完成的後續階段);階段 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 四段、kidplandeployment_modemachine_fingerprinttype 三值、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_typedeployment_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_MODEsaashost 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/grcapi/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 軸照抄同一形狀,判定條件從「是不是平台能力點」換成「租戶買了沒」。

存量資料策略:evaluate-time 抑制(決策者 2026-08-10 拍板)

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

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

UI 採反灰不隱藏——既有 license UI 原則的一個例外

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

驗收回饋修正:「已授予但未生效」不打勾(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_TYPESis_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.readresource_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 預設資料

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

基礎包(隨基本授權附,21 模組)

projectmodule-framecompliance-frameworkworkflowflow_templateauditdeviceinformation-systemdashboardreportproject-summary-reportuserroledepartmenttenantbulletinbulletin-listfeedbackfeedback-viewnotify_configstorage-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-managedetection-profileplugin三合一不拆賣 detection 任務鏈三者 hard 綁定(app/grc/service/job_service.py:131-135detection_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-configldap-configsmtp-configlogsystem-menusystem_config

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


7. 端到端驗收清單

以虛構客戶「宏遠科技」(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)

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

  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 裁決)。