權限與使用權益

以角色、能力與使用權益組成三層授權。依產品需求宣告權限後即可檢查,不必重新實作授權系統。

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.tsguest、register、premium、admin
靜態能力config/capability.tsuser.*、ticket.*、admin.*
使用權益config/entitlements.ts範例權益 starter_download → starter.download
OAuth 授權範圍config/scopes.tsuser、basic,套用於權杖,不是使用者角色

四個預設角色的意義如下:

角色何時取得預設靜態能力
guest尚未登入無。這是用來表達身分狀態的角色,不會寫入使用者紀錄
register註冊成功user 與 ticket 模組
premium購買範例方案與 register 相同,不會增加靜態能力
admin已驗證的電子郵件地址符合 adminEmailsadmin、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')。角色可以保留作為付費標記,產品能力仍應由使用權益提供,這樣取消訂閱後,功能才會隨著權益撤回。

將範本改成自己的產品時,請依下列步驟調整相關設定:

  1. 在 config/capability.ts 新增 Boolean 或 Numeric 能力。
  2. 在 config/entitlements.ts 建立使用權益,並列出這些能力。
  3. 在 config/products.ts 對應方案的 access 中授予權益。
  4. 在自己的頁面與 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 授權是獨立的系統,詳細說明請參考:

OAuth2 授權伺服器

在業務功能中檢查權限

完成設定後,業務程式碼直接讀取目前的授權結果即可。

基本原則是:

不要在業務程式碼中自行查詢角色、權益或付款資料表。請先確認身分,再檢查授權。

先完成使用者身分驗證,再檢查權限

受保護的頁面仍先執行 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 與授權檢查。

建議分別使用一般帳號與管理員帳號驗證,不要只檢查開發期間一直沿用的測試使用者。

常見問題

接下來

依接下來要開發的功能,可以繼續閱讀: