权限与权益
角色、能力和权益三层授权。根据产品需求声明权限后即可检查,无需重新实现授权系统。
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认证守卫和授权检查是两件事:
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 角色,若要支持其他员工角色,必须同步更新角色配置、服务端守卫及所有受影响的管理入口,确保权限体系一致。
配置次数、额度和点数
布尔权益适合 “买了就有、过期就无” 的功能开关。例如示例中的 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 角色直接套给 token。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 里检查,而不是只改了配置。
- 如使用额度或 Credits,业务成功路径上已经调用扣减。
- 新的受保护 API 同时做了登录守卫和授权检查。
建议用一个普通账号和一个管理员账号分别验证,不要只看开发期间一直存在的测试用户。
常见问题
接下来
根据接下来要开发的功能,可以继续阅读: