線稿索引所有 spec最後更新 2026-07-02
specs/dynamic-rbac.md

動態 RBAC

一句話:把「角色 → 權限指派」從寫死在 code 搬進 DB,讓 admin 在系統內以 resource × level 矩陣(GitHub fine-grained token 風)編輯角色權限;權限 catalog 仍由 code 定義

1. 目標 / 問題

現況:權限分兩層 ——

  1. catalog(capabilities)statement(resource → actions)寫在 packages/core/src/permissions-ac.ts。每個 (resource, action) 背後對應 code 裡真的有一道 requirePermission('x.y') 閘。這層必須 code 定義(憑空在 DB 加權限字串,code 沒對應檢查點 = 無意義)。
  2. 角色 → 權限指派:目前 salesGrant/opGrant/… 也寫死在 codeac.newRole(...) 是靜態物件;user.role 只存角色名。改 who-can-do-what 要改 code + deploy。staff 頁的權限矩陣是唯讀顯示

目標:第 2 層改成 DB-backed、admin 可在系統內 CRUD。catalog(第 1 層)維持 code。

為何現在可行:catalog 已是 resource-centric(trip/guide/journal/image/lodging[read, manage]order 細 verbs…),正好就是編輯 UI 需要的 resource × level 矩陣。前置「內容權限 resource-centric 化(修 read-gates-write)」已落地,見 permissions-ac

2. 資料模型

  • 平台層(control_db)或租戶層(business db)擇一存角色定義。傾向 租戶層(各租戶可有自己的角色集):
    • roles(租戶自訂角色:id / name / label / is_system)—— is_system 標記內建 5 角色(admin/sales/op/accountant/customer),不可刪。
    • role_permissionsrole_id / resource / action)或單一 JSON 欄 role.permissions jsonb(resource → actions[])。JSON 欄較簡單、夠用。
  • user.role 維持存角色名;解析時對映到該租戶的角色定義。
  • 內建 5 角色的現行靜態 grant 當 seed 灌進去(落地時一次性遷移)。

3. 邏輯 / 存取層

  • custom resolverrequirePermission 目前走 better-auth auth.api.userHasPermission(吃靜態 ac.newRole)。改成:
    • 方案 A:每請求依 user.role 從 DB 讀該角色 permissions → 動態建 ac.newRole(grant) 再檢查(或直接比對 resource/action)。
    • 方案 B:仍用 better-auth,但 app 啟動 / 角色變更時把 DB grant 同步成 better-auth role 定義。
    • 傾向 A(直接、租戶隔離乾淨);注意 cache(per-request cache(),避免每閘打 DB)。
  • catalog(statement)仍是唯一「合法權限字串」來源;DB grant 只能引用 catalog 內的 (resource, action)(存檔時驗證)。

4. UI 與權限

  • /admin/staff(或新 /admin/roles):角色清單 + 每角色一張 resource × level 矩陣
    • 每列一 resource(catalog 來),每格選 level(none / read / manage,或細到逐 action)。GitHub fine-grained token UI 的本地版。
    • 內建系統角色可複製不可改名/刪;自訂角色可 CRUD。
  • 閘:編輯角色權限本身需高權限 —— 用既有 role.manage(catalog 已有,僅 admin)。
  • staff 頁現行唯讀矩陣(src/components/admin/permission-matrix.tsx)升級成可編輯來源。

4.1 員工帳號生命週期(決策:離職走 banUser 凍結,2026-07-01)

員工帳號三態:active(在職)/ pending(已邀請未接受一次性密碼)/ suspended(離職凍結)。

  • 邀請inviteStaffActionuser + set role + 一次性臨時密碼;未首次登入前為 pending
  • 離職 / 停用:走 better-auth admin plugin banUser 完全凍結登入(ban_reason / ban_expires 可選),不是把 role 降級成 customer。凍結後該帳號無法以任何身分登入;復職走 unbanUser
  • 理由:降級 customer 會讓離職員工仍能以旅客身分登入、看自己的訂單,非「凍結存取」語意。

已落地:離職 / 復職走 banStaff / unbanStaff —— 直寫 user.banned(+ ban_reason / ban_expires)並刪該 user 所有 session;better-auth session.create.before hook 讀 user.banned 擋登入,凍結後無法以任何身分登入(非降級 customer)。staff 清單顯示帳號狀態(active / pending / suspended)+ 2FA 欄,pending 態由 emailVerified=false && twoFactorEnabled=false 派生(已邀請未接受一次性密碼)。

4.2 員工角色指派需涵蓋自訂角色(未閉環)

已落地:員工邀請 / 變更角色的 Select 動態讀該租戶 roles(含自訂角色),指派閘改用 isAssignableStaffRole(fail-closed:驗「存在於租戶 roles 且非 customer」而非硬編 4 內建角色),自訂角色 end-to-end 閉環。

5. 開放議題

  • 層級粒度:level 用 none/read/manage 三級(簡單、GitHub 風),還是逐 action 勾選(細、但 UI 雜)?傾向三級為主、order/payable 等多-verb resource 例外逐 action。
  • 角色存哪:租戶層(各租戶自訂)vs 平台層(全平台共用角色集)。傾向租戶層。
  • better-auth 整合:自刻 resolver(方案 A)會繞過 better-auth 的 AC 檢查——要確認 admin plugin 其他功能(user 管理端點)不依賴它的 role 物件。
  • catalog 演進:新增 code 權限時,既有自訂角色不會自動拿到 → 需要「新權限預設給誰」策略(預設只給 admin,其他角色 admin 再去勾)。
  • settings / member-ownership 的角色概念對齊。

Changelog

版本日期變更Commit
12026-06-01初版草案:DB-backed 可編輯角色權限(resource × level 矩陣,GitHub 風)。前置「內容權限 resource-centric 化」已落地;本刀把靜態 grant 變 DB CRUD(custom resolver + 角色/權限表 + admin 矩陣 UI),分先後做的後半。
22026-06-20§5 開放議題的 payment_request 對齊現行命名改為 payable
32026-06-23後半落地:custom resolver(builtin → better-auth 靜態 ac wildcard;custom → role_permissions DB 解析,未知角色 fail-closed)、packages/core/src/permissions/roles-repo.ts(角色 CRUD + 內建 5 角色 idempotent seed)、/admin/roles resource×action 可編輯矩陣(admin-only,掛「人員 & 會員」nav)。內建角色唯讀;roleFromSession 不再把自訂角色名收斂成 customer。
42026-07-02§4.1 / §4.2 code 落地:離職 / 復職走 banStaff / unbanStaff(直寫 user.banned + 刪 session,session.create.before 讀該欄擋登入),staff 清單顯示帳號狀態 / 2FA / pending 派生(emailVerified=false && twoFactorEnabled=false);員工邀請 / 變更角色動態讀租戶 roles + isAssignableStaffRole(fail-closed)閉環自訂角色指派。解除 D1。