FR-062 · License Management — 設計定稿 · 2026-08-08
公司內部自建簽發端(Ed25519 離線簽章)、產品端驗章執法、按功能模組授權、可組態到期管線、序號開通與機器指紋綁定。D1–D15 全數定案(含 discussion v2 後的最終修正:過渡照機制刪除、簽發端定案自建、Plan 為簽發端 DB 資料、升級換發立即生效)。本文件含完整詳細設計與八階段拆分表(FR-062.1–.8 × 28 子任務,含驗收回饋期追加),供 Notion 開卡派工。
狀態:設計定稿|建立日期: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.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 母案 |
Guidant AI 即將以兩種模式對外銷售:雲端 SaaS(公司自營多租戶環境,授權後台指派、即改即生效)與落地部署 Host 版(整套系統進客戶機房,授權必須以不可竄改的憑證檔隨產品交付、可離線驗證)。兩種模式共用同一套 License 控管機制,核心需求:
%%{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
%%{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 版由客戶上傳照——驗證引擎、守門、狀態機完全共用。
十五項決策全數定案。標示「最終修正」者為 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 起點,後台隨時可調。
本案掛進既有骨架,逐點盤點:
| 元件 | 現況 | 本案動作 |
|---|---|---|
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),供驗證引擎區分行為(如機器指紋是否必綁) |
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 只是簽發紀錄與照內容用來對照客戶的標籤,不是產品端驗證邏輯的比對鍵。現行照檔(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 編碼的字串。payload 任一 byte,解壓會失敗或還原出的 JSON 對不上簽章,兩者皆判定驗章失敗——防竄改能力與 v1 完全等價。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,非本階段範圍。.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-----
format_version/kid/payload/signature/alg)整包 JSON 的 base64 編碼,內層信封結構與簽章邏輯完全不動——armor_license/dearmor_license 只是「json.dumps → base64 → 加頭尾包行」與其逆操作,不碰 payload 打包或簽章演算法。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,避免客戶轉寄/複製貼上破壞格式。accept 屬性加 .license,判斷邏輯以副檔名為準(非標準副檔名瀏覽器 MIME type 判定不可靠);FileReader.readAsText 流程不變,讀出文字後偵測 BEGIN 行——armor 內容原樣以字串送到 BE(不在前端解包),裸 JSON 則沿用既有 JSON.parse。cryptography 庫),格式設計參考 Keygen 公開做法。[{kid, pubkey}],不放設定檔、不放 DB;照內帶 kid,驗章時按 kid 查表。issued_at 晚於當下系統時間即拒收。形態:極簡 Flask 應用(與主產品同技術棧)、獨立 repo、獨立 DB、部署公司內部不對外。Web 是殼、CLI 是引擎,兩者共用同一簽章引擎模組;air-gapped 簽發、Web 掛掉時仍可純命令列操作。
兩頁:
每個客戶一列:客戶代碼/名稱/訂單編號/照型態/Plan/模組集/到期日/推算狀態/開通狀態。依臨期排序上色。Host 客戶顯示的是「按合約推算」狀態(照在客戶機房,簽發端按到期日與 expiry_policy 推算現處階段);SaaS 租戶為真即時。高頻換綁客戶標記警訊(防複製部署)。
選 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 只影響之後發的照(照內存展開後清單,範本改動不回溯存量)。
tenant_licenses| 欄位群 | 欄位 | 說明 |
|---|---|---|
| 綁定 | tenant_id |
頂層客戶租戶 |
| 照本體 | license 原文 | 逐次驗章依據(不信任解析快取) |
| 快取 | 解析後欄位快取 | 模組集、到期日、expiry_policy 等,供守門快速讀取 |
| 狀態 | valid / notice / grace / readonly / locked |
每日排程更新;哪些狀態可達由照內 expiry_policy 決定 |
| 歷史 | is_current 旗標(或歷史表) |
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'}}}%%
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。| 執法點 | 設計 |
|---|---|
第六軸 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)決定機器指紋是否必驗 |
序號/機器碼開通僅 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
兩路共用同一張照格式與同一套驗證引擎,差別只在交付方式。機器綁定在開通當下完成;日後續約換照機器不變即不必重跑開通。線上開通伺服器掛雲端環境(定案不變);但「開發+全流程驗證」與「公網部署」已拆開處理——線上開通伺服器端點已在 local 落地並全流程驗證通過(BE 代打 LC 側公開 API,序號存在/未用/未過期三查+防濫用鎖定),公網曝露/TLS/網域/防火牆等純部署動作留待雲端環境就緒後補驗(T-5.2,見 §5)。
背景:原設計圖 4「路徑 0」把 LC 與產品 root 後台畫成同一個 participant,讀起來像「產品後台按一下、後面自動去簽發引擎拿照」,但兩者是獨立系統獨立 DB——實際上 SaaS 續約/加購/展延時,admin 必須先在 LC 端簽發下載 .license 檔,再切回產品後台上傳,兩趟手動搬檔。本設計補上一條產品端直接打 LC 內部 API 的線,取代人工搬檔,圖 4 已同步修正。
認證:API token 白名單機制(不複用 Host 版 activation 的一次性序號,因為簽發 API 沒有對應憑證):
api_tokens 白名單表:token_name(來源名稱,如 product-dev/product-prod)、environment、token_hash(不存明文)、is_active(撤銷用停用不刪列)issuer 欄位寫 token_name,帳本上每張照看得出由哪個環境請發三支 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_extendlc/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。
發照紀錄表存有完整照內容,一鍵重出同一張照(同內容、同指紋)交付。紀錄「補發」事件,不影響營收報表。
新機走一次開通流程取新機器碼 → 公司簽出同內容、改綁新機的照 → 紀錄「換綁」事件,舊指紋留歷史並作廢。
防複製政策:換綁人工受理(受理時確認舊機已停用,不提供自助換綁);同一客戶頻繁換綁是警訊,簽發端總覽頁標記提示。
static/vendor/,不依賴外部 CDN、不引入前端 build 鏈——與簽發站「極簡 Flask、獨立部署」定位一致。.env 的 LICENSE_CENTER_KEY_PASSPHRASE 環境變數,設定後簽發/展延/補發表單免手動輸入。表單 passphrase 欄位僅於未設環境變數時顯示(air-gapped 備援:私鑰與簽發站不同機、或刻意不落地環境變數的場景);輸入的 passphrase 只在單次請求記憶體內用於解密,不落地、不存 session。armor_license(.license armor 格式,§4.1 v3);總覽頁重下載走 get_if_current 守門(僅現行有效紀錄可重下載,作廢舊照不提供),輸出完整 v2 信封原文(不重簽)。/licenses/<license_id>,重用簽發結果頁樣板),非現行照(已被展延/補發/換綁取代)也可查詢供稽核,但畫面顯示「已被取代」提示+接替筆連結、不顯示下載鈕(下載守門規則不變);列尾原本平鋪的動作按鈕(下載/展延/補發/換綁)收成單一 Tabler dropdown Menu,新增「查看詳情」項。license_issuance 同步備份。私鑰遺失且無備份=無法再簽發與續約——備份紀律是營運前提,不是建議。.env 的 LICENSE_CENTER_KEY_PASSPHRASE(與私鑰檔同機),供 Web 自動解鎖(§4.8);正式對外簽發仍須密碼管理器保管+兩處站外加密備份,.env 存放僅限私鑰與簽發站同機、風險可控的內部場景,不可作為正式營運的唯一保管方式。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 授權管控」;此旗標同時供全域到期橫幅判斷是否對該租戶顯示。
拆分原則(決策者明確要求)
每階段自帶可觀察產出與人工測試素材(測試資料由前一階段產出,不依賴未完成的後續階段);階段 1–3 對現有系統零侵入,中途暫停不留風險;執法最後進場且帶環境開關。子任務只准依賴前面階段。階段 5 開通依 user 明示拆兩張 case(離線先、線上後)。
依賴鏈:.1 → .2 → .3 → .4;.5 依賴 .1+.2(與 .3/.4 可並行);.6 依賴 .1;.7 依賴 .3(+.2 的表)、migration 為上版前最後動作;.8 依賴 .4。
| # | 子任務 | 實作內容 | 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 |
| # | 子任務 | 實作內容 | 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 |
| # | 子任務 | 實作內容 | 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 |
| # | 子任務 | 實作內容 | 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 |
| # | 子任務 | 實作內容 | 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(共用開通頁與指紋模組) |
| # | 子任務 | 實作內容 | 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 |
| # | 子任務 | 實作內容 | 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.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_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,重複計入)。
分層邊界依 BE 程式碼相依盤點(2026-08-08 唯讀分析)實證劃定:基礎包缺一系統不成立,加值包關掉系統仍完整。本節內容為簽發站 plans 表與模組分層的 seed 資料——上線後屬簽發端商務設定,後台可調,非產品端寫死。
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)。
| 加值包 | 內含 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(舊架構殘留)。issue-integrate-config、ldap-config、smtp-config、log、system-menu、system_config。既有 35 個 capability 粒度內的重組(把某模組換包、Plan 增減模組)零工程成本——只是簽發端資料異動;新增新粒度(例如把某模組再拆細)才需要產品端補守門。
以虛構客戶「宏遠科技」(HY-001)為素材,逐項可勾稽:
expiry_policy)+序號、紀錄表新增一筆(含訂單編號/客戶代碼)→ 客戶首次登入被導向開通頁 → 抄機器碼交公司 → 簽綁定照回寄 → 上傳驗章通過全功能生效 → 紀錄表回寫「已開通+指紋+時間」。type: extension 照(只有到期日不同)→ 上傳後服務不中斷;紀錄表標展延、不計營收。issued_at 在未來的照被拒收。max_sub_tenants: 5)。九項核心設計決策的「為什麼這樣做」,供日後回頭質疑或延伸設計時對照——每項是取捨後的結論,不是唯一可行解,但都經過明確理由篩選過。
.license armor 外皮:純外觀對齊業界慣例(JetBrains .key/GitLab .gitlab-license/Keygen PEM 風格),裸 .json 檔顯得不夠專業;PEM 文字塊格式的附帶好處是 email 轉寄不容易被郵件系統破壞格式。capability.resource_type(35 個),不另建一套 license 專屬語彙。好處是商務重組(把某模組換包、調整 Plan 內容)零工程成本,只有新增全新的授權粒度時才需要產品端補守門。實作期發現這個原則有個例外:TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES(見 §3)管的是「開通租戶時預設給不給這個能力點」,跟 license 管的「這個租戶商務上買了沒買」是不同維度的兩件事,不能簡單合併退役(詳見 CM-1126 裁決)。