FR-132 · Access Management — 需求討論稿 · 2026-10-06 · 決策已定案

存取管理:補完 RBAC

誰 × 角色 × 範圍 × 時效

現在的權限系統有角色、有能力點(系統裡「可以做某件事」的最小單位)、也有指派表,但部門範圍存在資料表裡卻沒進判定、指派起訖日判定有算但畫面設不了、33 支 API 只驗登入不驗能力點、能力點命名有三套寫法,前端也只有一張逐筆勾選的角色表。本案參考 Azure 的「誰 × 角色 × 範圍 × 時效」模型,把公司層這一套補完:三段式命名+萬用字元(wildcard)角色、部門範圍繼承、指派起訖日、守門補齊、一個預設關閉的部門資料隔離開關,以及一個新的「存取管理」模組。專案內的成員角色合併另開 FR-B,不在本案範圍。

已拍板:模型、命名、wildcard、兩層分工、拆兩個 FR D1–D12 已定案 2026-10-06 母案:待開卡 後續:FR-B 專案層合併(時間由決策者與 PM 定) 守門補齊=客戶可感的行為變更
§1

需求背景與目標

現況是什麼

Guidant AI 的權限今天分兩層,各管各的:

  • 公司層(租戶 RBAC,Role-Based Access Control,以角色為單位授權):管理員建「角色」、在角色上勾「能力點」(例如 project.read),再把角色指派給使用者。API 用 require_capability("project.read") 這類守門擋人。這層由 jedi-iam 套件(帳號與權限套件)判定。
  • 專案層(成員角色):每個專案有自己的成員名單,每人是 manager/reviewer/auditor/viewer 四選一,決定專案內哪些按鈕能按、誰收待辦通知。這層散在主專案與六個 jedi 套件裡,約 70 處硬寫判斷(見盤點 ② 第 2 節)。

另有一層是資料隔離:資料庫用 RLS(Row-Level Security,資料庫列層級隔離)擋住「別的租戶的資料」。這層只到租戶,不到部門——見 4.2。

公司層這一套有骨架,但三個地方沒接上:

  1. 「範圍」只存不判,「時效」判了但設不了。user_roles 表有 org_unit_id(部門)與 scope 欄(jedi-iam infra/models/user_role.py:20-84),判定時卻只比租戶(user_role_repo_impl.py:81-101)——前端存部門指派時同時帶租戶 id,所以指派到某部門的角色,實際上在整個租戶都有效。指派的起訖日(starts_at/ends_at)判定有算,但前端只開了使用者帳號層的生效/到期日,每筆指派設不了。
  2. 守門不完整。api/ 下 61 支要登入的 route 檔裡,33 支沒掛任何能力點(盤點 ① 第 3 節)。
  3. 角色只能逐筆勾。123 個能力點、三套命名寫法,角色只能一個一個勾,沒有「這個模組的全部」「所有東西的唯讀」這種寫法,也沒辦法把角色帶到另一個環境。

Azure 的模型是什麼

Azure 把一次授權拆成四個問題:誰(使用者)× 什麼角色(一組動作,可用 * 萬用字元)× 在哪個範圍(訂用帳戶/資源群組/單一資源,上層涵蓋下層)× 到什麼時候(指派可設起訖)。角色定義可以匯出成 JSON 帶走,「檢查存取」頁可以列出某人實際拿到哪些權限、從哪個指派來。

本案要補的三件事

# 補什麼 白話
① 角色定義 能力點改成三段式名字、角色可以寫 grc.project.* 這類萬用字元、角色可匯出匯入 JSON
② 指派判定 部門範圍真的進能力點判定、上層部門的指派涵蓋子部門、每筆指派可設起訖日;另做「部門資料隔離」機制,租戶自己決定要不要開(預設關,D12)
③ 守門與畫面 33 支漏網的 API 補守門;前端做一個「存取管理」模組,能看清楚「誰有什麼、為什麼有」
§2

分工概述(30 秒版)

一句話:先把能力點清單理乾淨定規則(A.1),再改資料表(A.2)與判定邏輯(A.3),最後同時補守門(A.4)和做新畫面(A.5)。

階段 做什麼(白話) 產出 完成怎麼判定(決策者可親手檢查) 依賴
A.1 盤點與規則 把每支 API(含外部套件自帶的 route)對到一個三段式能力點,定出模組清單、整棵能力點樹與出貨角色的內容 能力點對照表(route × method → 新名 → 舊名 → UI 頁)、模組定稿表、出貨角色 JSON 草稿、D12 要改 policy 的表清單 打開對照表:每一支要登入的 route 都有一列、每列都有新名字;出貨角色 JSON 裡看得到 *.*.read 這類寫法 D9
A.2 資料地基 能力點改名、補模組/顯示名/排序;角色改存萬用字元;部門路徑回填;D12 開關欄 migration、JSON seed、拿真實舊庫跑過的升級排練紀錄 拿一份客戶舊庫升級後,用舊帳號登入,選單與能按的按鈕和升級前一模一樣 A.1、D2、D10
A.3 判定層 jedi-iam 學會展開萬用字元、吃部門範圍、加快取;選單、明細、is_admin 改吃同一份結果;D12 的 RLS 機制落地但開關預設關 jedi-iam 新版、選單與判定同源、D12 policy 給某人一筆「A 部門、明天到期」的指派:部門隔離開關關著時,他在整個租戶都能用那些能力點(與今天相同);開關打開後,他只能對掛在 A 或 A 子部門的資料用那些能力點,B 部門的專案看不到也改不了;把到期日改成昨天,重新登入就不能用 A.2、D3、D4、D8、D12
A.4 守門補齊 33 支漏網 route 掛上能力點;第一版只記錄不擋,下一版才真擋 route 掛點、「本來會被擋」的 log 與統計 用一般帳號點完全部頁面:第一版畫面完全沒變,但 log 裡看得到「若真擋會擋到誰」;下一版同一個動作會被擋 A.3、D5
A.5 存取管理前端 新模組四頁:角色、指派、檢查存取、使用者;部門隔離開關與開啟前預覽 四頁+取代舊兩頁 在「檢查存取」選一個人,看得到他每個能力點從哪個角色、哪個萬用字元、哪個部門來、何時到期 A.3、D1、D6

A.1→A.2→A.3 必須照順序;A.4 和 A.5 可以平行做。

§3

已定案事項

✅ 要做的事

  • 模型:參考 Azure「誰 × 角色 × 範圍 × 時效」補完既有 RBAC。
  • 能力點命名改三段 <模組>.<資源>.<動詞>,例如 grc.project.approve、oscal.ssp.export、system.ldap-config.update。資源名一律用連字號。前兩段(模組、資源)就是角色矩陣的分組,不另建群組表。模組怎麼定見 5.4;capabilities 補的欄見第 6 節。
  • 角色改存萬用字元:角色存的是 pattern 字串,例如 grc.project.*、*.*.read;一個完整名字本身也是一個 pattern。判定時把 pattern 展開成具體能力點再比對,展開結果放快取(鍵=租戶+能力點清單版本)。既有資料 migration 時把每筆能力點原樣轉成「完整名 pattern」,行為零變化;is_admin 角色改成 *.*.*。
  • 能力點粒度規則(見第 5 節)。
  • 兩層分工:公司層能力點管「能不能碰這類東西」,專案內按鈕由成員角色管——本案維持這個分工,合併是 FR-B 的事。
  • 角色 JSON 匯出匯入:格式 name / description / is_admin / capabilities[pattern];出貨角色從 SQL seed 改成 JSON seed;匯入時對不上的 pattern 報錯並列出,不靜默跳過。
  • 前端重做成「存取管理」模組四頁,取代 /auth/role-manage 與 /auth/user-manage(見第 10 節)。
  • 判定層(jedi-iam):萬用字元展開+快取、部門範圍繼承(org_units 已有路徑欄 path,例如 /10/12/,見 jedi-iam infra/models/org_unit.py:32)、user_roles 的部門範圍進判定(起訖已在判定裡)。
  • 守門補齊:33 支 route 掛能力點,先出「只記錄不擋」版,下一版真擋。新能力點靠 *.*.* 自動進系統管理員角色;一般角色由客戶自己勾。這是行為變更,寫進 release note。
  • 拆兩個 FR:本案 FR-132 只做公司層(FR-A);專案層合併(FR-B)另開,時間由決策者與 PM 討論後定。

✅ D1–D12 定案一覽(2026-10-06)

# 題目 定案
D1 模組命名 一個「存取管理」模組,底下四頁
D2 pattern 怎麼存 新表 role_capability_patterns,舊表 role_capabilities 並存一版後退役
D3 快取怎麼失效 能力點清單版本號+角色改動時主動清該租戶快取
D4 部門繼承方向 上層部門的指派涵蓋所有子部門
D5 只記錄觀察期 一個 release;DEV+STG 跑滿一週、無「非管理員會被擋」紀錄才真擋
D6 出貨角色 系統管理員 *.*.* 內建不可刪不可改、每租戶必有;稽核主管/稽核員/唯讀是出貨範例,可改可刪可複製
D7 is_admin 旗標 保留;判斷管理員只看旗標
D8 選單同源 選單可見性、選單明細、is_admin claim、判定四者吃同一份展開結果,併 A.3
D9 A.1 切棒 按模組分棒平行,首腦統一裁命名
D10 license 對應 新增 license_module 欄存舊 resource_type
D11 舊名過渡 判定一版內同時接受新舊名,守衛測試掃乾淨才移除
D12 部門資料隔離 做機制+租戶開關,預設關

每項的理由見第 12 節。

✅ 明確不做(與理由)

不做 理由
拒絕規則(deny/notActions) Azure 的 Deny Assignment 一般客戶不能自己建,只有系統內部用;規則一旦有「允許+拒絕」,管理員就要推算兩者疊起來的結果,出錯率遠高於好處
資料層動作(dataActions) Azure 用來區分「管理資源」與「讀資源裡的資料」。本系統的資料存取由專案成員角色、RLS 與 D12 的部門隔離管,再加一層只會重疊
角色可指派範圍限制(assignableScopes) 只有一個租戶內自訂角色時沒有實質需求;capabilities 新增的「可指派範圍層」已涵蓋「這個能力點只能在哪一層給」
IP/時段等條件 Azure 的 Conditional Access 掛在登入層,不是授權層。本系統登入層已有安全政策頁
政策語言(policy) 為了「表達力」引入一門語言,換來的是沒人看得懂角色到底給了什麼
資源實例級統一授權表 單一專案、單一 SSP 的授權今天由成員角色管,統一進一張表是 FR-B 之後才考慮的事
部門隔離改到全部 43 張帶 org_unit_id 的表 大多數是工作紀錄、子表或設定表,隔離它們沒有業務意義,只會增加 policy 維護面;只挑客戶會說「這是 A 部門的」那幾張(第 8 節)
§4

現況盤點

事實全部來自本資料夾三份盤點:盤點 ①(BE+jedi-iam)、盤點 ②(專案角色與流程)、盤點 ③(FE),資料隔離與部門指派的數字另查 DEV DB。jedi-iam 路徑以 jedi-iam/jedi_iam/ 為根,FE 路徑以 compliance-manager-fe/src/ 為根。

4.1 可以直接沿用的

元件 現況 座標
指派表已有部門與起訖欄 user_roles 有 org_unit_id、scope、starts_at、ends_at(timestamptz),以及「租戶或部門至少一個」的檢查 infra/models/user_role.py:20-84、:26-44
有效指派的共用條件 active_user_role_conditions 已判起訖、角色啟用、未刪除、租戶 infra/repository/active_role_conditions.py:19-39
部門有路徑欄 org_units.path(例 /10/12/)+索引 (tenant_id, path),前綴比對就能找子部門;DEV 目前沒有空值 infra/models/org_unit.py:17,32
單一請求內的快取 CapabilityGuard 把能力點清單存在 flask.g authz/capability.py:48,99-106
super_admin 一律放行 viewer_has_capability 與 require_capability 開頭就判 authz/capability.py:115,135
RLS 注入 session 變數的做法 每次開 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_org_allowed_for_session(org_unit_id):資料列的部門 path 是否落在 session 部門清單之下 DEV DB 函式,現只用在 INSERT 檢查
前端指派畫面已能選部門 指派對話框三個下拉:租戶、部門、角色 FE views/user-manage/UserMTRBACForm.vue:589-650
前端矩陣範本 RoleForm 內的 TreeTable 三態勾選矩陣 FE views/role-manage/RoleForm.vue:456-516、CSS :531-605
新能力點補配舊角色的 migration 範例 有現成寫法 scripts/sql/2026-09-25-fr114-module-frame-read-capability-backfill.sql
seed 用名稱反查 id 出貨基線不寫死 id scripts/init/README.md:57-66

4.2 缺口(每條附座標)

🔴 部門指派被當成全租戶指派

get_active_capability_names 只比 tenant_id,完全沒用 org_unit_id 與 scope,也沒有子部門繼承(infra/repository/user_role_repo_impl.py:81-101)。前端存部門指派時同時帶租戶 id(FE UserMTRBACForm.vue:216-223),所以判定只比租戶的結果是:指到某部門的角色,在整個租戶都有效。

DEV 現有 39 筆部門指派(scope = 'org',另有 141 筆租戶指派),全部帶租戶 id,對應 38 位使用者;其中 6 筆是管理員角色。管理員以為「只給 A 部門」,系統給的是整個租戶,而且沒有任何錯誤訊息。本案定案(7.1,方式乙):部門範圍套在資料上,D12 開關關著時部門指派仍等同全租戶有效、行為與今天一致;打開開關後才真正收窄,開啟前的預覽清單(8.3)會列出誰會少看到什麼。

🔴 資料隔離只有租戶一層,org_unit_id 只是標記

DEV 全部 RLS 讀取 policy 只比 app_tenant_allowed_for_session(tenant_id)。43 張表有 org_unit_id 欄,沒有一條 SELECT/UPDATE/DELETE policy 看它,Python 層也沒有按部門過濾。用到部門的 policy 只有兩種:

  • INSERT 的寫入檢查:bulletins、devices、surveys、survey_folders、workflow_templates——新增時部門必須是自己所屬部門之下。
  • user_org_units、project_org_unit_mapping 自身的 policy。

所以 org_unit_id 今天是三種標記:歸屬紀錄(這筆是誰的)、UI 篩選器(列表可依部門篩)、公告投遞(bulletin_org_units)。同租戶的人看得到所有部門的資料。要不要讓部門也擋資料,由 D12 決定(第 8 節)。

🔴 資安項:兩個讀取端不看「有效指派」

  1. 選單明細:每個選單節點列出持有的能力點時,直接走 user.roles → role.capabilities,不過濾效期、停用、租戶(app/service/user_service.py:522-529, 547)。
  2. JWT 的 is_admin claim:any(role.is_admin == 1 for role in user.roles),同樣不過濾(middleware/jwt_mw.py:79)。前端路由守衛 requiresAdmin 吃的就是這個值(FE config/router/index.js:1057-1066)。

後果:一個已到期或已停用的管理員角色,仍可能讓前端顯示管理頁、選單明細顯示他「有」某能力點。API 層的 require_capability 有走有效條件、不受影響,所以這是「看得到不該看到的入口」而不是「做得到不該做的事」。併 A.3 一起修(D8)。

命名三套寫法(DEV 共 123 個能力點、37 種 resource_type,其中 16 個是平台層):

resource 寫法 resource_type 種數 能力點筆數 例
底線 4 13 cloud_integration、notify_config、system_config、flow_template
連字號 19 56 ldap-config、smtp-config
單字無分隔 14 54 project、feedback

另有 9 個孤兒能力點(沒掛任何 route):cloud_integration.update、feedback.export、system_config.create/delete/update、workflow.create/delete/read/update;system_config 缺 read,而 system_config_route 剛好沒有任何守門(盤點 ① 第 4 節)。

33 支只驗登入的 route(盤點 ① 第 3 節 J 組):

模組 支數 備註
oscal(SSP) 13 ssp_components、ssp_control_implementation、ssp_export 等,service 層有成員角色守門
oscal(framework) 3 framework_parse_job、framework_version_edit、oscal_framework_route
flow_engine 5 stage_advance、stage_rollback 等,service 層有成員角色守門
grc(flow_control) 3 assessment_plan、job_force_start、job_route
grc(project) 3 ap_docx_import、ar_import、audit_round
grc(project_summary_report) 2 報告與報告歷史
system 2 system_config_route、setup_route(設定精靈,可能是刻意不設守門)
其他 2 ai_quota_route、diagnostic_bundle_route

SSP、flow_engine、grc 這幾組多數在 service 層已有專案成員角色守門(盤點 ② 第 2 節),「沒掛能力點」不等於「誰登入都能用」——缺的是租戶 RBAC 這一層:公司沒辦法說「稽核部門的人不准碰 SSP 匯出」。這 61 支只算 BE api/;jedi-iam 的 /roles、jedi-survey 等外部套件自帶的 route 不在其中,A.1 要一併列入。

其他缺口:

  • 指派起訖日畫面設不了:前端只有使用者帳號層的 effective_date/expire_date(FE UserMTRBACForm.vue:489,501),每筆角色指派本身沒有;資料表有 starts_at/ends_at、判定也會看,只是畫面沒開。
  • 路由守衛不判能力點:FE 只有 requiresAdmin 與 requiresPlatformAdmin(config/router/index.js:1057-1100),註解明寫不依選單擋直接輸入網址(:464、:873)。
  • 前端沒有通用元件:沒有部門樹、日期區間、矩陣、轉移清單的封裝;矩陣唯一範本是 RoleForm 內嵌 TreeTable,未抽元件(盤點 ③ 第 6 節)。
  • 角色沒有匯出匯入:角色端點只有 5 個 CRUD/狀態切換(api/routing.py:107-111)。
  • 出貨預設角色只有一個:Administrator(id=2、is_admin = 1,scripts/init/04-seed-core.sql:62),從 :272 起配全部 123 個能力點;新租戶另建 System Manager(app/service/tenant_provisioning_service.py:49,149)。DEV 9 個租戶各至少有一個 is_admin 角色,其中租戶 102 有兩個;轉換規則見第 6 節。
§5

能力點命名與粒度規則

5.1 三段式

<模組>.<資源>.<動詞>,三段都小寫,資源名內部用連字號:

  • grc.project.read、grc.project.approve、grc.audit-round.close
  • oscal.ssp.export、oscal.framework-version.update
  • system.ldap-config.update、system.security-policy.read

前兩段就是矩陣的兩層分組,所以「群組」不是另一張表,而是名字的前綴。新增一個能力點,矩陣自動長出它該在的位置。

5.2 粒度規則

規則 說明 例
每個資源固定 read/create/update/delete 四個基本動詞一律有,不視情況省略 grc.project.read … grc.project.delete
獨立動詞只給「給了 update 卻不想一起給」的 只有四類:狀態轉換(approve/close/publish)、對外送出(export/send)、指派(assign)、匯入(import) grc.project.approve、oscal.ssp.export、grc.project.assign
一個資源一個 read 不拆「列表」與「明細」 oscal.ssp.read 同時管清單與內容
跨資源動作歸「被產出的資源」 從 SSP 產生 AP,算 AP 的 create grc.assessment-plan.create
純查詢輔助端點不開能力點 下拉選單、menu 類端點跟著主資源的 read 專案下拉跟 grc.project.read

5.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
  ROOT["全部 *.*.*"] --> GRC["grc 專案稽核"]
  ROOT --> OSCAL["oscal 合規框架"]
  ROOT --> SYS["system 系統設定"]
  GRC --> P["project 專案<br/>read create update delete<br/>approve assign export"]
  GRC --> AR["audit-round 稽核輪次<br/>read create update delete close"]
  OSCAL --> SSP["ssp 系統安全計畫<br/>read create update delete<br/>import export"]
  SYS --> LDAP["ldap-config LDAP 設定<br/>read create update delete"]
圖 1 — 能力點樹=矩陣的列;動詞=矩陣的欄

矩陣上,勾「grc」這一列=存 grc.*.*;勾「grc / project」的 read 欄=存 grc.project.read;勾整欄 read=存 *.*.read。畫面上看到的永遠是展開後的結果,存起來的是最短的 pattern。

5.4 模組怎麼定

模組不是 UI 選單。 選單會隨改版重排,能力點名卻是對外契約——授權照、角色 JSON、客戶已勾好的角色都綁著它,改一次就是一次全客戶升級。兩者各自獨立:矩陣用模組分組、顯示 display_name;選單照自己的結構走,透過 route_capabilities 對到能力點。

判準:客戶會不會把這群功能當一組來授權。 不照程式住在哪個套件分——外部套件(jedi-*)只是實作位置,一個模組可以橫跨主專案與數個套件,一個套件也可以分屬兩個模組。

硬規則:模組邊界與授權照的加值包邊界對齊。 授權照把功能分成基礎包與四個加值包(docs/features/FR-062-2608-license-management/design.md 第 6 節):

加值包 內含 resource_type
問卷包 survey
檢測工具包(三合一不拆賣) remote-agent-manage、detection-profile、plugin
雲端整合包 cloud_integration
AI 儀表板包 ai-dashboard

另有不受授權照管制的基礎設施七項 INFRASTRUCTURE_MODULES(smtp-config、ldap-config、log-forwarding、security-policy、ai-call-log、ai-quota、scan-zone,common/authz/license.py:107-121)。一個加值包必須整包落在單一模組內,不可橫跨兩個模組——否則「買了檢測工具包」在矩陣上會散成兩區,客戶勾一半就以為勾完了。模組可以比加值包大(一個模組含一個加值包加其他基礎功能),不可以把一個加值包切開。

現況:沒有正式的模組盤點。手上有的是三份半成品——DEV 的 37 個 resource_type(=三段名的第二段,沿用不重定義)、授權照的基礎包/加值包分層、core/plugins/ 下約 30 支 plugin 註冊檔。

草案(A.1 的起點,不是定稿):

模組 資源(現有 resource_type) 住在哪
grc 專案稽核 project、audit、workflow、project-summary-report、information-system api/project、flow_control、jedi-compliance-audit、jedi-task-platform
oscal 合規框架 compliance-framework、module-frame api/oscal、api/module_frame
flow 流程設計 flow_template api/flow_engine、jedi-flow-engine
detection 檢測 device、remote-agent-manage、detection-profile、plugin、scan-zone jedi-detection、jedi-remote-agent
evidence 證據 cloud_integration、storage-config jedi-evidence-classification、api/cloud_integration
survey 問卷 survey jedi-survey
system 系統設定 smtp-config、ldap-config、notify_config、security-policy、log-forwarding、system_config、system-menu、issue-integrate-config、license api/system_config 等
iam 身分與存取 user、role、department、tenant jedi-iam
platform 平台(原廠) ai-dashboard、ai-call-log、ai-quota、bulletin、bulletin-list、feedback、feedback-view、log is_platform 與平台層

盤點時預期會有爭議、要回來請決策者裁的歸屬:

  • information-system 歸 grc 還是 oscal(它是 SSP 受評範圍的支撐資料,也是專案的設定項)
  • storage-config 歸 evidence 還是 system(存的是證據檔,但屬機房設定性質)
  • ai-quota 歸 platform 還是 system
  • ai-dashboard 是賣給租戶的加值包,放在「平台(原廠)」模組名稱上會誤導;要不要獨立成一個模組

A.1 每棒產出表欄位固定:route × method → 新三段名 → 現有能力點 → UI 頁。外部套件自帶的 route 一併列入(jedi-iam 的 /roles、jedi-survey 的端點等)——第 4.2 節的 61 支只算 BE api/,只照那份盤會漏掉整個 iam 與 survey 模組。

§6

資料模型草案

表 動作 內容 對應決策
capabilities 改資料 123 筆 name 改三段式;resource_type 改成新資源名(三段名的第二段);action 是動詞 A.1
capabilities 加欄 module(三段名第一段,與 name 一致由檢查約束保證)、display_name(i18n key)、sort、assignable_scopes(tenant/org_unit/project,project 為 FR-B 預留)、license_module(存舊 resource_type) D10
capabilities 加欄(過渡) 舊名欄,判定一版內同時接受新舊名 D11
capabilities 補資料 補 system.system-config.read;9 個孤兒依 A.1 結論刪或掛 A.1
role_capability_patterns 新表 role_id、pattern D2
role_capabilities 保留一版後退役 並存期間舊版程式仍可讀 D2
roles 加欄 is_system(內建,不可刪不可改)、is_template(出貨範例,可改可刪可複製) D6
user_roles 不改結構 既有 org_unit_id、scope 開始進判定;starts_at、ends_at 前端補欄位 D4
快取版本 新 capability_catalog_version 整數,新增或改名能力點時加一;放新表或 system_configs 一列 D3
租戶設定 加欄 org_unit_isolation 開關,預設關;放 tenants 加欄或 system_configs 一列,A.2 依 RLS 讀取成本定 D12
RLS 改 policy D12 挑選的業務表,SELECT/UPDATE/DELETE policy 加部門條件;session 加 app.allowed_org_unit_paths D12
org_units 回填 path 掃一次,保證每列有值且格式一致(/id/…/) D12 前置
seed 改 scripts/init/04-seed-core.sql 的 123 列 role_capabilities 變成一列 *.*.*(系統管理員)+三個範例角色;出貨角色改 JSON seed D6

升級相容:全部是加欄、加表、改資料,沒有刪欄、沒有改型別。帶舊資料升級不會因 schema 失敗;會出事的只會是「資料改得不對」,所以靠下面的自檢擋。

migration 要點

  1. 行為零變化是硬條件:每筆既有 role_capabilities 轉成一筆「完整名 pattern」,角色實際能做的事一個都不能多、不能少。migration 結尾自檢:轉換前後每個角色展開出來的能力點集合逐角色比對一致,有任何一個角色不一致就整支 rollback。
  2. *.*.* 展開的範圍:非 root 租戶展開時扣掉 is_platform 能力點與不販售清單 TENANT_ADMIN_EXCLUDED_RESOURCE_TYPES(core/plugins/identity.py:89-95),與今天新租戶 System Manager 拿到的集合一致(DEV:123 − 16 個平台層 − 4 個 workflow = 103)。授權照另由軸⑥判,不在展開時扣。
  3. 改名要同時處理三種消費者:BE 寫死的 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)。
  4. license 判定讀的是 resource_type:授權照裡的模組鍵就是能力點的 resource_type(common/authz/license.py:79-80、:283-297),照已簽出在客戶手上不能重簽。改名後 license 改讀 license_module(D10)。
  5. 新租戶與既有租戶回補:tenant_provisioning_service.py 建角色的邏輯改成建系統管理員 *.*.* 加三個範例;scripts/init/migrate-capability-grants.sql 模板改寫 pattern。
  6. 出貨基線待重產:scripts/init/02-schema.sql 與 seed 要同步,否則新裝客戶缺這幾支 migration 且不報錯。所有 migration 落地後回報「出貨基線待重產」,重產屬決策者裁示。
  7. 升級排練用真實舊庫:見第 14 節。

✅ 定案:哪個 is_admin 角色變成內建系統管理員

規則:租戶內「能力點=該租戶全集」的 is_admin 角色,轉成內建系統管理員 *.*.*(設 is_system);同一租戶有多個時取最早建的那個。其餘 is_admin 角色保留旗標,能力點原樣轉成完整名 pattern,不升級成 *.*.*。這樣第 1 點的零變化成立,也不會替客戶刪角色;升級排練紀錄列出每租戶選到誰、哪些沒轉。

各環境實查:DEV 租戶 102 原本有一個 is_admin 角色只配 60 個能力點,已補滿,該租戶兩個全集角色依上條取最早建者。STG 與 190:is_admin 角色皆每租戶一個且配滿(STG 的 121/101 個是版本落後少兩個能力點,升級會補)。

§7

判定層草案

判定還是在 jedi-iam 的 CapabilityGuard,對外介面不變(require_capability("grc.project.read") 照用),裡面換三件事:

  1. 找有效指派:沿用 active_user_role_conditions(起訖、啟用、未刪除、租戶),加上部門條件(7.1)。
  2. 展開 pattern:把這些角色的 pattern 展開成具體能力點集合。展開只跟「角色有哪些 pattern」與「能力點清單有哪些名字」有關,跟使用者無關,所以可以跨請求快取。
  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'}}}%%
sequenceDiagram
    participant R as API 請求
    participant G as CapabilityGuard
    participant DB as 資料庫
    participant C as 展開快取
    R->>G: require_capability("grc.project.read")
    G->>G: super_admin?是就直接放行
    G->>DB: 查有效指派(起訖、啟用、租戶;部門指派連同其部門 path 一起取回)
    DB-->>G: 角色清單
    G->>C: 取這些角色的展開結果(鍵:租戶+能力點清單版本+角色)
    alt 快取有
        C-->>G: 具體能力點集合
    else 快取沒有
        G->>DB: 讀角色 pattern 與能力點清單
        G->>G: 展開 pattern(grc.project.* → read/create/…)
        G->>C: 存入
    end
    G->>G: 比對;同一請求內再存 flask.g
    G-->>R: 放行,或回 403
圖 2 — 一次能力點判定的時序

7.1 部門範圍怎麼算(方式乙:範圍套在資料上)

角色不分部門,指派才分部門。 角色定義(一組 pattern)全租戶共用;user_roles.org_unit_id 是這筆指派的範圍。既有欄位設計正確,缺的是判定去讀它,以及資料有可比的部門歸屬。

先分清兩個不同的「部門」:

名詞 存在哪 意思 參不參與本案判定
所屬部門 user_org_units 這個人隸屬哪些部門;登入時依租戶過濾後放進 user context(jedi-iam middleware/context.py:119-129) 不參與
指派部門 user_roles.org_unit_id 這筆角色指派生效的範圍 參與

判法:一筆掛在部門 X 的指派,在使用者碰的資料列 org_unit_id 落在 X 子樹內時啟用(資料列部門的 path 以 X 的 path 開頭)。使用者本人隸屬哪個部門不影響結果——一個人可以隸屬稽核部,卻被指派去管財務部的專案。這就是 Azure「上層涵蓋下層」的原意:比的是被操作的對象落不落在指派範圍內。

  • 租戶指派(org_unit_id 為空):在該租戶內一律啟用,與資料部門無關。
  • D12 開關關著:部門不拿來分資料,部門指派等同整租戶有效,行為與今天完全一致。
  • D12 開關打開:部門指派只對掛在 X 子樹內的資料有效;org_unit_id 為空的資料列不受部門限制。

落到程式上分兩層:

層 問的問題 判法
route 層 require_capability(cap) 這個人能不能做這類動作 任一有效指派(租戶或部門)給了這個能力點就放行——對外介面不變
資料可見性(RLS) 看得到哪幾筆 D12 開時,資料列部門要落在「有效部門指派的部門 path」子樹內,或此人有有效租戶指派(第 8 節)
單筆寫入 require_capability_on(cap, org_unit_id) 對這一筆能不能做 D12 開時,要求「給了這個能力點的指派」本身的範圍涵蓋該筆資料的部門;D12 關時等同 require_capability

第三層存在的原因:一個人在 A 部門是編輯、在 B 部門是唯讀,RLS 讓他看得到 A、B 兩邊,route 層也會因為 A 那筆指派放行 update——只有比對「哪一筆指派給的」才擋得住他改 B 的資料。第三層只需掛在 D12 挑選的業務表的寫入路徑(第 8.2 節),由 A.3 提供、守門補齊(A.4)一併掛上。

7.2 選單與判定同源

今天選單可見性(infra/repository/ui_route_repo_impl.py:70-141)、判定(user_role_repo_impl.py:73-101)、選單明細(user_service.py:522-529)、JWT is_admin claim(middleware/jwt_mw.py:79)是四條各自的查法,後兩條不看有效指派。pattern 與部門範圍上線後若四條各自實作,必然有一條漏改,症狀是「選單看得到、點進去 403」或反過來,且不報錯。四條全部改吃同一個「有效指派+展開後能力點集合」(D8)。

§8

部門資料隔離

D12 定案:做機制,租戶自己開,預設關。 關著時行為與今天完全一樣;需要「A 部門看不到 B 部門的專案」的客戶,在存取管理裡打開。

8.1 機制

%%{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{"租戶允許?<br/>app_tenant_allowed_for_session"}
  T -- 否 --> NO["看不到"]
  T -- 是 --> S{"本租戶開了部門隔離?"}
  S -- 否 --> YES["看得到(與今天相同)"]
  S -- 是 --> N{"該列 org_unit_id 為空?"}
  N -- 是 --> YES
  N -- 否 --> P{"該列部門 path 落在<br/>允許的部門清單子樹內?"}
  P -- 是 --> YES
  P -- 否 --> NO
圖 3 — 開了部門隔離後,一筆資料看不看得到
  • session 多注入一個變數 app.allowed_org_unit_paths,照 jedi-common session/database/db.py:205-209 注入 app.allowed_tenant_paths 的做法。值=使用者有效部門指派的部門 path 清單;有任何有效租戶指派、是 super_admin 或系統管理員時,等同全部門可見。
  • 與既有 app.allowed_org_paths 不是同一件事:那個是所屬部門(context.py:129),今天只給 INSERT 檢查用;新的是指派部門。名字刻意不同,A.3 落地時兩個變數旁都要寫明差別,否則下一個人會拿錯的那個。
  • policy 形狀:挑選的表 SELECT/UPDATE/DELETE policy 改成「租戶允許 且(該列 org_unit_id 為空 或 其 path 在允許清單子樹內 或 本租戶未開隔離 或 app.can_read_all_orgs 為真)」。can_read_all_orgs 是背景流程已在用的系統放行旗標(app/flow_engine/service/workflow_execution_service.py:581,706),policy 必須尊重它,否則背景工作會在開隔離的租戶裡讀不到資料。
  • 開關怎麼讓 policy 讀到:開 session 時從租戶設定讀出、注入成 session 變數,policy 只比變數,不在每一列去查設定表。

8.2 挑哪些表

只挑客戶會說「這是 A 部門的」業務表,初估 8~12 張,A.1 盤點定案:

候選表 說明
compliance.projects 稽核專案
public.devices 受評設備
compliance.evidence_batches 證據批次
compliance.detection_executions 檢測執行紀錄
survey.surveys 問卷
public.bulletins 公告——已有 bulletin_org_units 的投遞規則,A.1 要判斷再加一層隔離會不會和投遞打架
compliance.information_systems 資訊系統
compliance.module_frames 控制項框架範本

不挑的:工作紀錄、解析任務、子表(如 evidence_batch_files)、設定表。子表靠父表入口間接隔離,A.1 要確認沒有直接列出子表的 API 繞過父表。

8.3 開啟流程

  1. 管理員在存取管理裡按「開啟部門隔離」。
  2. 系統先產預覽清單:開啟後,每位使用者在每張表會少看到幾筆、哪些人會整張表看不到任何資料。
  3. 客戶確認後才寫入開關,並記一筆稽核事件。
  4. 關閉不需預覽,直接回到只看租戶。

預覽是這個功能的安全網:部門指派下錯一層,開了之後主管看不到自己部門的專案,而 RLS 擋掉的資料畫面上只是少了幾筆,不會報錯。

8.4 前置

  • org_units.path migration 掃一次回填,保證每列有值且格式一致。DEV 目前沒有空值,但客戶舊庫未驗過;path 有誤,子樹比對就會靜默算錯。

8.5 不做

  • 不把 43 張帶 org_unit_id 的表全改 policy——只挑業務表。
  • 不提供「依角色決定看不看得到別部門」這類細規則;部門可見性只跟有效部門指派走。
§9

守門補齊策略

9.1 範圍

第 4.2 節那 33 支 route,由 A.1 盤點逐支定出要掛的能力點。setup_route(設定精靈)在安裝當下還沒有任何帳號,A.1 要確認是否刻意不設守門,是的話登記成豁免清單而非漏網。

9.2 兩階段上線

階段 行為 客戶感受
第一版:只記錄 判定照跑,結果是「不放行」時寫 log 但仍放行 無感
下一版:真擋 不放行就回 403 沒被授權的一般角色會開始被擋

新掛上去的能力點會自動進 *.*.*(系統管理員角色),管理員不受影響;一般角色要客戶自己勾。這條要寫進兩版的 release note:第一版預告「下一版會開始擋,請到存取管理頁補勾」,並附「只記錄」期間的統計讓客戶知道會擋到誰。觀察期長度與放行判準見 D5。

§10

存取管理前端

一個模組(D1)、四頁,取代今天的 /auth/role-manage、/auth/role-form、/auth/user-manage、/auth/user-form-mtrbac(FE config/router/index.js:93-122)。

%%{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 FE["存取管理模組(前端)"]
    ROLE["角色<br/>矩陣、pattern、JSON 匯出入"]
    ASSIGN["指派<br/>誰 × 角色 × 範圍 × 起訖"]
    CHECK["檢查存取<br/>有效能力點與來源"]
    USER["使用者<br/>帳號本身"]
  end
  subgraph BE["後端"]
    CAP["能力點清單<br/>(含顯示名、排序)"]
    RP["角色 pattern"]
    UR["user_roles 指派"]
    EXP["展開服務<br/>(與判定同源)"]
  end
  ROLE --> CAP
  ROLE --> RP
  ASSIGN --> UR
  CHECK --> EXP
  EXP --> RP
  EXP --> UR
  USER -.->|從使用者跳到指派| ASSIGN
圖 4 — 存取管理模組四頁與後端的關係

10.1 角色

  • 矩陣:列是「模組 → 資源」兩層樹(就是能力點名字的前兩段),欄是動詞。以 RoleForm 現有 TreeTable 三態矩陣為底(FE RoleForm.vue:456-516),抽成可重用元件。
  • 每個勾選背後存的是 pattern,畫面另有一區列出「這個角色存了哪幾條 pattern」,讓人看得懂 grc.*.* 與一個個勾的差別。
  • 系統管理員(*.*.*,is_system):內建、不可刪、不可改內容、每租戶必有一個;畫面上整列鎖住,只能看、只能複製。
  • 稽核主管/稽核員/唯讀(is_template):出貨範例,客戶可以直接改、刪、複製。刪了或改壞了,用角色 JSON 匯入出貨預設那份即可還原(第 11 節)。範例內容在 A.1 定出能力點樹後才填。
  • JSON 匯出、匯入(第 11 節)。
  • 既有的 license 旗標與平台層過濾照搬(FE RoleForm.vue:236-240、:41-54、:171-220)。

10.2 指派

  • 一筆指派=誰 × 角色 × 範圍 × 起訖。本案範圍只開 tenant 與 org_unit;project 留給 FR-B。
  • 範圍選部門時,畫面要寫清楚「這筆指派只對掛在該部門與子部門的資料有效;本租戶未開部門隔離時,等同全租戶有效」;若本租戶已開部門隔離,改寫成「他只看得到、只能操作該部門與子部門的資料」。
  • 起訖日每筆指派各自設定,空白=立即生效、永不到期。
  • 三個入口:從「人」看(他有哪些指派)、從「角色」看(誰拿了這個角色)、從「範圍」看(這個部門有哪些指派)。
  • 需要新元件:部門樹選擇器、日期區間選擇器——專案內目前都沒有(盤點 ③ 第 6 節)。

10.3 檢查存取

選一個人,列出他目前的有效能力點,每一列寫出:來自哪個角色、哪條 pattern、在哪個範圍、何時到期。另外標出三種「不必看矩陣」的豁免:帳號層 super_admin、角色 is_admin、平台管理員。這頁吃的就是判定層的展開服務,所以畫面上看到的=API 實際放行的。本租戶開了部門隔離時,另列一區「他看得到哪些部門的資料」,同樣來自判定層的有效部門指派。

10.4 部門隔離設定

租戶層一個開關(D12,第 8 節),放在存取管理模組內。按下開啟先出預覽清單(每人每張表少看到幾筆、誰會整張表空掉),確認後才生效並記稽核事件。只有系統管理員能動。

10.5 使用者

只管帳號本身(基本資料、啟用停用、帳號層生效到期、匯入)。今天表單裡的角色指派欄(FE UserMTRBACForm.vue:548-578)移到「指派」頁,這頁只留一個連結跳過去。

§11

角色 JSON 匯出入

{
  "name": "稽核員",
  "description": "執行稽核、可讀全部專案",
  "is_admin": 0,
  "capabilities": ["grc.*.read", "grc.audit-round.update", "oscal.ssp.read"]
}
  • 用途:跨環境搬角色(DEV 調好搬到客戶機)、出貨角色(取代 SQL seed)、出貨範例被刪或改壞時匯入還原、給稽核當「這個角色當時被授予什麼」的證據。
  • 匯入規則:每條 pattern 必須在目標環境的能力點清單裡至少命中一個,命中零個就整份報錯並列出哪幾條,不靜默跳過——靜默跳過會讓匯入後的角色比來源環境少權限,而且沒人發現。
  • 不帶 id:以名字對應,同名角色由使用者決定覆蓋或另存。
  • 系統管理員不可被匯入覆蓋:匯入檔內若有與內建系統管理員同名的角色,一律另存,不改內建那個。
  • license 不在 JSON 管:匯入後若含未授權模組的能力點,沿用既有寫入端 license 守門(app/auth/service/role_app_service.py:54-91)。
§12

決策 D1–D12(已定案 2026-10-06)

D1 ✅ 定案:一個「存取管理」模組,底下四頁

定案:角色、指派、檢查存取、使用者四頁收在同一個「存取管理」模組,取代「角色管理」「使用者管理」兩個頁名。

理由:四頁在概念上是一組,分散在兩頁名下,「檢查存取」與「指派」找不到家;名稱也和 Azure 的「存取控制」對得上,客戶 IT 人員一看就懂。被排除的選項是保留兩個舊頁名各自加功能。

D2 ✅ 定案:新表 role_capability_patterns,舊表並存一版

定案:pattern 存新表 role_capability_patterns(role_id, pattern),role_capabilities 保留一版、下一版退役。

理由:新表並存期間,升級出問題可以退回舊版程式繼續讀舊表,回滾不需要動資料;舊表下一版再退役,那時已有一版的實際運行可以確認新表正確。被排除的選項是 role_capabilities 直接改欄,回滾就得動資料。

D3 ✅ 定案:版本號+主動清除

定案:快取鍵含能力點清單版本號(新增或改名能力點時加一),角色改動時主動清掉該租戶的快取。

理由:權限的錯誤方向是「改了還有效」——管理員拔掉某人權限,若靠時間到期,到期前他還能操作,這在稽核上說不過去。代價只是角色寫入時多清一次快取。多台 api/worker 行程時清除要能通知所有行程(或快取放 Redis),見第 14 節。

D4 ✅ 定案:上層部門的指派涵蓋所有子部門

定案:部門指派對掛在指派部門本身與其全部子部門的資料有效(7.1,範圍套在資料上,使用者所屬部門不參與);D12 關著時部門指派等同全租戶有效。

理由:這是一般人對「部門」的直覺(總公司主管管得到子部門),也是 Azure 的做法;要嚴格限定時,直接把指派下到子部門即可。反過來若嚴格只在該部門本身有效,總公司主管要在每個子部門各指派一次,部門一多就失控。

D5 ✅ 定案:只記錄觀察一個 release

定案:守門補齊先出「只記錄不擋」版,觀察一個 release。放行判準:DEV+STG 跑滿一週,log 裡沒有「非管理員會被擋」的紀錄;若有,逐筆判斷是該補勾還是能力點掛錯,處理完再進下一版。

D6 ✅ 定案:一個內建系統管理員+三個出貨範例

定案:

  • 系統管理員(*.*.*):內建、不可刪、不可改內容、每個租戶必有一個。
  • 稽核主管、稽核員、唯讀(唯讀為 *.*.read):出貨範例,客戶可改、可刪、可複製;刪了或改壞了,用角色 JSON 匯入出貨預設那份還原。

理由:系統管理員是「至少有一個人能進來修權限」的保證,客戶刪掉或改掉它,整個租戶可能沒人能再開存取管理,所以鎖死。其餘三個只是起點,各家稽核編制不同,硬鎖只會逼客戶複製出一堆近似角色。稽核主管與稽核員的 pattern 等 A.1 把能力點樹定出來再填,現在定只會被改。

D7 ✅ 定案:保留 is_admin 旗標,判斷管理員只看旗標

定案:is_admin 保留,系統管理員角色的 pattern 一律是 *.*.*;判斷「誰是管理員」只看旗標,不看是否持有 *.*.*。

理由:JWT 的 is_admin claim(middleware/jwt_mw.py:79)、前端 requiresAdmin 路由守衛、新租戶建角色(tenant_provisioning_service.py:149)、升級腳本都讀這個旗標;而用「持有某能力點」反推管理員身分已經實際踩過坑(升級腳本把非管理員塞滿權限)。

D8 ✅ 定案:選單、明細、is_admin、判定四者同源,併 A.3

定案:選單可見性、選單明細、JWT is_admin claim 都改吃判定層同一份「有效指派+展開後能力點」(7.2)。

理由:這同時修掉第 4.2 節的兩個資安漏洞;而且 pattern 上線後若各自展開,必然漏改一條,症狀是「選單看得到、點進去 403」或反過來,且不報錯。

D9 ✅ 定案:A.1 按模組分棒平行

定案:按 5.4 的模組草案分棒平行盤點(BE route 與外部套件 route 都算),各出一張「route × method → 新三段名 → 現有能力點 → UI 頁」表,首腦合併成一棵樹,並統一裁「資源怎麼命名、動詞歸哪、模組邊界爭議」。

理由:61 支 BE route 檔加外部套件端點加 123 個能力點,一棒全掃 context 會爆;分棒各自小、可平行。命名由首腦統一裁,避免每棒各出一套慣例。

D10 ✅ 定案:新增 license_module 欄存舊 resource_type

定案:capabilities 加 license_module 欄,migration 時存入舊 resource_type 原值;license 判定(common/authz/license.py:79-80、:283-297)、寫入端 license 守門(app/auth/service/role_app_service.py:54-91)、基礎設施豁免清單 INFRASTRUCTURE_MODULES(common/authz/license.py:107-121)改讀 license_module;resource_type 改成新的資源段。

理由:授權照已簽出在客戶手上不能重簽,「這個能力點屬於照上的哪個模組」必須保持原值。resource_type 今後會被讀成「三段式的中間段」,繼續拿它當 license 鍵,下一個人改資源名時一定會順手改到它,症狀是整個模組在已出貨客戶那邊被判成「沒買」。獨立一欄、名字寫明是 license 用,才擋得住。

D11 ✅ 定案:一版內新舊名並存

定案:能力點表加一欄舊名,判定一版內同時接受新舊名;加一支守衛測試掃「程式裡還有沒有舊名」,掃乾淨下一版才移除舊名欄。

理由:BE 約 116 處、jedi 套件 11 支檔、FE 約 14 支檔寫死舊名;jedi 套件要發版、FE 和 BE 要同版上線,一次改完任何一處漏改就是靜默 403。

D12 ✅ 定案:部門資料隔離做機制+租戶開關,預設關

定案:RLS 加部門條件、session 加 app.allowed_org_unit_paths、租戶層 org_unit_isolation 開關預設關;只改挑選的業務表;開啟前先出預覽清單。細節見第 8 節。

理由:今天資料隔離只到租戶(第 4.2 節),有客戶需要「部門之間互不相見」,但大多數客戶的部門只是組織標記,硬開會讓主管突然看不到東西。做成開關、預設關,升級對所有人無感,需要的客戶自己開;預覽清單讓「開了之後誰會少看到什麼」在開之前就看得到,因為 RLS 擋掉的資料不會報錯。被排除的選項:不做(需要的客戶無解)、全面開啟(升級即改行為)、改到 43 張全部(維護面過大且多數無業務意義)。

§13

分期與版本落點

%%{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 V1["版本 1"]
    A1["A.1 盤點與規則"] --> A2["A.2 資料地基"] --> A3["A.3 判定層+D12 機制(開關關)"]
  end
  subgraph V2["版本 2"]
    A4L["A.4 守門(只記錄)"]
    A5["A.5 存取管理前端+隔離開關"]
  end
  subgraph V3["版本 3"]
    A4B["A.4 守門(真擋)"]
  end
  subgraph VB["時間待定"]
    FRB["FR-B 專案層合併"]
  end
  A3 --> A4L
  A3 --> A5
  A4L --> A4B
  A1 -.->|能力點樹決定 FR-B 範圍| FRB
圖 5 — 分期依賴與預估版本落點
版本 內容 客戶感受
版本 1 A.1~A.3 權限行為與資料可見性都不變(D12 開關預設關,既有部門指派仍等同全租戶有效)
版本 2 A.4 只記錄版+A.5 新前端 換一套權限畫面;可以自己開部門隔離;release note 預告下一版開始擋
版本 3 A.4 真擋 沒被授權的一般角色開始被擋
時間待定 FR-B 專案層合併;開始時間由決策者與 PM 討論後定
§14

風險與排練

⚠️ 升級相容:要拿真實舊庫排練,不是乾淨新裝

A.2 改能力點名字、搬角色資料,新裝環境永遠會過;會出事的是「帶著舊資料升級」。每版必須可直接升級不影響舊資料,所以 A.2 的驗收要拿一份隔一版以上的真實客戶舊庫跑 migrate,比對升級前後每個角色展開出來的能力點集合完全相同(逐角色 diff,不是抽樣)。

⚠️ 部門指派只在 D12 開關打開後才真正收窄

方式乙下,開關關著時部門指派等同全租戶有效,升級不改任何人的權限。D12 開關打開後部門指派才真正收窄,開啟前的預覽清單(8.3)會列出每個人會少看到什麼。

⚠️ test repo 連動:190 E2E 環境 82 筆指派全掛部門

190 E2E 環境的 82 筆角色指派全部掛在部門上。開關預設關,這些指派照舊等同全租戶有效,既有回歸測試不壞;但 D12 開啟後的行為(看不到別部門資料、改不了別部門資料、預覽清單)要另寫測試案,不能拿既有案子打開開關硬跑。

⚠️ 守門行為變更:版本 3 會擋到人

客戶的一般角色是客戶自己勾的,我們不知道他們勾了什麼。版本 2 只記錄期間的統計要隨 release note 交給客戶,否則版本 3 一上線就會接到「某某人突然不能用」的電話。

⚠️ 部門隔離開啟後,少看到的資料不會報錯

RLS 擋掉的列只是不出現在列表裡,沒有錯誤訊息。部門指派下錯一層、或 org_units.path 有誤,主管就會「以為部門沒有專案」。開啟前的預覽清單(8.3)與 path 回填(8.4)是兩道必要防線,不可省。背景流程要尊重 app.can_read_all_orgs,否則開了隔離的租戶裡排程工作會讀不到資料。

其他:

  • 快取一致性:多台 api/worker 行程時,快取放在各行程記憶體內會不同步,D3 的「清除」要能通知所有行程(或快取放 Redis)。
  • jedi-iam 發版:判定層改在套件,開發期走 poetry path dependency,發版等決策者明示。
  • 出貨基線:所有 migration 落地後要回報「出貨基線待重產」。
§15

後續 FR-B 預覽:專案層合併

本節只寫範圍與風險,不展開設計。FR-B 另開時間由決策者與 PM 討論後定;FR-132 只替它預留一件事:capabilities.assignable_scopes 的值域含 project。

要做什麼:把專案成員角色(manager/reviewer/auditor/viewer)收進同一套「誰 × 角色 × 範圍 × 時效」,範圍多一層 project;專案內的按鈕改由能力點判定,成員角色變成「一組 pattern 的預設角色」。

為什麼至少要等 A.1:專案內每個按鈕要對到哪個能力點,取決於 A.1 定出來的能力點樹;樹沒定,FR-B 的對照表無從寫起。

已知的難處(盤點 ②):

難處 規模 座標
硬寫的角色判斷 約 70 處,橫跨主專案與 6 個 jedi 套件(task_platform、compliance_audit、survey、evidence_classification、detection、flow_engine) 盤點 ② 第 2 節
五張參與者表 專案、控制項群組、專案群組、控制項、流程各一張,role 欄同形 盤點 ② 第 1 節
回退規則 control → group → project,下層蓋過上層;另有只認專案層的精確判法,兩者不可互相冒充 participant_role_service.py:34-85、common/authz/project.py:28-90
流程綁角色寫在 XML 裡 BPMN UserTask 的 main_role 屬性;stage_objects.default_main_roles 是後備 盤點 ② 第 3 節
進行中的流程是整份 XML 快照 DEV 有 1150 筆 PROCESSING 實例,各有一份獨立的範本 XML;只改設計器範本不影響已建立的實例 盤點 ② 第 5 節

最後一條決定了 FR-B 的關鍵取捨:翻譯 main_role 必須連實例那份 XML 一起改,或在 _resolve_main_roles(app/flow_engine/service/stage_advance_service.py:584-591)做讀取時轉換。

§16

參考座標

  • 盤點:inventory/01-authz-capabilities.md、inventory/02-project-roles-and-flow.md、inventory/03-frontend.md
  • 授權守門決策表:common/authz/__init__.py
  • 統一授權守門設計(FR-048):docs/analysis/2026-07-07-unified-auth-guard-design.md
  • 權限與選單系統設計說明書:docs/system-design/permission/Permission-System-權限與選單系統設計說明書.md
  • license 第六軸與基礎設施豁免:common/authz/license.py
  • 授權照基礎包/加值包分層:docs/features/FR-062-2608-license-management/design.md 第 6 節
  • 新租戶排除清單:core/plugins/identity.py:89-95