本章目標與前置條件
讀完後,你應能分辨「目前是誰」「此人持有哪些常駐權限」「此刻是否正在升權」以及「此頁為何可見」。 前置條件是了解 HTTP、UUID、角色與資料範圍;不需要先修改任何帳號或角色。
核心概念
身分投影與授權投影分開
GET /v1/me 回傳 account、tenant、可為 null 的 employee、effective_permissions、groups 與 elevation_session。
employee 為 null 表示該帳號沒有 active 員工檔。effective_permissions 只包含常駐權限;升權會話獨立回傳,
避免會話撤銷後畫面仍把臨時權限當成永久權限。elevation_session 是該帳號目前有效的會話,與這次請求有沒有帶升權 header 無關。
GET /v1/me/menus 回傳 nodes、攤平的 page_keys 與已排列的 navigation。兩者(連同 POST /v1/authz/check)都是
auth_only 路由。不存在可單獨授予的 menu permission:頁面可見性由掛在該頁的 read point 推導(管理員改綁 point
所屬頁面後,選單跟著改),另有三條規則(internal/service/iam/menus.go):
- tenant-wide 頁(例如所有
iam.*頁)要求該 read point 的資料範圍達 tenant 以上;只有 self 等窄範圍的 read point 不會讓管理頁出現。 - baseline 首頁
platform.home不綁任何 point,除非租戶把它隱藏,否則對所有登入者可見。 - 待辦審核頁
workflow.approval另依authz.Decide對workflow.actionread(TaskKind inbox)的判定顯示;這條判定走 task 關係分支,沒有常駐 read point 的帳號也可能看到(inbox 只列呼叫者自己的任務)。
按鈕操作仍須檢查精確的 resource.action。
認證鏈:從 token 到 principal
後端不持有登入 session,只接受 Bearer JWT:
- 前端
proxy.ts把 httpOnly cookie_t的 access token 注入Authorizationheader,瀏覽器 JS 拿不到 token。 identityMiddleware(internal/api/v1/middleware.go)要求恰好一個 Bearer credential,交給identity.Resolver(internal/service/identity/resolver.go)。internal/platform/keycloak/verifier.go依 kid 從 JWKS 取公鑰驗簽,檢查 iss、aud(client ID)、exp/nbf(含 leeway)、sub,以及必須是合法 UUID 的tenant_idclaim。- resolver 以
tenant_idclaim 開該租戶的交易(RLS 租戶),在交易內依(issuer, subject)查user_identities取得 account,再讀 tenant 與該帳號的 active employee。 - account 狀態與 tenant lifecycle 都必須是
active,通過後 TenantID、AccountID 才寫進RequestContext。
| 情況 | 回應(internal/api/v1/identity.go 的 identityError) |
|---|---|
| 缺 token、驗簽或 claim 不合法、租戶內查無綁定 | 401 10100 unauthenticated |
| 帳號不是 active | 403 10101 account_disabled |
| 租戶不是 active | 403 10102 tenant_suspended |
| JWKS 或資料庫等依賴失敗 | 503 10500 service_unavailable |
前端 libs/api/client.ts 遇到 10100 先呼叫 /api/auth/refresh 一次並重送,失敗才登出;10101、10102、10104
直接登出;503 不登出。登入不會自動建立帳號或 user_identities 綁定;帳號開通與密碼重置見 組織與員工。
升權會話如何生效
- 資格:管理員以
PUT /v1/iam/roles/:id/assumers/:accountId授予帳號承擔某個 active break_glass 角色的資格(須附理由;不能授予自己、升權中不能授予,對象帳號須為 active)。 - 開會話:該帳號
POST /v1/iam/elevation-sessions(role_id、reason、ttl_seconds),成功回 201。角色必須是 active、break_glass 且 requestable,帳號必須在 role_assumers 內;ttl_seconds必須大於 0、不超過 4 小時,角色的 max_ttl_seconds 大於 0 時也不得超過它;同一帳號已有有效會話時回 40920205session_active。 - 使用:會話不會自動套用。請求必須帶
X-Elevation-Sessionheader(前端只在呼叫端傳入config.custom.elevationSessionId時由libs/api/client.ts附加),authz.Decide才會考慮它:會話不存在、不屬於本人、已過期、已撤銷或 header 不是 UUID,一律 40320102elevation_invalid;會話角色涵蓋該 point 但會話帶有 boundary 時,因 boundary 格式尚未定案,目前一律拒絕並回 40320103;常駐授權已命中時,判定與沒帶 header 完全相同。 - 撤銷:
DELETE /v1/iam/elevation-sessions/:id寫入 revoked_at 並回 204;會話判定不走快照,下一個帶該 header 的請求就被拒。
授權資料的來源
| 來源 | 語意 | 目前狀態 |
|---|---|---|
| account/role binding | 角色綁定,principal 可為帳號或群組,可含起訖時間;break_glass 與 system 角色不能綁,也不能綁給自己或自己所在的群組 | 已實作(原始碼層級) |
| user group | 有效時間窗內的成員,繼承群組角色;不能把自己加入群組 | 已實作(原始碼層級) |
| role assumer | 帳號對某個 break_glass 角色的承擔資格,是開升權會話的前提,屬高風險治理 | backend route 已實作;前端尚無 UI |
| elevation session | 有理由與 TTL 的 break-glass 臨時授權,只能收窄邊界;須由請求 header 明示使用 | backend route/service 已實作;前端尚無管理頁 |
| data scope | self、direct_reports、department、department_subtree、tenant、all | 已實作(原始碼層級) |
| field policy | 依 clearance 套用 readonly、mask、hide 或 deny | backend API 已實作;前端尚無 UI |
判定使用精確 permission key,不用前綴或萬用字元推測。資料範圍是額外約束,不能由前端選單取代。
功能清單與現況
| 功能 | 主要 API/頁面 | 現況說明 |
|---|---|---|
| 角色 | /v1/iam/roles、/iam/roles | frontend iam/roles 有列表、新增、編輯(含 SoD、requestable、max TTL、clearance)與帶 version 的樂觀鎖更新/刪除 |
| 角色綁定 | /v1/iam/roles/:id/members | backend 可綁帳號或群組;frontend 只在 /iam/groups 建立與解除「群組」綁定,沒有帳號直綁入口,也沒有角色成員列表 |
| 群組與成員 | /v1/iam/user-groups、/iam/groups | frontend iam/groups 有成員時間窗、群組角色檢視與群組綁定 |
| 帳號/權限檢視 | /v1/iam/accounts、/iam/access | frontend iam/access 有帳號選取與來源呈現 |
| 可承擔者 | /v1/iam/roles/:id/assumers | backend 已有 GET/PUT/DELETE;frontend 尚無 UI,只有權限點文案 |
| 升權 | /v1/iam/elevation-sessions | backend 已有 route;frontend 沒有 /iam/elevation 頁,也沒有呼叫此 API 的 hook |
| 權限目錄與選單 | /v1/iam/catalog、/iam/catalog | frontend 有頁面、群組、point 綁定與轉換工具 |
| 欄位政策/資料範圍 | /v1/iam/field-policies、/v1/iam/data-scopes | API 已有;資料範圍第一階段為固定內建六項且唯讀(角色表單會讀取);欄位政策沒有前端 UI |
| 資源級預檢 | POST /v1/authz/check | 拒絕也回 200,body 為 allowed、scope、reason_code,並納入 X-Elevation-Session;前端 hooks/api/useAuthzApi.ts,目前用於員工匯出逐筆預檢 |
因此「升權 API 已存在」與「升權管理頁已完成」必須分開描述;不要把 iam.elevation 的 page metadata
當成前端 route 已存在的證據。HR 的組織、員工、職位等畫面在缺少會話或收到 20102 時會顯示連到 /iam/elevation
的「前往升權會話」,該路徑落到 catch-all app/(platform)/[...segments]/page.tsx:頁面可見時只顯示「開發中」的
PendingRoutePage,不可見時顯示 not-found,目前無法從前端建立會話。
實際請求鏈
瀏覽器 same-origin /v1/**
→ proxy.ts 以 httpOnly cookie _t 注入 Authorization: Bearer
→ identityMiddleware → identity.Resolver(Keycloak JWT、tenant_id claim、user_identities)
→ GET /v1/me:EffectivePoints + IAM Profile
→ GET /v1/me/menus:exact page_keys / navigation
→ PageGate(path membership)+ permissionSet.can(point)
→ 業務 API 由 authorizeMiddleware 以 authz.Decide 再判定(含 X-Elevation-Session 與 tenant-wide)前端 useGetMe 的 Zod schema 在 runtime 要求六段都存在,但各層都是 .passthrough()(容許未知欄位,狀態值以字串接收、
由判定函式 fail closed);「恰好六段的 closed object」由後端 api/openapi.yaml 的 MeData(additionalProperties: false)
保證,前端漂移由 test/contract/openapiShape.test.ts 抓。hooks/api/useMenusApi.ts 的 useGetMenus 只負責取數;
精確 membership 在 libs/permission/menuAccess.ts 的 hasPage/hasPath,PageGate 依 path 判斷,不從父頁推導子頁。
libs/permission/permissionSet.ts 的 can() 是 Set 查找,不能改成 startsWith;升權不會改變 can() 的結果。
開發步驟
- 先在
internal/api/v1/routes.go(IAM 路由都在這裡;其他域在各自的*_routes.go或 handler 檔的register*Routes)找route.Point(...)宣告的 point、risk、tenant-wide 與 page,對照全量快照internal/route/testdata/registry.golden與頁樹internal/route/pages.go;api/openapi.yaml只提供 wire schema,不含 point/risk/page 等權限 metadata。 - 寫方案時先列端點、權限點、資料範圍、欄位效果與錯誤碼;不要先在 React 中藏按鈕。
- 角色或 group binding 變更由 service/iam 交易處理,確認 SoD、版本 CAS、有效時間窗與權限版本失效。後端會以 422
10430拒絕 break_glass/system 角色的綁定,以及綁給自己、綁給自己所在的群組或把自己加入群組(欄位碼break_glass_not_bindable、immutable、self_binding)。 - 若新增頁面或 point,改
internal/route/pages.go與路由宣告(API 啟動時由 catalog reconcile 逐租戶同步),再確認 /v1/me/menus 的呈現排序與 tenant-wide 可見性。 - 欄位政策只對已宣告 field 寫入;未宣告欄位回 422
10430(欄位碼undeclared),而不是靜默新增。 - 前端以
useGetMe、useGetMenus、IAM hooks 串接,保留 loading、403、503 與未知 enum 的 fail-closed 呈現;治理寫入後用libs/permission/revalidateIdentity.ts同時 revalidate/v1/me與/v1/me/menus;需要依單筆資源或目標員工判定時用POST /v1/authz/check,不要用 effective_permissions 推測資料範圍。
預期結果與驗證
預期結果是:同一帳號在 /v1/me 看見常駐權限與獨立升權狀態;/v1/me/menus 只列可見頁; IAM 管理頁的 mutation 仍由 backend permission、tenant-wide 與資料庫 RLS 共同守門。
建議按以下順序做驗證:
- 靜態核對
internal/route/testdata/registry.golden與路由宣告的 permission triple 完全一致(tests/contract/route_snapshot_test.go以 golden 比對)。 - 檢查角色綁定的生效/到期邊界,以及 group 半開時間窗。
- 用兩個受控租戶做正、負向查詢,確認 account/role/catalog 不跨租戶。
- 以升權 session 驗證「只收窄、不放寬」:沒帶
X-Elevation-Session時判定不變;撤銷後 /v1/me 的 elevation_session 變成 null,再帶同一 header 的請求回 40320102;effective_permissions 自始至終不含升權點。 - 檢查 field policy 的 mask/hide 輸出與資料範圍的條件,再進行前端頁面驗收。
以上是核對清單,不代表本輪已執行這些測試或 API 操作。
常見錯誤
- 把 effective_permissions 當成包含 elevation 的完整權限集。
- 以為 /v1/me 出現 elevation_session 就代表請求已升權;沒帶
X-Elevation-Session的請求不會使用會話。 - 只隱藏按鈕,卻沒有在 backend route/service 重做授權。
- 用 iam.* 前綴猜測權限,導致未知或相鄰 point 被意外放行。
- 將 menu visibility 當成 grant,或以父頁可見推導所有子頁;也別忘了 tenant-wide 頁需要 tenant 以上的資料範圍。
- 想把 break-glass role 綁成 standing access;後端會以
break_glass_not_bindable拒絕,應改走 assumer 資格加升權會話。 - 把目前找不到 frontend elevation page 寫成「升權功能不存在」;應標成 backend 已實作、UI 待補。
- 把 source-level test 檔存在寫成已通過,或把登入頁能開啟寫成 IAM mutation 已驗收。