權限與使用權益
以角色、能力與使用權益組成三層授權。依產品需求宣告權限後即可檢查,不必重新實作授權系統。
Saavo 範本內建完整的能力、角色與使用權益三層授權系統,也提供預設設定。你只需要依自己的產品需求調整即可。
其中,能力(Capability)有三種類型:
enum CapabilityType {
Static = 'static',
Boolean = 'boolean',
Numeric = 'numeric',
}Static:靜態能力,不會因使用者而改變,適合描述角色的能力,例如user.*、ticket.*、admin.*。Boolean:布林能力,會因使用者而改變,適合描述功能型的使用權益,例如starter.download。Numeric:數值能力,會因使用者而改變,適合描述計量型的使用權益,例如starter.download.usage。
能力的上一層是角色或使用權益。可以先這樣理解:角色主要描述身分與職責,使用權益主要描述使用者擁有的產品權限與資源額度,能力則是組成角色與使用權益的基本單位。
角色通常包含一組預先定義的能力。例如,admin 角色可以包含使用者管理、訂單管理等能力。這裡的「預先定義」不是指使用者的角色無法改變,而是指角色本身包含哪些能力,通常是固定的。會改變的是某位使用者是否擁有該角色,而不是角色本身的能力定義。
相較於角色,使用權益與實際業務狀態的關聯更密切。使用者可能因為註冊、購買、訂閱或其他業務行為取得某項權益,也可能在使用產品時,消耗權益中的可用額度。
例如,產品可以定義:
Pro
├── download = true
├── export = true
└── credits = 1000其中的 download、export 與 credits 都屬於能力,用來描述這項權益具體包含什麼。
能力本身是抽象的概念,沒有固定的業務意義,需要依實際產品定義。
如果只需要表示使用者是否擁有某項功能或資格,可以使用布林能力。例如:
download = true這表示該角色或權益包含下載能力。
如果需要表示使用者擁有多少可用資源,可以使用數值能力。例如:
download = 100
credits = 1000分別表示 100 次下載額度與 1000 點。
三者的用途可以這樣理解:
- 角色:你是誰?
- 使用權益:你擁有什麼?還剩多少?
- 能力:角色與使用權益具體包含什麼?
實際開發時,大多數業務情境不需要直接判斷能力。
例如,判斷使用者能否進入管理後台,通常只需要確認是否擁有 admin 角色。判斷使用者是否已購買某項功能,通常只需要確認是否擁有對應權益。判斷額度是否足夠時,也可以直接讀取對應權益的可用額度。
只有業務需要更細緻的權限控制,或多個角色與權益需要共用同一種能力時,才需要直接判斷能力。
因此,角色與使用權益是業務開發最常使用的授權概念,能力則主要用來描述兩者內部包含的權限與資源。
開發自己的產品時,通常不必另外實作權限資料表或權限中介軟體。在設定中宣告角色、使用權益與對應能力後,範本會在註冊、購買等業務事件發生時,自動完成授予。業務頁面與 API 只要依實際情境判斷角色或使用權益,需要更細緻的控制時,再判斷能力即可。
既有功能
範本初始化後,即可使用下列授權功能:
| 層級 | 設定 | 預設內容 |
|---|---|---|
| 角色 | config/roles.ts | guest、register、premium、admin |
| 靜態能力 | config/capability.ts | user.*、ticket.*、admin.* |
| 使用權益 | config/entitlements.ts | 範例權益 starter_download → starter.download |
| OAuth 授權範圍 | config/scopes.ts | user、basic,套用於權杖,不是使用者角色 |
四個預設角色的意義如下:
| 角色 | 何時取得 | 預設靜態能力 |
|---|---|---|
guest | 尚未登入 | 無。這是用來表達身分狀態的角色,不會寫入使用者紀錄 |
register | 註冊成功 | user 與 ticket 模組 |
premium | 購買範例方案 | 與 register 相同,不會增加靜態能力 |
admin | 已驗證的電子郵件地址符合 adminEmails | admin、user、ticket |
業務程式碼統一使用 src/authz/index.ts 匯出的 authz。頁面與 API 不要直接查詢角色或權益資料表。

提示
premium 只表示「曾購買付費產品」。實際開放付費功能的是方案 access 授予的使用權益,不是角色名稱本身。
先體驗權限控制
修改權限設定前,建議先完整操作一次預設生命週期,確認範本已自動完成授予與撤回。
註冊成功
↓
取得 register 角色
↓
可使用帳號中心、提交客服案件等基本功能
購買範例方案
↓
取得 premium 角色與 starter_download 使用權益
↓
authz.hasEntitlementCapability(c, 'starter.download') 為 true
取消訂閱並等到訂閱結束
↓
撤回該次購買授予的角色與使用權益
使用 adminEmails 中的電子郵件地址完成驗證
↓
取得 admin 角色
↓
可以存取 /dashboard身分驗證 Guard 與授權檢查是兩件事:
authenticatedGuard()
↓
目前是誰、電子郵件與 2FA 是否符合政策
authz.hasRole / hasEntitlement / isAdmin
↓
這位使用者目前可以做什麼速率限制與 Turnstile 屬於安全政策,也不能取代授權檢查。
提示
預設的使用者角色包含基本靜態能力,例如 user.*、ticket.*、admin.*,但頁面邏輯並未使用這些能力。目前只有管理員身分會用來限制 /dashboard 頁面的存取。如果有其他需求,例如讓沒有 ticket 權限的使用者無法存取 /tickets,需要自行在頁面邏輯中補上判斷。
設定權限與使用權益
預設範本中的授權程式碼用於簡單示範。正式開發時,建議至少確認以下事項。
付費功能應使用權益,而不是 premium 角色
範例方案會同時授予 premium 與 starter_download。檢查付費功能時,應使用:
await authz.hasEntitlement(c, 'starter_download')或:
await authz.hasEntitlementCapability(c, 'starter.download')不要只寫 authz.hasRole(c, 'premium')。角色可以保留作為付費標記,產品能力仍應由使用權益提供,這樣取消訂閱後,功能才會隨著權益撤回。
將範本改成自己的產品時,請依下列步驟調整相關設定:
- 在
config/capability.ts新增 Boolean 或 Numeric 能力。 - 在
config/entitlements.ts建立使用權益,並列出這些能力。 - 在
config/products.ts對應方案的access中授予權益。 - 在自己的頁面與 API 中檢查新能力,不要修改共用
authz的語意。
完整範例請參考:
什麼時候需要新角色
需要新的靜態權限時,先判斷是否屬於現有角色。只有產品確實存在新的長期身分類別時,才在 config/roles.ts 新增角色,並一併檢查所有呼叫角色判斷的程式碼。
例如,產品需要「建立專案」功能:
// config/capability.ts
project: {
create: {
type: CapabilityType.Static,
description: { value: 'Create a project' },
},
},接著將 project 或 project.create 加入 register 或某個業務角色,再於頁面中檢查:
await authz.hasRole(c, 'register')或:
await authz.hasRoleCapability(c, 'project.create')請勿臨時在程式碼中新增 isStaff 之類的布林參數來判斷權限。目前的管理後台程式碼只檢查 admin 角色。若要支援其他員工角色,必須同步更新角色設定、伺服器端 Guard 與所有受影響的管理入口,確保權限系統一致。
設定次數、額度與點數
布林權益適合「購買後可用、到期後停用」的功能開關,範例中的 starter.download 就屬於這一類。
如果產品需要按次計費、每月額度限制或點數(Credits):
- 能力類型必須是
CapabilityType.Numeric。 - 方案的
access.entitlements必須使用kind: 'numeric',並填寫額度。 - 在業務 Service 中呼叫
userEntitlementCapabilityRepo.consumeQuotaBySubject扣除額度,並處理實際扣除量與不足的部分。
範例產品沒有 numeric 權益。只修改顯示文案,不會自動產生「每月 100 次」的額度。詳細說明請參考:
管理後台的 Quota Events 用來稽核額度的發放與消耗,不能取代業務邏輯中的額度扣除流程。
OAuth 授權範圍不等於使用者角色
config/scopes.ts 中的 user、basic 套用於第三方應用程式取得的存取權杖。即使使用者是管理員,權杖也可能只有 basic。
保護供第三方呼叫的 API 時,請使用 authz.hasScopes 或 authz.authorizeOAuthRequest,不要直接將使用者的 admin 角色套用到權杖上。OAuth 授權是獨立的系統,詳細說明請參考:
在業務功能中檢查權限
完成設定後,業務程式碼直接讀取目前的授權結果即可。
基本原則是:
不要在業務程式碼中自行查詢角色、權益或付款資料表。請先確認身分,再檢查授權。
先完成使用者身分驗證,再檢查權限
受保護的頁面仍先執行 authenticatedGuard,通過後再檢查角色、使用權益或管理員身分:
const guardResult = authenticatedGuard(c);
if (!guardResult.success) {
return c.redirect(
getFullPath(guardResult.details?.redirectUrl as string, locale),
);
}
if (!await authz.hasEntitlementCapability(c, 'starter.download')) {
return c.redirect(getFullPath('/#pricing', locale));
}API 也採用相同方式,只是在失敗時回傳 JSON,而不是重新導向。
常見的檢查如下:
await authz.hasRole(c, 'register')
await authz.hasRoleCapability(c, 'ticket.create')
await authz.hasEntitlement(c, 'starter_download')
await authz.hasEntitlementCapability(c, 'starter.download')
await authz.isAdmin(c)
await authz.hasPurchasedProduct(c, productId, planId)hasPurchasedProduct 用來判斷是否曾購買某個方案,適合用來避免重複購買。決定是否開放付費功能時,則應檢查使用權益。
整體流程通常如下:
請求
↓
authenticatedGuard()
↓
authContext
↓
角色 / 使用權益 / 能力
↓
業務邏輯在業務 Service 中扣除額度
Numeric 能力不會因為「使用者按下按鈕」就自動減少。同一位使用者可能同時擁有註冊贈送、訂閱與加值等多筆額度紀錄。可以在業務 Service 中使用 consumeQuotaBySubject,依使用者與能力統一扣除:
import { userEntitlementCapabilityRepo } from '@/core/repositories/access/user-entitlement-capability';
import { subjectOfUser } from '@/core/services/access/subject';
const result = await userEntitlementCapabilityRepo.consumeQuotaBySubject(
workerCtx,
{
subject: subjectOfUser(userId),
capabilityKey,
amount: 1,
eventType: 'consume',
reason: 'export',
metadata: { taskId },
},
);這裡的 userId 來自已驗證的使用者身分,capabilityKey 是由業務 Service 決定的 Numeric 能力,taskId 則用來關聯這次業務工作。API 先完成身分驗證與授權,再呼叫業務 Service,不直接呼叫 Repository。
回傳值中的 requested 是要求扣除的數量,consumed 是實際扣除量,remaining 是扣除後的餘額,debt 是不足的部分。餘額不足時,函式會先扣除可用額度並回傳 debt,不會自動拋出「額度不足」例外,業務邏輯必須處理這個結果。
扣款時機由業務規則決定。例如,「只有成功匯出才收費」的功能,應在確認匯出成功後結算。只檢查一次餘額,無法避免多個並行請求同時通過。業務邏輯還需要明確定義並行控制、餘額不足與部分完成時的處理方式。同一項工作重試時不能重複扣款,metadata.taskId 只是交易紀錄中的資訊,不會自動提供等冪性。
adjustQuotaUsed 仍可用來調整指定紀錄的已用額度,但不會自動跨紀錄分攤,也不會自動阻止超額調整,因此不能將它視為完整的業務扣款流程。購買後的發放與訂閱結束後的撤回,仍由付款生命週期處理。
不能依賴前端判斷權限
前端可以依目前使用者的狀態,決定是否顯示升級按鈕或隱藏入口,但資料存取必須在伺服器端透過 authz 檢查。能開啟某個 React 頁面,不代表已取得授權。
上線檢查
授權會直接影響付費功能與後台入口,上線前建議至少確認:
-
config/base.ts的adminEmails已改為實際管理員的電子郵件地址。 - 使用該地址註冊並完成驗證後,可以進入
/dashboard。 - 一般註冊使用者只有
register角色,無法存取管理後台。 - 購買後,權益檢查結果為真,且可以使用付費功能。
- 訂閱結束後會撤回權益,付費功能不再可用。
- 已在自己的頁面與 API 中檢查自訂角色、能力與權益,不是只修改設定。
- 若使用額度或點數,業務成功流程已呼叫額度扣除功能。
- 新增的受保護 API 同時執行登入 Guard 與授權檢查。
建議分別使用一般帳號與管理員帳號驗證,不要只檢查開發期間一直沿用的測試使用者。
常見問題
接下來
依接下來要開發的功能,可以繼續閱讀:
- 想限制登入後才能存取的頁面 → 登入後才能存取的頁面
- 想限制管理員頁面 → 管理員頁面
- 想限制付費功能 → 付費後才能使用的功能
- 想依次數或點數計費 → 設計方案與使用權益
- 想了解方案如何授予使用權益 → 付款與方案
- 想保護供第三方呼叫的 API → OAuth2 授權伺服器
- 想查詢角色、能力與使用權益的設定欄位 → 設定檔