FR-132 · Access Management — 設計文件 · 2026-10-06
誰 × 角色 × 範圍 × 時效,公司層補完
把公司層權限補成 Azure 式的四段模型:三段式能力點+萬用字元角色、部門範圍與起訖日真的進判定、33 支漏網 API 補守門、存取管理前端四頁、角色 JSON 匯出匯入,外加一個預設關閉的部門資料隔離開關。D1–D12 已定案;專案層成員角色合併是 FR-B,另開。
狀態:設計定案(D1–D12 已拍板)|日期:2026-10-06|討論稿(含全部流程圖與現況細節):
discussion.html|母案:CM-2510(子卡 CM-2511~CM-2541) 前作:FR-048 統一授權守門(common/authz/)、FR-062 License 控管(授權照模組對照)、FR-069 jedi-iam 套件化
| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-10-06 | 初版設計定案:D1–D12 全數拍板;部門範圍採「套在資料上」的判法(使用者所屬部門不參與判定);is_admin 角色轉換規則定案 |
FR-132 母案 CM-2510 |
Guidant AI 的公司層權限是 RBAC(Role-Based Access Control,以角色為單位授權):管理員建角色、在角色上勾能力點(系統裡「可以做某件事」的最小單位,例如 project.read),再把角色指派給使用者;API 用 require_capability(...) 擋人,判定在 jedi-iam 套件(帳號與權限套件)。骨架都在,但有四個地方沒接上:
| 缺口 | 白話 | 證據 |
|---|---|---|
| 部門範圍只存不判 | 指派表有部門欄,判定只比租戶——指到某部門的角色在整個租戶都有效,且無任何錯誤訊息 | jedi-iam infra/repository/user_role_repo_impl.py:81-101;FE 存部門指派時同時帶租戶 id(UserMTRBACForm.vue:216-223) |
| 起訖日判了但設不了 | 判定有看指派的起訖日,畫面只開了帳號層的生效/到期日 | infra/repository/active_role_conditions.py:19-39;FE UserMTRBACForm.vue:489,501 |
| 33 支 API 只驗登入 | 公司沒辦法說「稽核部的人不准碰 SSP 匯出」 | 盤點 ① 第 3 節 J 組 |
| 角色只能逐筆勾 | 123 個能力點、三套命名寫法,沒有「整個模組」「全部唯讀」的寫法,也不能帶到別的環境 | 盤點 ① 第 4、6 節 |
另有兩個資安漏洞:選單明細與 JWT(登入後隨每個請求送出的身分憑證)裡的 is_admin claim(憑證內的一個欄位)不過濾已到期、已停用的指派(app/service/user_service.py:522-529,547、middleware/jwt_mw.py:79),會讓過期管理員仍看得到管理頁入口。
資料隔離今天只到租戶:資料庫用 RLS(Row-Level Security,資料庫列層級隔離)擋別租戶的資料,43 張表帶 org_unit_id 但沒有一條讀取 policy(RLS 的過濾規則)看它。要不要讓部門也擋資料,由 D12 做成租戶開關。
%%{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 TB
subgraph DEF["管理員在「存取管理」設定"]
R1["角色:一組 pattern<br/>例 grc.project.* 、 *.*.read"]
R2["指派:誰 × 角色 × 範圍(租戶/部門)× 起訖"]
R3["部門隔離開關(預設關)"]
end
subgraph REQ["一次 API 請求"]
Q1["require_capability('grc.project.update')"] --> Q2{"super_admin?"}
Q2 -- 是 --> OK["放行"]
Q2 -- 否 --> Q3["取有效指派<br/>(起訖、啟用、租戶)"]
Q3 --> Q4["展開 pattern 成能力點集合<br/>(Redis 快取,鍵含清單版本號)"]
Q4 --> Q5{"集合內有這個能力點?"}
Q5 -- 否 --> NO["403"]
Q5 -- 是 --> Q6{"本租戶開了部門隔離?"}
Q6 -- 否 --> OK
Q6 -- 是 --> Q7["RLS 只回指派範圍內部門的資料<br/>單筆寫入另比「給這個能力點的指派」涵不涵蓋該筆部門"]
Q7 --> OK
end
R1 --> Q4
R2 --> Q3
R3 --> Q6
CHK["「檢查存取」頁"] -.->|吃同一份展開結果| Q4
MENU["選單、選單明細、is_admin claim"] -.->|吃同一份展開結果| Q4
一句話:先把能力點清單理乾淨定規則(A.1),再改資料表(A.2)與判定邏輯(A.3),最後同時補守門(A.4)和做新畫面(A.5)。
| 階段 | 做什麼(白話) | 產出 | 完成怎麼判定(決策者檢查法) | 依賴 |
|---|---|---|---|---|
| A.1 盤點與規則 | 每支 API(含外部套件自帶的)對到一個三段式能力點;定模組清單、整棵能力點樹、出貨角色內容、D12 要改的表 | 能力點對照表、模組定稿表、出貨角色 JSON 草稿、D12 表清單 | 打開對照表:每一支要登入的 route 都有一列、每列都有新名字;出貨角色 JSON 看得到 *.*.read 這類寫法 |
無 |
| A.2 資料地基 | 能力點改名並補欄;角色改存萬用字元;部門路徑回填;隔離開關欄;出貨角色改 JSON | migration、JSON seed、真實舊庫升級排練紀錄 | 拿一份客戶舊庫升級,用舊帳號登入:選單與能按的按鈕和升級前一模一樣;排練紀錄裡每個角色的能力點集合逐角色比對一致 | A.1 |
| A.3 判定層 | jedi-iam 學會展開萬用字元、吃部門範圍、加 Redis 快取;選單、明細、is_admin 改吃同一份結果;D12 的 RLS 機制落地但開關預設關 |
jedi-iam 新版、判定 API、D12 policy | 給某人一筆「A 部門、明天到期」的指派:開關關時他在整個租戶都能用;打開開關後 B 部門的專案看不到也改不了;把到期日改成昨天,重新登入就不能用 | A.2 |
| A.4 守門補齊 | 33 支漏網 route 掛能力點;第一版只記錄不擋,下一版才真擋 | route 掛點、「本來會被擋」的 log 與統計 | 一般帳號點完全部頁面:第一版畫面完全沒變、log 看得到「若真擋會擋到誰」;下一版同一動作回 403 | A.3 |
| A.5 存取管理前端 | 新模組四頁(角色、指派、檢查存取、使用者)+部門隔離開關頁 | 新頁取代 /auth/role-manage、/auth/user-manage 等四條路由 |
在「檢查存取」選一個人,看得到他每個能力點從哪個角色、哪條 pattern、哪個範圍來、何時到期 | A.3 |
A.1 → A.2 → A.3 必須照順序;A.4 和 A.5 可以平行。版本落點:版本 1 出 A.1~A.3(客戶無感),版本 2 出 A.4 只記錄版+A.5,版本 3 出 A.4 真擋。
| # | 題目 | 定案 | 理由 | 被排除方案 |
|---|---|---|---|---|
| D1 | 模組命名 | 一個「存取管理」模組,四頁:角色、指派、檢查存取、使用者 | 四頁概念上是一組;名稱對得上 Azure 的「存取控制」,客戶 IT 一看就懂 | 保留「角色管理」「使用者管理」兩個舊頁各自加功能——檢查存取與指派沒有家 |
| D2 | pattern 怎麼存 | 新表 role_capability_patterns(role_id, pattern);role_capabilities 並存一版後退役 |
並存期間升級出問題可退回舊版程式讀舊表,回滾不必動資料 | role_capabilities 直接改欄——回滾要動資料 |
| D3 | 快取失效 | 快取鍵含能力點清單版本號(新增或改名能力點時加一)+角色改動時主動清該租戶快取;快取放 Redis | 權限的錯誤方向是「拔了還有效」,靠時間到期在稽核上說不過去;多台 api/worker 行程時只有共用快取能一致清除 | 純 TTL 到期;各行程記憶體快取 |
| D4 | 部門繼承方向 | 上層部門的指派涵蓋所有子部門 | 一般人對部門的直覺,也是 Azure 做法;要嚴格限定就直接指派到子部門 | 嚴格只在該部門本身——部門一多,總公司主管要指派幾十次 |
| D5 | 只記錄觀察期 | 一個 release;DEV+STG 跑滿一週、log 無「非管理員會被擋」紀錄才真擋,有紀錄就逐筆判斷補勾或改掛點 | 客戶一般角色是客戶自己勾的,我們不知道內容;先觀察才不會上線就接到「某某人不能用」 | 直接真擋 |
| D6 | 出貨角色 | 系統管理員 *.*.*:內建、不可刪不可改、每租戶必有;稽核主管/稽核員/唯讀(*.*.read):出貨範例,可改可刪可複製 |
系統管理員是「至少有一個人能進來修權限」的保證;其餘只是起點,各家編制不同,硬鎖只會逼客戶複製近似角色 | 四個全鎖;全部可刪 |
| D7 | is_admin 旗標 |
保留;判斷管理員只看旗標,不看是否持有 *.*.* |
JWT claim、FE requiresAdmin、新租戶建角色、升級腳本都讀旗標;用「持有某能力點」反推管理員已實際踩過坑(升級腳本把非管理員塞滿權限) |
拿掉旗標改看 *.*.* |
| D8 | 選單同源 | 選單可見性、選單明細、JWT is_admin claim、判定四者吃同一份「有效指派+展開結果」,併 A.3 |
同時修掉兩個資安漏洞;各自展開必漏改一條,症狀是「選單看得到、點進去 403」且不報錯 | 各自改 |
| D9 | A.1 切棒 | 按模組分五份平行盤點,首腦合併定樹並統一裁命名 | 61 支 BE route 檔+外部套件端點+123 個能力點,一份全掃 context 會爆;命名統一裁才不會每份一套慣例 | 一份全掃;各份各自定名 |
| D10 | license 對應 | capabilities 加 license_module 欄存舊 resource_type 原值;license 判定與寫入端守門改讀它 |
授權照已簽出在客戶手上不能重簽;resource_type 今後是三段名中間段,繼續兼 license 鍵,有人改資源名就會讓整個模組在客戶端被判「沒買」 |
繼續拿 resource_type 當 license 鍵;重簽授權照 |
| D11 | 舊名過渡 | 能力點表加舊名欄,判定一版內同時接受新舊名;守衛測試掃乾淨下一版才移除 | BE 約 116 處、jedi 套件 11 支檔、FE 約 14 支檔寫死舊名,跨套件跨 repo 一次改完任何一處漏改就是靜默 403 | 一次切換 |
| D12 | 部門資料隔離 | 做機制+租戶開關 org_unit_isolation,預設關;只改挑選的業務表;開啟前先出預覽清單 |
有客戶需要部門互不相見,但多數客戶的部門只是組織標記;預設關升級無感;RLS 擋掉的資料不報錯,預覽讓影響在開之前看得到 | 不做(需要的客戶無解);全面開(升級即改行為);改到 43 張全部(維護面過大、多數無業務意義) |
部門範圍的判法(D4/D12 的落地方式):範圍套在資料上——一筆掛在部門 X 的指派,在使用者碰的資料列 org_unit_id 落在 X 子樹內時啟用;使用者本人所屬部門(user_org_units)不參與判定。被排除的判法是「拿使用者所屬部門與指派部門互比」:那會讓一個隸屬稽核部、被指派去管財務部專案的人完全用不到那筆指派,且兩種部門混在一起,管理員無法從指派畫面推出結果。
明確不做:拒絕規則(deny)、資料層動作(dataActions)、角色可指派範圍限制(assignableScopes)、IP/時段條件、政策語言、資源實例級統一授權表——理由見討論稿〈已定案事項〉。
jedi-iam 路徑以 jedi-iam/jedi_iam/ 為根,FE 以 compliance-manager-fe/src/ 為根。完整證據見 盤點 ①、盤點 ②、盤點 ③。
| 元件 | 現況 | 本案動作 |
|---|---|---|
capabilities 表 |
name/resource_type/action/is_platform;123 筆、37 種 resource_type、三套命名(infra/models/capability.py:15-27) |
改名三段式;加 module、display_name、sort、assignable_scopes、license_module、legacy_name |
role_capabilities 表 |
role_id+capability_id,無 pattern 欄(role_capability.py:14-23) |
並存一版;新表 role_capability_patterns 為判定來源 |
user_roles 表 |
已有 org_unit_id、scope、starts_at、ends_at(user_role.py:20-84) |
結構不改;部門欄開始進判定,起訖開到畫面 |
roles 表 |
is_admin int(role.py:23-84) |
加 is_system、is_template |
org_units.path |
例 /10/12/+索引 (tenant_id, path)(infra/models/org_unit.py:17,32) |
掃一次回填保證格式;子樹比對的基礎 |
| 有效指派條件 | active_user_role_conditions 判起訖、啟用、未刪除、租戶(active_role_conditions.py:19-39) |
沿用;取回時帶指派部門 path |
判定 get_active_capability_names |
只比 tenant_id,不看部門(user_role_repo_impl.py:73-101) |
改讀 pattern 並展開;部門指派納入 |
CapabilityGuard |
單一請求快取於 flask.g、super_admin 放行(authz/capability.py:48,51-66,99-106,115,135) |
對外介面不變;加 Redis 跨請求快取、只記錄模式、require_capability_on |
| 選單可見性 | 有套有效條件(ui_route_repo_impl.py:70-141) |
改吃展開服務 |
| 選單明細 | 不過濾有效指派(user_service.py:522-529,547) |
改吃展開服務(資安修正) |
JWT is_admin claim |
不過濾有效指派(middleware/jwt_mw.py:79) |
只看有效指派中的 is_admin 角色(資安修正) |
| RLS session 變數 | 注入 app.allowed_tenant_paths、app.allowed_org_paths(後者是所屬部門)(jedi-common session/database/db.py:205-209、jedi-iam middleware/context.py:119-129) |
新增 app.allowed_org_unit_paths(指派部門)與隔離開關變數 |
| 背景流程放行旗標 | app.can_read_all_orgs(app/flow_engine/service/workflow_execution_service.py:581,706) |
D12 policy 必須尊重 |
| license 判定 | 讀 resource_type(common/authz/license.py:79-80,283-297);豁免清單 INFRASTRUCTURE_MODULES(:107-121);寫入端守門(app/auth/service/role_app_service.py:54-91) |
改讀 license_module |
| 出貨 seed | 只有 Administrator(id=2,scripts/init/04-seed-core.sql:62),:272 起 123 列逐筆配;名稱反查 id(scripts/init/README.md:57-66) |
改成 JSON seed:系統管理員一列 *.*.*+三個範例 |
| 新租戶建角色 | System Manager 配全集扣排除清單(tenant_provisioning_service.py:49,149;排除清單 core/plugins/identity.py:89-95) |
改建系統管理員 *.*.*+三個範例 |
| 既有租戶回補模板 | scripts/init/migrate-capability-grants.sql |
改寫成 pattern |
| 角色端點 | 只有 5 個 CRUD/狀態切換(jedi-iam api/routing.py:107-111) |
加 JSON 匯出匯入、檢查存取、指派 CRUD |
| FE 角色矩陣 | RoleForm 內嵌 TreeTable 三態(views/role-manage/RoleForm.vue:456-516,CSS :531-605) |
抽成可重用元件,列改成「模組 → 資源」 |
| FE 指派 | 扁平部門下拉、無每筆起訖(UserMTRBACForm.vue:548-578,589-650) |
移到「指派」頁,部門樹+日期區間 |
| FE 能力點判定 | hasCap 約 13 支檔(utils/userUtil.js:24-42);路由守衛只有 requiresAdmin、requiresPlatformAdmin(config/router/index.js:1057-1100) |
舊名改新名;守衛行為不變 |
| FE 通用元件 | 無部門樹、日期區間、矩陣、轉移清單(盤點 ③ 第 6 節) | A.5 新建 |
| 能力點補配舊角色範例 | scripts/sql/2026-09-25-fr114-module-frame-read-capability-backfill.sql |
A.2 migration 參考寫法 |
格式:<模組>.<資源>.<動詞>,三段小寫,資源名內部用連字號。例:grc.project.approve、oscal.ssp.export、system.ldap-config.update。前兩段就是角色矩陣的兩層分組,不另建群組表。
粒度規則:
| 規則 | 說明 | 例 |
|---|---|---|
| 每個資源固定 read/create/update/delete | 四個基本動詞一律有 | grc.project.read … .delete |
| 獨立動詞只給「給了 update 卻不想一起給」的 | 限四類:狀態轉換(approve/close/publish)、對外送出(export/send)、指派(assign)、匯入(import) | oscal.ssp.export |
| 一個資源一個 read | 不拆列表與明細 | oscal.ssp.read |
| 跨資源動作歸被產出的資源 | 從 SSP 產生 AP,算 AP 的 create | grc.assessment-plan.create |
| 純查詢輔助端點不開能力點 | 下拉、menu 類跟主資源的 read | 專案下拉跟 grc.project.read |
模組判準:客戶會不會把這群功能當一組來授權。模組不是 UI 選單(選單會重排,能力點名是對外契約——授權照、角色 JSON、客戶已勾好的角色都綁著它);也不照程式住在哪個套件分。
硬規則(模組邊界與授權照加值包對齊):一個加值包必須整包落在單一模組內,不可橫跨兩個模組;模組可以比加值包大。加值包見 docs/features/FR-062-2608-license-management/design.md 第 6 節:問卷(survey)、檢測三合一(remote-agent-manage、detection-profile、plugin)、雲端整合(cloud_integration)、AI 儀表板(ai-dashboard)。不受授權照管制的基礎設施七項見 common/authz/license.py:107-121。
模組草案(A.1 起點,A.1 合併時定稿):
| 模組 | 資源(現有 resource_type) | 住在哪 | A.1 分工 |
|---|---|---|---|
grc 專案稽核 |
project、audit、workflow、project-summary-report、information-system | api/project、flow_control、jedi-compliance-audit、jedi-task-platform |
T-1.1 |
oscal 合規框架 |
compliance-framework、module-frame | api/oscal、api/module_frame |
T-1.2 |
flow 流程設計 |
flow_template | api/flow_engine、jedi-flow-engine |
T-1.2 |
detection 檢測 |
device、remote-agent-manage、detection-profile、plugin、scan-zone | jedi-detection、jedi-remote-agent | T-1.3 |
evidence 證據 |
cloud_integration、storage-config | jedi-evidence-classification、api/cloud_integration |
T-1.3 |
survey 問卷 |
survey | jedi-survey | T-1.3 |
system 系統設定 |
smtp-config、ldap-config、notify_config、security-policy、log-forwarding、system_config、system-menu、issue-integrate-config、license | api/system_config 等 |
T-1.4 |
iam 身分與存取 |
user、role、department、tenant | jedi-iam | T-1.4 |
platform 平台(原廠) |
ai-dashboard、ai-call-log、ai-quota、bulletin、bulletin-list、feedback、feedback-view、log | is_platform 與平台層 |
T-1.5 |
A.1 合併時要請決策者裁的歸屬:information-system(grc 或 oscal)、storage-config(evidence 或 system)、ai-quota(platform 或 system)、ai-dashboard 是否獨立成模組(它是賣給租戶的加值包,放「平台(原廠)」名下會誤導)。
capabilities 欄位(改後):
| 欄位 | 型別 | 說明 | 決策 |
|---|---|---|---|
name |
varchar(100) UNIQUE | 三段式新名 | A.1 |
module |
varchar(50) NOT NULL | 三段名第一段;CHECK `name LIKE module | |
resource_type |
varchar(100) | 三段名第二段(新資源名) | 6.1 |
action |
varchar(50) | 動詞 | 6.1 |
display_name |
varchar(200) | i18n key,矩陣顯示用 | A.5 |
sort |
int | 矩陣內排序 | A.5 |
assignable_scopes |
text[] | 值域 tenant/org_unit/project(project 為 FR-B 預留) |
FR-B 預留 |
license_module |
varchar(100) | 舊 resource_type 原值,license 判定專用 |
D10 |
legacy_name |
varchar(100) | 舊名,判定一版內同時接受;守衛測試掃乾淨後下一版移除 | D11 |
is_platform |
bool | 不變 | — |
新表與加欄:
| 表 | 欄位 | 說明 | 決策 |
|---|---|---|---|
role_capability_patterns(新) |
role_id FK CASCADE、pattern varchar(100),主鍵 (role_id, pattern) |
角色存的 pattern;一個完整名本身也是 pattern | D2 |
roles |
is_system bool、is_template bool |
內建(不可刪改)/出貨範例 | D6 |
| 能力點清單版本號 | 整數,新增或改名能力點時加一 | 快取鍵一部分;放 system_configs 一列或獨立小表,A.2 定 |
D3 |
租戶設定 org_unit_isolation |
bool 預設 false | 部門隔離開關;放 tenants 加欄或 system_configs,A.2 依 RLS 讀取成本定 |
D12 |
新表照 SQL migration 鐵則:每語句加日期註解、GRANT ... TO cm_app、收尾 INSERT public.schema_migrations。
pattern 語法:三段,每段是名字或 *;* 只能整段,不支援 grc.proj*。合法例:*.*.*、grc.*.*、*.*.read、grc.project.*、grc.project.read。寫入時驗格式,不合法回 400。
seed:scripts/init/04-seed-core.sql 的 123 列 role_capabilities 改為系統管理員一列 *.*.*+三個範例角色;出貨角色內容以 JSON 檔為來源(scripts/init/ 下新增,A.2 定路徑),seed 由 JSON 產生。
migration 自檢與升級相容:
role_capabilities 轉成一筆完整名 pattern;migration 結尾逐角色比對「轉換前能力點集合」與「新表展開後集合」,任一角色不一致整支 rollback(psql --single-transaction -v ON_ERROR_STOP=1)。is_admin 角色轉換規則:租戶內「能力點=該租戶全集」的 is_admin 角色轉內建系統管理員 *.*.*(設 is_system);同一租戶有多個時取最早建的那個。其餘 is_admin 角色保留旗標,能力點原樣轉完整名,不升級。三環境實查:DEV、STG、190 的 is_admin 角色皆每租戶有一個配滿者(STG 的 121/101 為版本落後兩個能力點,升級會補)。*.*.* 展開範圍:非 root 租戶展開時扣 is_platform 能力點與 TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES(core/plugins/identity.py:89-95),與新租戶 System Manager 拿到的集合一致(DEV:123 − 16 − 4 = 103)。授權照另由第六軸判,不在展開時扣。require_capability/viewer_has_capability 約 116 處、jedi 套件 11 支檔(jedi_iam 4、jedi_issue 3、jedi_ai_dashboard 2、jedi_detection 1、jedi_system_core 1)、FE hasCap 約 14 支檔——一版內新舊名並存(D11)。scripts/init/02-schema.sql 與 seed),重產由決策者裁示。判定仍在 jedi-iam CapabilityGuard,require_capability("grc.project.read") 對外介面不變,內部三步:
active_user_role_conditions,回傳每筆指派的角色 id 與指派部門 path(租戶指派為空)。快取:
| 層 | 鍵 | 內容 | 失效 |
|---|---|---|---|
| Redis | authz:exp:{tenant_id}:{catalog_version}:{role_id} |
該角色展開後的能力點名集合 | 角色 pattern 改動、角色停用/刪除 → 刪 authz:exp:{tenant_id}:*;能力點新增或改名 → 版本號加一,舊鍵自然不命中 |
flask.g |
_authz_capability_names |
本請求的合併集合 | 請求結束 |
🔴 Redis 讀不到時直接查 DB 展開,不可放行也不可回 403——快取壞掉不能變成權限放寬或全站 403。指派的起訖、啟用每次都查 DB(不進 Redis),所以到期當下就失效,不受快取影響。
部門範圍(範圍套在資料上):角色不分部門,指派才分部門——角色定義全租戶共用,user_roles.org_unit_id 是這筆指派的範圍。使用者所屬部門(user_org_units)不參與。
| 層 | 回答什麼 | D12 關 | D12 開 |
|---|---|---|---|
require_capability(cap)(route 層) |
能不能做這類動作 | 任一有效指派(租戶或部門)給了就放行 | 同左 |
| RLS(資料可見性) | 看得到哪幾筆 | 只看租戶,與今天相同 | 資料列 org_unit_id 為空、或落在任一有效部門指派的子樹內、或此人有有效租戶指派,才看得到 |
require_capability_on(cap, org_unit_id)(單筆寫入) |
對這一筆能不能做 | 等同 require_capability |
要求「給了這個能力點的那筆指派」範圍涵蓋該筆資料的部門(租戶指派一律涵蓋) |
第三層的必要性:某人在 A 部門是編輯、在 B 部門是唯讀,RLS 讓他兩邊都看得到,route 層也會因 A 那筆放行 update;只有比對「哪一筆指派給的」才擋得住他改 B 的資料。第三層只掛在 D12 挑選的業務表寫入路徑(6.4)。
同源四條查詢(D8):
| 消費者 | 座標 | 改成 |
|---|---|---|
| 判定 | user_role_repo_impl.py:73-101 |
展開服務 |
| 選單可見性 | ui_route_repo_impl.py:70-141 |
展開服務的結果比對 route_capabilities |
| 選單明細 | user_service.py:522-529,547 |
展開服務(修資安漏洞:不再走 user.roles → role.capabilities) |
JWT is_admin claim |
middleware/jwt_mw.py:79 |
只看有效指派中的角色旗標(修資安漏洞) |
FE 路由守衛 requiresAdmin 吃 claim,claim 修正後自動跟著正確(FE config/router/index.js:1057-1066)。
%%{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 TB
Q["讀一筆業務資料"] --> T{"租戶允許?"}
T -- 否 --> NO["看不到"]
T -- 是 --> S{"本租戶開了部門隔離?"}
S -- 否 --> YES["看得到(與今天相同)"]
S -- 是 --> B{"can_read_all_orgs 或有有效租戶指派?"}
B -- 是 --> YES
B -- 否 --> N{"該列 org_unit_id 為空?"}
N -- 是 --> YES
N -- 否 --> P{"該列部門 path 落在<br/>有效部門指派的子樹內?"}
P -- 是 --> YES
P -- 否 --> NO
session 變數(照 jedi-common session/database/db.py:205-209 注入 app.allowed_tenant_paths 的做法):
| 變數 | 值 | 誰讀 |
|---|---|---|
app.allowed_org_unit_paths |
有效部門指派的部門 path 清單;有有效租戶指派、super_admin 或系統管理員時為全部門可見標記 | D12 policy |
app.org_unit_isolation |
本租戶開關值,開 session 時從租戶設定讀出 | D12 policy(不在每列查設定表) |
app.allowed_org_paths(既有) |
所屬部門 path,今天只給 INSERT 檢查用 | 既有 INSERT policy,不變 |
🔴 app.allowed_org_unit_paths 與既有 app.allowed_org_paths 名字相近、意思不同(指派部門 vs 所屬部門),兩個變數注入處都要寫一行註解寫明差別。
policy 形狀(挑選表的 SELECT/UPDATE/DELETE):租戶允許 且(本租戶未開隔離 或 app.can_read_all_orgs 為真 或 該列 org_unit_id 為空 或 其 path 落在 app.allowed_org_unit_paths 子樹內)。can_read_all_orgs 是背景流程的系統放行旗標,漏了它,開隔離的租戶裡排程工作會讀不到資料且不報錯。
挑選的表(初估 8~12 張,A.1 定案):compliance.projects、public.devices、compliance.evidence_batches、compliance.detection_executions、survey.surveys、public.bulletins(要確認與 bulletin_org_units 投遞規則不打架)、compliance.information_systems、compliance.module_frames。不挑工作紀錄、解析任務、子表、設定表;子表靠父表入口間接隔離,A.1 要確認沒有直接列子表的 API 繞過父表。
前置:org_units.path migration 掃一次回填,保證每列有值且格式 /id/…/;path 錯了子樹比對會靜默算錯。
開啟流程:管理員按開啟 → 系統產預覽清單(每位使用者在每張表會少看到幾筆、誰會整張表空掉)→ 確認後寫入開關並記一筆稽核事件。關閉不需預覽。只有系統管理員能動。
33 支只驗登入的 route(api/ 下,盤點 ① 第 3 節 J 組):
| 模組 | 支數 | 檔案 | A.4 分工 |
|---|---|---|---|
| oscal(SSP) | 13 | ssp_components、ssp_control_impl_import、ssp_control_implementation、ssp_document_pool、ssp_docx_import、ssp_excel_import、ssp_export、ssp_inventory_items、ssp_leveraged、ssp_party、ssp_resources、ssp_scoped_excel_import、ssp_system_characteristic | T-4.3 |
| oscal(framework) | 3 | framework_parse_job、framework_version_edit、oscal_framework_route | T-4.3 |
| flow_engine | 5 | flow_engine_route、job_evidence、stage_advance、stage_object、stage_rollback | T-4.2 |
| grc(flow_control) | 3 | assessment_plan、job_force_start、job_route | T-4.2 |
| grc(project) | 3 | ap_docx_import、ar_import、audit_round | T-4.2 |
| grc(project_summary_report) | 2 | project_summary_report_route、project_summary_report_history_route | T-4.2 |
| system | 2 | system_config/system_config_route、setup/setup_route | T-4.4 |
| 其他 | 2 | ai_quota/ai_quota_route、support/diagnostic_bundle_route | T-4.4 |
外部套件自帶的 route(jedi-iam /roles 等、jedi-survey 端點)不在這 33 支內,由 A.1 一併列入對照表、T-4.4 補掛。setup_route 是安裝精靈,安裝當下沒有帳號,A.1 確認是刻意不設守門就登記成豁免清單,不算漏網。SSP、flow_engine、grc 多數在 service 層已有專案成員角色守門,本案補的是租戶 RBAC 這層,兩層並存。
兩階段上線(D5):
| 階段 | 行為 | 客戶感受 |
|---|---|---|
| 版本 2:只記錄 | 判定照跑,結果「不放行」時寫結構化 log(使用者、角色、能力點、route)但仍放行;另出統計 | 無感 |
| 版本 3:真擋 | 不放行就 403 | 沒被授權的一般角色開始被擋 |
只記錄模式以每個能力點一個旗標控制(新掛的 33 支標成只記錄,既有守門不受影響),不是全域開關——全域開關一開,既有守門也會被放寬。新掛的能力點靠 *.*.* 自動進系統管理員,一般角色由客戶自己勾。
一個模組(D1),取代 /auth/role-manage、/auth/role-form、/auth/user-manage、/auth/user-form-mtrbac(FE config/router/index.js:93-122)。
| 頁 | 內容 | 重用/新建 |
|---|---|---|
| 角色 | 矩陣列=「模組 → 資源」兩層樹、欄=動詞;另一區列出這個角色實際存的 pattern;系統管理員整列鎖住只能看與複製;範例角色可改刪複製;JSON 匯出匯入;license 旗標與平台層過濾照搬(RoleForm.vue:41-54,171-220,236-240) |
矩陣從 RoleForm.vue:456-516 抽成元件 |
| 指派 | 一筆=誰 × 角色 × 範圍(tenant/org_unit)× 起訖;起訖空白=立即生效、永不到期;三個入口(從人、從角色、從部門);範圍選部門時說明文字依開關切換——關:「只對該部門與子部門的資料有效;本租戶未開部門隔離時等同全租戶有效」;開:「只看得到、只能操作該部門與子部門的資料」 | 新建部門樹選擇器、日期區間選擇器 |
| 檢查存取 | 選一個人,列出每個有效能力點的來源角色、pattern、範圍、到期日;標出 super_admin、is_admin、平台管理員三種豁免;開了隔離時另列「看得到哪些部門的資料」。吃判定層展開服務,畫面=API 實際放行 |
新頁 |
| 使用者 | 只管帳號本身(基本資料、啟用停用、帳號層生效到期、匯入);原表單的角色指派欄(UserMTRBACForm.vue:548-578)移到指派頁,這裡只留連結 |
由現有頁瘦身 |
| 部門隔離設定 | 開關+預覽清單+確認;只有系統管理員能進 | 新頁 |
FE hasCap 約 14 支檔的能力點名改新名;版本 2 上線時 BE 已同時接受新舊名,FE 可一次切。
{
"name": "稽核員",
"description": "執行稽核、可讀全部專案",
"is_admin": 0,
"capabilities": ["grc.*.read", "grc.audit-round.update", "oscal.ssp.read"]
}| 規則 | 內容 |
|---|---|
| 用途 | 跨環境搬角色、出貨角色來源(取代 SQL seed)、範例被刪改後還原、稽核證據 |
| 命中檢查 | 每條 pattern 在目標環境至少命中一個能力點;命中零個就整份報錯並列出哪幾條,不靜默跳過(跳過會讓匯入後的角色比來源少權限且沒人發現) |
| 識別 | 不帶 id,以名字對應;同名由使用者選覆蓋或另存 |
| 系統管理員 | 匯入檔與內建系統管理員同名時一律另存,不改內建那個 |
| license | 不在 JSON 管;匯入後含未授權模組能力點,走既有寫入端 license 守門(app/auth/service/role_app_service.py:54-91,改讀 license_module) |
依賴鏈:A.1 → A.2 → A.3 → (A.4 ∥ A.5)。每卡粒度為單一 session 一到兩天。jedi-iam 開發期走 poetry path dependency,發版等決策者明示;所有 migration 只套 DEV。
T-1.1~T-1.5 五份平行;每份產出表欄位固定:route × method → 新三段名 → 現有能力點 → UI 頁,外部套件自帶的 route 一併列入。
| 編號 | 卡號 | 做什麼(白話) | 產出 | 驗收條件 | 依賴 | 建議 model |
|---|---|---|---|---|---|---|
| T-1.1 | CM-2511 | 盤 grc 模組:專案、稽核輪次、流程控制、摘要報告、資訊系統的每支 route 對到新三段名 | inventory/a1-grc.md |
該模組每支要登入的 route(含 jedi-compliance-audit、jedi-task-platform 端點)都有一列且有新名 | — | opus |
| T-1.2 | CM-2512 | 盤 oscal+flow:SSP、框架、資源庫、流程設計 | inventory/a1-oscal-flow.md |
同上;含 33 支 J 組中 oscal 16 支與 flow_engine 5 支 | — | opus |
| T-1.3 | CM-2513 | 盤 detection+evidence+survey:設備、agent、檢測基準、插件、掃描網段、雲端整合、儲存設定、問卷 | inventory/a1-detection-evidence-survey.md |
同上;檢測三合一加值包整包落在同一模組 | — | opus |
| T-1.4 | CM-2514 | 盤 system+iam:系統設定各頁、使用者、角色、部門、租戶;確認 setup_route 是否刻意不設守門 |
inventory/a1-system-iam.md |
同上;含 jedi-iam /roles 等端點;setup_route 有結論 |
— | opus |
| T-1.5 | CM-2515 | 盤 platform:AI 儀表板、AI 呼叫紀錄、AI 額度、公告、意見回饋、log | inventory/a1-platform.md |
同上;標出每個 is_platform 能力點 |
— | opus |
| T-1.6 | CM-2516 | 合併五份成一棵能力點樹:統一資源命名與動詞歸屬、模組定稿、9 個孤兒能力點去留、出貨三個範例角色 pattern、D12 挑選表定案;邊界爭議送決策者裁 | inventory/a1-capability-tree.md(新名 → 舊名 → license_module → 模組全表)、出貨角色 JSON 草稿、D12 表清單 |
每個現有能力點都有新名或「刪除」結論;加值包未被切開;6.1 列出的四項歸屬爭議有決策者裁示 | T-1.1~1.5 | fable(首腦裁,文件外包) |
| 編號 | 卡號 | 做什麼(白話) | 產出 | 驗收條件 | 依賴 | 建議 model |
|---|---|---|---|---|---|---|
| T-2.1 | CM-2517 | capabilities 加欄(module、display_name、sort、assignable_scopes、license_module、legacy_name)+依 A.1 全表改名+補 system.system-config.read+孤兒處理 |
migration(jedi-iam ORM 同步加欄) | DEV 套用後每列 name 符合三段式、module 與 name 前綴一致、license_module 等於舊 resource_type | T-1.6 | opus |
| T-2.2 | CM-2518 | 新表 role_capability_patterns;roles 加 is_system、is_template;既有 role_capabilities 逐筆轉完整名 pattern;is_admin 角色依 6.2 規則轉 *.*.*;結尾逐角色集合比對、不一致 rollback |
migration | DEV 套用成功;故意改壞一筆轉換時整支 rollback(突變驗證自檢有牙齒) | T-2.1 | fable |
| T-2.3 | CM-2519 | org_units.path 掃描回填與格式檢查;租戶 org_unit_isolation 開關欄(預設 false);能力點清單版本號 |
migration | 每列 path 非空且格式 /id/…/;開關欄存在且全為 false |
T-1.6 | sonnet |
| T-2.4 | CM-2520 | 出貨角色改 JSON 來源:系統管理員+三範例;改寫 04-seed-core.sql、tenant_provisioning_service.py:49,149、migrate-capability-grants.sql 模板 |
JSON seed、改後 seed 與 provisioning | 乾淨新裝後每租戶有一個 is_system 系統管理員+三範例;新建租戶同樣;回報「出貨基線待重產」 |
T-2.2 | opus |
| T-2.5 | CM-2521 | license 判定改讀 license_module:common/authz/license.py:79-80,107-121,283-297、app/auth/service/role_app_service.py:54-91 |
程式改動 | 舊授權照在改名後 DEV 開同樣的模組;未授權模組的能力點仍不能勾進角色 | T-2.1 | sonnet |
| T-2.6 | CM-2522 | 升級排練:拿一份隔一版以上的真實客戶舊庫跑 migrate,逐角色比對升級前後能力點集合;列每租戶選到哪個系統管理員、哪些 is_admin 沒轉 |
排練紀錄(inventory/a2-upgrade-rehearsal.md) |
逐角色 diff 全部一致(不抽樣);舊帳號登入選單與按鈕不變 | T-2.1~2.5 | opus |
| 編號 | 卡號 | 做什麼(白話) | 產出 | 驗收條件 | 依賴 | 建議 model |
|---|---|---|---|---|---|---|
| T-3.1 | CM-2523 | jedi-iam pattern 展開服務:pattern 語法驗證、展開、*.*.* 依租戶扣平台與排除清單、新舊名皆認 |
jedi-iam 展開服務+單元測試 | *.*.read、grc.*.*、完整名三類展開正確;非 root 租戶 *.*.* 等於 103 個 |
T-2.2 | opus |
| T-3.2 | CM-2524 | Redis 快取:鍵含租戶、清單版本號、角色;角色改動主動清租戶快取;Redis 失敗退回查 DB | 快取層 | 改角色後下一個請求即生效(多行程);停掉 Redis 判定結果不變、不放行不全擋 | T-3.1 | opus |
| T-3.3 | CM-2525 | 判定改讀新表:get_active_capability_names 走有效指派+展開;加守衛測試掃程式內舊能力點名 |
jedi-iam 判定、守衛測試 | DEV 全部既有角色判定結果與改前相同;守衛測試列出剩餘舊名 | T-3.1 | opus |
| T-3.4 | CM-2526 | 同源:選單可見性、選單明細、JWT is_admin claim 改吃展開服務(修兩個資安漏洞) |
jedi-iam 改動 | 把某管理員指派到期日改成昨天,重新登入後 claim 為假、管理頁入口消失、選單明細不再列能力點 | T-3.3 | opus |
| T-3.5 | CM-2527 | 部門範圍:有效指派帶回指派部門 path;新增 require_capability_on(cap, org_unit_id);D12 關時等同 require_capability |
jedi-iam guard | A 部門編輯+B 部門唯讀的帳號,開關開時改 B 部門資料被擋、改 A 放行;開關關時兩邊都放行 | T-3.3 | fable |
| T-3.6 | CM-2528 | D12 RLS:注入 app.allowed_org_unit_paths、app.org_unit_isolation;挑選表 SELECT/UPDATE/DELETE policy migration(尊重 can_read_all_orgs) |
jedi-common/jedi-iam 注入、policy migration | 開關關時所有查詢結果與改前相同;開時只看得到指派範圍內部門資料;背景流程在開隔離租戶仍讀得到 | T-2.3、T-3.5 | fable |
| T-3.7 | CM-2529 | 角色 JSON 匯出匯入 API:命中檢查、同名覆蓋或另存、系統管理員不可覆蓋、走 license 守門 | jedi-iam 端點 | 匯入含零命中 pattern 的檔整份報錯並列出;匯入同名系統管理員一律另存 | T-3.1 | sonnet |
| T-3.8 | CM-2530 | 指派 CRUD(含起訖、部門)、檢查存取(每個能力點的來源角色/pattern/範圍/到期)、隔離開關預覽與切換(含稽核事件)API | jedi-iam 與 BE 端點 | 檢查存取回傳與判定放行逐項一致;預覽清單在 DEV 跑得出每人每表少看筆數 | T-3.4、T-3.6 | opus |
| 編號 | 卡號 | 做什麼(白話) | 產出 | 驗收條件 | 依賴 | 建議 model |
|---|---|---|---|---|---|---|
| T-4.1 | CM-2531 | guard 加「只記錄」模式:每個能力點一個旗標,不放行時寫結構化 log 仍放行;統計查詢 | jedi-iam guard、統計腳本 | 標成只記錄的能力點在無權帳號下放行且有 log;未標的既有守門照擋 | T-3.3 | opus |
| T-4.2 | CM-2532 | grc+flow 13 支 route 掛能力點(flow_engine 5、flow_control 3、project 3、摘要報告 2),標只記錄 | route 掛點 | 每支都有守門;一般帳號點完相關頁面畫面不變、log 有紀錄 | T-4.1 | sonnet |
| T-4.3 | CM-2533 | oscal 16 支 route 掛能力點(SSP 13、framework 3),標只記錄 | route 掛點 | 同上 | T-4.1 | sonnet |
| T-4.4 | CM-2534 | system 2 支+其他 2 支+外部套件 route 掛點;setup_route 進豁免清單;D12 挑選表寫入路徑掛 require_capability_on |
route 掛點、豁免清單 | 每支 route 要嘛有守門要嘛在豁免清單;D12 開時跨部門寫入被擋 | T-4.1、T-3.5 | opus |
| T-4.5 | CM-2535 | 觀察期報告與真擋切換:DEV+STG 一週 log 統計、逐筆判斷補勾或改掛點;版本 3 拿掉只記錄旗標;兩版 release note 文字 | 統計報告、真擋 commit、release note 草稿 | 統計無「非管理員會被擋」或每筆有處理結論;真擋後同一動作回 403 | T-4.2~4.4 | opus |
| 編號 | 卡號 | 做什麼(白話) | 產出 | 驗收條件 | 依賴 | 建議 model |
|---|---|---|---|---|---|---|
| T-5.1 | CM-2536 | 共用元件:能力點矩陣(從 RoleForm 抽出,列改「模組 → 資源」)、部門樹選擇器、日期區間選擇器 | 三個元件 | 矩陣勾整列存 grc.*.*、勾整欄存 *.*.read;部門樹可選子部門 |
T-3.1 | opus |
| T-5.2 | CM-2537 | 角色頁:矩陣、pattern 清單區、系統管理員鎖定、範例角色、JSON 匯出匯入 | 角色頁 | 系統管理員不能改刪只能複製;匯出再匯入另一環境得到相同角色 | T-5.1、T-3.7 | opus |
| T-5.3 | CM-2538 | 指派頁:三入口、每筆起訖、範圍說明文字依開關切換 | 指派頁 | 設一筆部門指派+明天到期,檢查存取頁看得到;到期後消失 | T-5.1、T-3.8 | opus |
| T-5.4 | CM-2539 | 檢查存取頁 | 檢查存取頁 | 選人後每個能力點有來源角色、pattern、範圍、到期;豁免三種有標示 | T-3.8 | opus |
| T-5.5 | CM-2540 | 使用者頁瘦身、路由替換、舊四條路由退役、hasCap 改新名、i18n |
使用者頁、路由、i18n | 舊網址導到新頁;全站 hasCap 無舊名;中英文齊 |
T-5.2~5.4 | sonnet |
| T-5.6 | CM-2541 | 部門隔離設定頁:開關、預覽清單、確認 | 設定頁 | 非系統管理員進不去;按開啟先看到預覽,確認後才生效並有稽核事件 | T-3.8 | opus |
| 項目 | 怎麼驗 | 通過條件 |
|---|---|---|
| 升級排練(真實舊庫) | 拿隔一版以上的真實客戶舊庫跑 migrate(新裝環境永遠會過,不算數) | 逐角色比對能力點集合完全一致;舊帳號登入選單與按鈕不變;排練紀錄列出每租戶選到的系統管理員 |
| 開關關閉的回歸 | 版本 1 升級後跑既有 site-regression | 全綠;資料可見性與今天相同 |
| D12 開啟 | test repo 另寫案子:開隔離、看不到別部門資料、改不了別部門資料、預覽清單 | 新案全綠 |
| 守門兩階段 | 版本 2 一般帳號點完全站;版本 3 同一動作 | 版本 2 無畫面變化且有 log;版本 3 回 403 |
| 資安漏洞 | 過期管理員重新登入 | 管理頁入口消失、選單明細不列能力點 |
E2E 連動(test repo):190 E2E 環境的 82 筆角色指派全掛在部門上。開關預設關,這些指派照舊等同全租戶有效,既有回歸不壞;D12 開啟後的行為要另寫測試案,不能拿既有案子打開開關硬跑。E2E 屬母案收口動作,arc 完成後到 test repo 統一補。
release note 要寫的行為變更:
| 版本 | 寫什麼 |
|---|---|
| 版本 1 | 能力點改用三段式名稱(舊名仍可用一版);出貨角色改為系統管理員+三個範例;過期或停用的管理員角色不再顯示管理頁入口 |
| 版本 2 | 新的「存取管理」取代角色管理與使用者管理;可設每筆指派起訖日;可開啟部門資料隔離(預設關);預告下一版 33 個功能開始檢查能力點,附只記錄期間的統計,請管理員到存取管理補勾 |
| 版本 3 | 33 個功能開始檢查能力點,未授權的一般角色會被擋 |
開始時間由決策者與 PM 討論後定;FR-132 只替它預留 capabilities.assignable_scopes 的 project 值。
要做什麼:把專案成員角色(manager/reviewer/auditor/viewer)收進同一套「誰 × 角色 × 範圍 × 時效」,範圍多一層 project;專案內按鈕改由能力點判定,成員角色變成「一組 pattern 的預設角色」。至少要等 A.1 定出能力點樹,FR-B 的按鈕對照表才寫得出來。
已知難處:硬寫角色判斷約 70 處,橫跨主專案與 6 個 jedi 套件(盤點 ② 第 2 節);五張參與者表同形(第 1 節);control → group → project 回退規則與只認專案層的精確判法不可互相冒充(participant_role_service.py:34-85、common/authz/project.py:28-90);流程綁角色寫在 BPMN XML 的 main_role,DEV 有 1150 筆進行中實例各帶一份範本 XML 快照(盤點 ② 第 3、5 節)——翻譯 main_role 要連實例 XML 一起改,或在 _resolve_main_roles(app/flow_engine/service/stage_advance_service.py:584-591)做讀取時轉換。
common/authz/__init__.pydocs/analysis/2026-07-07-unified-auth-guard-design.mddocs/system-design/permission/Permission-System-權限與選單系統設計說明書.mdcommon/authz/license.pydocs/features/FR-062-2608-license-management/design.md 第 6 節core/plugins/identity.py:89-95docs/claude/sql-migration-conventions.md、sql-migration skill