权限与权益

角色、能力和权益三层授权。根据产品需求声明权限后即可检查,无需重新实现授权系统。

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

认证守卫和授权检查是两件事:

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 角色,若要支持其他员工角色,必须同步更新角色配置、服务端守卫及所有受影响的管理入口,确保权限体系一致。

配置次数、额度和点数

布尔权益适合 “买了就有、过期就无” 的功能开关。例如示例中的 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 授权是单独体系,详见:

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 里检查,而不是只改了配置。
  • 如使用额度或 Credits,业务成功路径上已经调用扣减。
  • 新的受保护 API 同时做了登录守卫和授权检查。

建议用一个普通账号和一个管理员账号分别验证,不要只看开发期间一直存在的测试用户。

常见问题

接下来

根据接下来要开发的功能,可以继续阅读: