---
title: "存取管理 — 需求討論稿 (FR-132)"
brand: "Guidant AI · **FR-132** 存取管理"
eyebrow: "FR-132 · Access Management — 需求討論稿 · 2026-10-06 · 決策已定案"
h1: "存取管理：補完 RBAC"
subtitle: "誰 × 角色 × 範圍 × 時效"
lede: "現在的權限系統有角色、有能力點（系統裡「可以做某件事」的最小單位）、也有指派表，但**部門範圍存在資料表裡卻沒進判定**、**指派起訖日判定有算但畫面設不了**、**33 支 API 只驗登入不驗能力點**、**能力點命名有三套寫法**，前端也只有一張逐筆勾選的角色表。本案參考 Azure 的「誰 × 角色 × 範圍 × 時效」模型，把公司層這一套補完：三段式命名＋萬用字元（wildcard）角色、部門範圍繼承、指派起訖日、守門補齊、一個預設關閉的部門資料隔離開關，以及一個新的「存取管理」模組。專案內的成員角色合併另開 FR-B，不在本案範圍。"
chips: [
  {text: "已拍板：模型、命名、wildcard、兩層分工、拆兩個 FR", kind: ok},
  {text: "D1–D12 已定案 2026-10-06", kind: ok},
  {text: "母案：待開卡", kind: accent},
  {text: "後續：FR-B 專案層合併（時間由決策者與 PM 定）", kind: accent},
  {text: "守門補齊＝客戶可感的行為變更", kind: crit}
]
footer: "FR-132 · 存取管理 — 需求討論稿 · 2026-10-06 · D1–D12 已定案 · 母案待開卡 · 盤點來源：本資料夾 inventory/01-authz-capabilities.md（BE＋jedi-iam）、02-project-roles-and-flow.md（專案成員角色與流程）、03-frontend.md（FE）· 前作：FR-048 統一授權守門、FR-062 License 控管"
---

## 需求背景與目標 {#why nav="背景"}

### 現況是什麼

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 補守門；前端做一個「存取管理」模組，能看清楚「誰有什麼、為什麼有」 |

## 分工概述（30 秒版） {#overview nav="分工概述"}

**一句話**：先把能力點清單理乾淨定規則（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 可以平行做。

## 已定案事項 {#decided nav="已定案"}

::: {.callout .decided}
**✅ 要做的事**

- **模型**：參考 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 討論後定。
:::

::: {.callout .decided}
**✅ 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 節。
:::

::: {.callout .decided}
**✅ 明確不做（與理由）**

| 不做 | 理由 |
|---|---|
| 拒絕規則（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 節） |
:::

## 現況盤點 {#inventory nav="現況盤點"}

事實全部來自本資料夾三份盤點：[盤點 ①](inventory/01-authz-capabilities.md)（BE＋jedi-iam）、[盤點 ②](inventory/02-project-roles-and-flow.md)（專案角色與流程）、[盤點 ③](inventory/03-frontend.md)（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 缺口（每條附座標）

::: {.callout .crit}
**🔴 部門指派被當成全租戶指派**

`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）會列出誰會少看到什麼。
:::

::: {.callout .crit}
**🔴 資料隔離只有租戶一層，`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 節）。
:::

::: {.callout .crit}
**🔴 資安項：兩個讀取端不看「有效指派」**

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 節。

## 能力點命名與粒度規則 {#naming nav="命名規則"}

### 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 矩陣怎麼畫

```{.mermaid cap="圖 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 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"]
```

矩陣上，勾「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 模組。

## 資料模型草案 {#model nav="資料模型"}

| 表 | 動作 | 內容 | 對應決策 |
|---|---|---|---|
| `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 節。

::: {.callout .decided}
**✅ 定案：哪個 `is_admin` 角色變成內建系統管理員**

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

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

## 判定層草案 {#decide nav="判定層"}

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

1. **找有效指派**：沿用 `active_user_role_conditions`（起訖、啟用、未刪除、租戶），加上部門條件（7.1）。
2. **展開 pattern**：把這些角色的 pattern 展開成具體能力點集合。展開只跟「角色有哪些 pattern」與「能力點清單有哪些名字」有關，跟使用者無關，所以可以跨請求快取。
3. **比對**：要求的能力點在不在集合裡。

```{.mermaid cap="圖 2 — 一次能力點判定的時序"}
%%{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
```

### 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）。

## 部門資料隔離 {#isolation nav="部門隔離"}

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

### 8.1 機制

```{.mermaid cap="圖 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 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
```

- **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——只挑業務表。
- 不提供「依角色決定看不看得到別部門」這類細規則；部門可見性只跟有效部門指派走。

## 守門補齊策略 {#guard nav="守門補齊"}

### 9.1 範圍

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

### 9.2 兩階段上線

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

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

## 存取管理前端 {#fe nav="前端"}

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

```{.mermaid cap="圖 4 — 存取管理模組四頁與後端的關係"}
%%{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
```

### 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`）移到「指派」頁，這頁只留一個連結跳過去。

## 角色 JSON 匯出入 {#json nav="JSON 匯出入"}

```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`）。


## 決策 D1–D12（已定案 2026-10-06） {#decisions nav="決策"}

::: {.callout .decided}
**D1 ✅ 定案：一個「存取管理」模組，底下四頁**

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

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

::: {.callout .decided}
**D2 ✅ 定案：新表 `role_capability_patterns`，舊表並存一版**

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

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

::: {.callout .decided}
**D3 ✅ 定案：版本號＋主動清除**

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

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

::: {.callout .decided}
**D4 ✅ 定案：上層部門的指派涵蓋所有子部門**

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

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

::: {.callout .decided}
**D5 ✅ 定案：只記錄觀察一個 release**

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

::: {.callout .decided}
**D6 ✅ 定案：一個內建系統管理員＋三個出貨範例**

定案：

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

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

::: {.callout .decided}
**D7 ✅ 定案：保留 `is_admin` 旗標，判斷管理員只看旗標**

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

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

::: {.callout .decided}
**D8 ✅ 定案：選單、明細、`is_admin`、判定四者同源，併 A.3**

定案：選單可見性、選單明細、JWT `is_admin` claim 都改吃判定層同一份「有效指派＋展開後能力點」（7.2）。

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

::: {.callout .decided}
**D9 ✅ 定案：A.1 按模組分棒平行**

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

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

::: {.callout .decided}
**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 用，才擋得住。
:::

::: {.callout .decided}
**D11 ✅ 定案：一版內新舊名並存**

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

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

::: {.callout .decided}
**D12 ✅ 定案：部門資料隔離做機制＋租戶開關，預設關**

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

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

## 分期與版本落點 {#phases nav="分期"}

```{.mermaid cap="圖 5 — 分期依賴與預估版本落點"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
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
```

| 版本 | 內容 | 客戶感受 |
|---|---|---|
| 版本 1 | A.1～A.3 | 權限行為與資料可見性都不變（D12 開關預設關，既有部門指派仍等同全租戶有效） |
| 版本 2 | A.4 只記錄版＋A.5 新前端 | 換一套權限畫面；可以自己開部門隔離；release note 預告下一版開始擋 |
| 版本 3 | A.4 真擋 | 沒被授權的一般角色開始被擋 |
| 時間待定 | FR-B | 專案層合併；開始時間由決策者與 PM 討論後定 |

## 風險與排練 {#risks nav="風險"}

::: {.callout .warn}
**⚠️ 升級相容：要拿真實舊庫排練，不是乾淨新裝**

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

::: {.callout .warn}
**⚠️ 部門指派只在 D12 開關打開後才真正收窄**

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

::: {.callout .warn}
**⚠️ test repo 連動：190 E2E 環境 82 筆指派全掛部門**

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

::: {.callout .warn}
**⚠️ 守門行為變更：版本 3 會擋到人**

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

::: {.callout .warn}
**⚠️ 部門隔離開啟後，少看到的資料不會報錯**

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

其他：

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

## 後續 FR-B 預覽：專案層合併 {#fr-b nav="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`）做讀取時轉換。

## 參考座標 {#refs nav="參考"}

- 盤點：[inventory/01-authz-capabilities.md](inventory/01-authz-capabilities.md)、[inventory/02-project-roles-and-flow.md](inventory/02-project-roles-and-flow.md)、[inventory/03-frontend.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`
