跳至主要内容

ADR-007: Applet 層級權限模型

狀態

已採用 (Accepted) - 2025-12-20

變更歷史:

  • 2025-12-19: 初版 - Role + Authority 雙層模型
  • 2025-12-20: 簡化版 - 前端只用 Role,API 層用 R/W/X Authority
  • 2025-12-20: 最終版 - 移除 Authority,前端只做 Applet 層級控制
  • 2025-12-20: 角色格式統一 - 全系統使用 ROLE_XXX 格式
  • 2025-12-20: 角色繼承與扁平化 - 模擬 Spring Security Role Hierarchy

背景 (Context)

問題陳述

原有的 Role + Authority 設計過於複雜:

  1. 前端維護 Authority 定義和 Role-to-Authority 映射
  2. Mock 層需要對每個 API 做權限檢查
  3. 組件層需要判斷用戶是否有權限執行特定操作

經過評估,決定大幅簡化權限模型,遵循以下原則:

  1. 前端權限以 Applet 為單位:只用 RoleGuard 保護 Applet 入口
  2. 進入即全功能:通過 RoleGuard 後,該 Applet 所有功能都可用
  3. 後端兜底:實際環境由 Spring Security 提供 API 層保護
  4. Mock 不做權限管制:只做認證(token 有效性),不做權限檢查

簡化動機

  • 前端是參考實作,不應承擔權限控制的責任
  • 真正的權限控制由後端 Spring Security 處理
  • 如果某角色只需要部分功能,應設計獨立的 Applet,而非在組件層做權限檢查
  • Mock 環境的目的是開發與測試,不需要模擬權限拒絕

決策 (Decision)

核心設計

┌─────────────────────────────────────────┐
│ UI 層:Role → Applet │
│ 「誰可以進入哪個功能模組」 │
│ - 前端只需要 Role │
│ - RoleGuard 是唯一的權限檢查點 │
│ - 進入 Applet 後所有功能都可用 │
└─────────────────────────────────────────┘

┌─────────────────────────────────────────┐
│ API 層:後端 Spring Security │
│ 「對資源可以讀還是寫還是執行」 │
│ - 前端不做權限判斷 │
│ - 前端收到 403 就顯示無權限 │
│ - Mock 只驗證 token,不做權限檢查 │
└─────────────────────────────────────────┘

Role 定義

全系統統一使用 Spring Security 格式(ROLE_XXX):

Role說明
ROLE_ADMIN系統管理員
ROLE_OWNER店主
ROLE_MANAGER店長
ROLE_SALES銷售人員
ROLE_ACCOUNTANT會計
ROLE_PURCHASER採購
ROLE_FLORIST花藝師
ROLE_DELIVERY配送員

角色繼承(Role Hierarchy)

後端 Spring Security 使用角色繼承機制,前端收到扁平化的 roles 陣列:

ROLE_ADMIN
└── ROLE_OWNER
├── ROLE_MANAGER
│ ├── ROLE_SALES
│ └── ROLE_ACCOUNTANT
├── ROLE_PURCHASER
├── ROLE_FLORIST
└── ROLE_DELIVERY

扁平化 roles 陣列範例:

主要角色回傳的 roles 陣列
ROLE_ADMIN[ADMIN, OWNER, MANAGER, SALES, ACCOUNTANT, PURCHASER, FLORIST, DELIVERY]
ROLE_OWNER[OWNER, MANAGER, SALES, ACCOUNTANT, PURCHASER, FLORIST, DELIVERY]
ROLE_MANAGER[MANAGER, SALES, ACCOUNTANT]
ROLE_SALES[SALES]
ROLE_FLORIST[FLORIST]

設計優點:

  • allowedRoles 只需指定「最基本需要的角色」
  • 例如:訂單管理只需 [ROLE_SALES],店主自動有權限(因為繼承 SALES)
  • 大幅簡化前端配置

Role → Applet 對應(簡化版)

由於角色繼承,allowedRoles 只需指定最基本的角色:

AppletallowedRoles說明
orders[SALES]OWNER/MANAGER 自動有權限
customers[SALES]OWNER/MANAGER 自動有權限
products[PURCHASER, FLORIST]OWNER 自動有權限
sales[SALES]OWNER 自動有權限
design[FLORIST]OWNER 自動有權限
delivery[DELIVERY]OWNER 自動有權限
reports[ACCOUNTANT]OWNER/MANAGER 自動有權限
analytics[ACCOUNTANT]OWNER/MANAGER 自動有權限
settings[ADMIN]僅 ADMIN

共用 Applet(空陣列 = 所有角色可見): home, calendar, messages

實施細節

1. 角色繼承映射

// src/types/auth.ts
export const ROLE_HIERARCHY: Record<RoleName, RoleName[]> = {
[Role.ADMIN]: [Role.ADMIN, Role.OWNER, Role.MANAGER, Role.SALES, Role.ACCOUNTANT, Role.PURCHASER, Role.FLORIST, Role.DELIVERY],
[Role.OWNER]: [Role.OWNER, Role.MANAGER, Role.SALES, Role.ACCOUNTANT, Role.PURCHASER, Role.FLORIST, Role.DELIVERY],
[Role.MANAGER]: [Role.MANAGER, Role.SALES, Role.ACCOUNTANT],
[Role.SALES]: [Role.SALES],
[Role.ACCOUNTANT]: [Role.ACCOUNTANT],
[Role.PURCHASER]: [Role.PURCHASER],
[Role.FLORIST]: [Role.FLORIST],
[Role.DELIVERY]: [Role.DELIVERY],
};

2. 路由守衛(檢查 roles 陣列交集)

// src/routes/role-guard.tsx
// 檢查用戶的 roles 陣列是否與 allowedRoles 有交集
const hasPermission =
allowedRoles.length === 0 ||
currentUser.roles.some((role) => allowedRoles.includes(role));

// 使用範例:只需指定最基本的角色
<RoleGuard allowedRoles={[Role.SALES]}>
<SalesApplet /> {/* OWNER 自動有權限 */}
</RoleGuard>

3. Applet 權限配置(簡化版)

// src/config/applet-registry.ts
import { Role } from '@/types/auth';

{
id: 'orders',
name: 'Order Management',
allowedRoles: [Role.SALES], // 簡化:OWNER/MANAGER 自動有權限
// ...
}

{
id: 'design',
name: 'Design Workbench',
allowedRoles: [Role.FLORIST], // 簡化:OWNER 自動有權限
// ...
}

4. Mock API 回傳扁平化 roles

// src/mocks/handlers/auth.ts
// 登入回應使用 ROLE_HIERARCHY 取得扁平化 roles
const roles = ROLE_HIERARCHY[user.role as RoleName] ?? [user.role];

return HttpResponse.json({
accessToken,
refreshToken,
user: {
...userWithoutPassword,
roles: [...roles], // 扁平化陣列
authorities: [...authorities],
},
});

5. usePermission Hook(僅提供用戶資訊)

// src/hooks/use-permission.ts
const { currentUser } = usePermission();

// 用於 UI 顯示用戶資訊
<span>Role: {currentUser?.role}</span>

6. Mock 認證(僅驗證 token)

// src/mocks/middleware/auth-middleware.ts
export function checkAuth(request: Request): AuthCheckResult {
// 只驗證 token 有效性
// 不做任何權限檢查
}

已移除的內容

項目原因
Authority 常數定義前端不需要
ROLE_AUTHORITIES 映射表前端不需要
hasRole() hook不需要在組件層檢查
hasAnyRole() hook不需要在組件層檢查
canAccess() hook不需要在組件層檢查
AuthorityGuard 組件不需要細粒度權限控制
hasAuthority() hook前端不需要
src/mocks/utils/permissions.tsMock 不做權限檢查
src/utils/role-utils.ts統一格式後不需要轉換
組件層 allowedRoles 配置進入 Applet 即可使用所有功能

決策後果 (Consequences)

正面影響 (Positive)

  • 大幅簡化 - 移除所有 Authority 相關邏輯和格式轉換
  • 職責分離 - 前端只管 UI 入口,權限控制交給後端
  • 維護成本降低 - 不需要維護 Role-to-Authority 映射和格式轉換
  • Mock 更簡潔 - 只做認證,不模擬權限拒絕
  • 概念清晰 - 進入 Applet 即可使用所有功能,無需判斷按鈕是否可用
  • 格式統一 - 前端、Mock、後端使用同一格式(ROLE_XXX)
  • 角色繼承 - 模擬 Spring Security Role Hierarchy,allowedRoles 只需指定最基本角色
  • 配置簡化 - 例如訂單管理只需 [SALES],店主自動有權限

設計原則

  • ⚠️ 如果某角色只需要部分功能

    • 解法:為該角色設計專屬的 Applet(如 Sales Applet、Design Applet、Delivery Applet)
    • 不要在組件層做權限判斷
  • ⚠️ 前端無法阻止無權限的 API 呼叫

    • 解法:這是正確的,權限控制由後端負責
    • 前端收到 403 時顯示錯誤訊息即可

保留的內容

項目原因
RoleName 類型RoleGuard 和菜單過濾需要
Role 常數便於使用,避免字串硬編碼
VALID_ROLES 常數角色驗證需要
ROLE_HIERARCHY 常數角色繼承映射,Mock API 使用
RoleGuard 組件Applet 層級存取控制(使用 roles 陣列交集檢查)
ProtectedRoute 組件認證檢查
menu-config.tsallowedRolesApplet 層級菜單過濾
applet-registry.tsallowedRolesApplet 層級應用啟動器過濾(簡化版)
checkAuth 函數Mock 認證(token 有效性)

影響範圍 (Impact)

修改的檔案

檔案變更
src/types/auth.ts新增 ROLE_HIERARCHY 角色繼承映射
src/routes/role-guard.tsx使用 roles 陣列交集檢查
src/config/applet-registry.ts簡化 allowedRoles(只需最基本角色)
src/components/application-launcher/使用 roles 陣列過濾
src/mocks/utils/jwt.ts使用 ROLE_HIERARCHY 生成扁平化 roles
src/mocks/handlers/auth.ts回傳扁平化 roles 陣列
src/features/iam/me-slice.ts移除格式轉換邏輯
src/hooks/use-permission.ts只保留 currentUser

已刪除的檔案

  • src/mocks/utils/permissions.ts - Mock 不做權限檢查
  • src/utils/role-utils.ts - 統一格式後不需要轉換
  • src/routes/authority-guard.tsx - 不需要細粒度權限控制

參考資料 (References)


最後更新: 2025-12-20 決策者: 開發團隊