Strapi @strapi/permissions 权限引擎实战:从 CASL Ability 构建到 Hook 拦截机制
【免费下载链接】strapi🚀 Strapi is the leading open-source headless CMS. It’s 100% JavaScript/TypeScript, fully customizable, and developer-first.项目地址: https://gitcode.com/GitHub_Trending/st/strapi
@strapi/permissions是 Strapi 核心权限引擎包(位于 packages/core/permissions),负责把一组「权限规则」编译成 CASL(@casl/ability)的 Ability 对象,供运行时判断「某主体能否执行某动作、能访问哪些字段、是否满足查询条件」。本篇将基于该包的官方文档与 源码实现,完整讲清引擎的创建、providers 依赖、能力生成流程、五类 Hook 的执行时序与 Strapi Admin 后端对它的真实用法,帮助你既能独立使用该包,也能理解 Strapi RBAC 底层的判定原理。
安装与包定位
文档给出的安装方式:
yarn add @strapi/permissions从 package.json 可以看到,该包当前版本为 5.52.2,其运行时依赖非常精简:
@casl/ability6.7.5 —— 能力模型的底层实现(Ability / AbilityBuilder);@strapi/utils—— 提供providerFactory(providers 的容器)与hooks(Hook 系统);lodash—— 引擎内部的函数式处理;qs—— 用于序列化「带参数动作」(parametrized action);sift—— 在内存中对 RBAC 条件查询(condition)做匹配。
包的公开入口见 src/index.ts,仅导出两个命名空间:
import * as domain from './domain'; // 权限领域对象:create / addCondition / getProperty import * as engine from './engine'; // 权限引擎:new / abilities export { domain, engine };因此文档示例中的permissions.engine.new(...)、permissions.domain等调用都来自这里。
创建引擎实例:providers 是硬性依赖
创建引擎的核心 API 是engine.new(params),其中params的类型定义在 src/engine/index.ts:
export interface EngineParams { providers: { action: ActionProvider; condition: ConditionProvider }; abilityBuilderFactory?(): abilities.CustomAbilityBuilder; }两个硬性要求:
- 必须同时提供
action和condition两个 provider。文档明确指出:“You need to give both an action and a condition provider as parameters when instantiating a new permission engine instance. They must be contained in aprovidersobject property.” 从源码看,conditionprovider 会在条件求值阶段被providers.condition.get(id)逐个解析(src/engine/index.ts),如果找不到对应条件或其 handler 不是函数,该条件会被静默过滤。 - 可选传入
abilityBuilderFactory定制generateAbility返回的 Ability 类型,默认使用abilities.caslAbilityBuilder(即@casl/ability的 builder),对应文档中的说明:“By default it'll use a@casl/abilitybuilder.”
一个最小可用的完整示例(文档示例 + 测试文件中 providers 的真实构造方式,参考 单元测试):
const permissions = require('@strapi/permissions'); const { providerFactory } = require('@strapi/utils'); // providers:action / condition 两个提供者容器 const providers = { action: providerFactory(), condition: providerFactory(), }; // 注册一个条件(真实 Strapi 中由插件注册,如 plugin::content-manager.isOwner) await providers.condition.register('isAuthor', { name: 'isAuthor', async handler() { return true; }, }); const engine = permissions.engine.new({ providers }); const ability = await engine.generateAbility([ { action: 'read' }, { action: 'delete', subject: 'foo' }, { action: 'update', subject: 'bar', properties: { fields: ['foobar'] } }, { action: 'create', subject: 'foo', properties: { fields: ['foobar'] }, conditions: ['isAuthor'], }, ]); ability.can('read'); // true ability.can('publish'); // false ability.can('update', 'foo'); // false ability.can('update', 'bar'); // true这里体现的四条判定规律值得注意:
{ action: 'read' }不指定 subject,会被注册为对all主体生效——CASL builder 内部对subject为空的规则做了归一化处理(见下文「CASL Builder 细节」);publish动作未注册,判定为false;update + foo未注册,update + bar已注册;create + foo附带isAuthor条件:条件通过则以无条件形式注册;若条件 handler 返回对象(查询片段),则会被合并为 condition 挂在规则上。
条件(conditions)求值逻辑:源码级深挖
generateAbility内部对每条权限执行evaluate流程(src/engine/index.ts),其中条件求值是最复杂的分支,源码逻辑可以归纳为四步:
- 解析:
resolveConditions用providers.condition.get(id)把conditions: ['isAuthor']之类的字符串 ID 解析为{ name, handler }对象,并过滤掉不存在或 handler 非函数的无效条件; - 执行:每个条件的 handler 以
_.merge(options, { permission: cloneDeep(permission) })为入参并行执行(Promise.all),即 handler 可以拿到generateAbility(permissions, options)传入的第二个参数(在 Admin 中通常是当前用户对象)以及被克隆的权限对象本身; - 过滤结果:只保留返回
boolean或object的结果; - 三种分支注册(src/engine/index.ts):
if (evaluatedConditions.every(resultPropEq(false))) { return; // 所有条件都返回 false → 该权限整体作废,不注册 } if (_.isEmpty(evaluatedConditions) || evaluatedConditions.some(resultPropEq(true))) { return register({ action, subject, properties }); // 有任一条件返回 true → 无条件注册 } // 全部返回对象(查询片段)→ 合并为 $and/$or 条件后注册 return register({ action, subject, properties, condition: { $and: [{ $or: results }] } });也就是说:条件之间是「或」语义——任一条件为真则权限生效;全部为假则权限被丢弃;全部返回查询对象时,这些对象会被包进{ $and: [{ $or: [...] }] }作为 Ability 规则的 condition,最终在ability.can()调用时由内存查询匹配器判定。单元测试中的hasId125/hasId200两个返回{ id: ... }对象的条件正是覆盖此分支(见 测试用例)。
此外还有一个容易忽略的细节:如果权限携带actionParameters,引擎会用qs.stringify将其拼进动作名(src/engine/index.ts):
if (actionParameters && Object.keys(actionParameters).length > 0) { action = `${actionName}?${qs.stringify(actionParameters)}`; }五类 Hook:完整时序与各自能力
文档说:“You can also register to some hooks for each engine instance. Seelib/engine/hooks.js->createEngineHooksfor available hooks.”(lib/为旧版目录名,当前源码位于 src/engine/hooks.ts)。当前版本共有5 个 Hook,且各自对应不同的钩子类型,这直接决定了 handler 的写法(能否返回false中断、能否返回新对象覆盖):
| Hook 名称 | 钩子类型(@strapi/utils hooks) | 可做什么 |
|---|---|---|
before-format::validate.permission | AsyncBailHook | 返回false立即废弃该权限;上下文提供只读permission克隆 |
format.permission | AsyncSeriesWaterfallHook | 返回新对象可改写权限(前一个 handler 的输出作为下一个的输入) |
after-format::validate.permission | AsyncBailHook | 格式化后再校验,返回false废弃 |
before-evaluate.permission | AsyncSeriesHook | 上下文额外提供addCondition(condition)方法,可向权限动态追加条件 |
before-register.permission | AsyncSeriesHook | 注册前最后拦截;上下文提供condition.and(obj)/condition.or(obj)向规则追加查询条件 |
其中前三个的执行顺序写死在evaluate函数中(src/engine/index.ts):
before-format::validate.permission →(false 则终止) format.permission →(waterfall,可改写权限对象) after-format::validate.permission →(false 则终止) before-evaluate.permission而before-register.permission在createRegisterFunction包装的 register 阶段触发(src/engine/index.ts),before-evaluate.permission在条件解析前触发。
文档示例一:用 bail hook 拦截权限
const engine = permissions.engine .new({ providers }) .on('before-format::validate.permission', ({ permission }) => { if (permission.action === 'read') { return false; // bail:终止该权限的后续流程 } }); const ability = await engine.generateAbility([ { action: 'read' }, // ...其余同前 ]); ability.can('read'); // false,因为校验 hook 阻止了引擎注册该权限这里{ permission }就是createBeforeEvaluateContext/createValidateContext生成的上下文——注意上下文的permission是克隆(cloneDeep),handler 里对它的普通修改不会影响原权限对象,只有 bail hook 返回false或 waterfall hook 返回新对象才能产生实际影响。
文档示例二:用 waterfall hook 改写动作名
const engine = permissions.engine .new({ providers }) .on('before-format::validate.permission', ({ permission }) => { if (permission.action === 'modify') return false; // 拦截最终动作名 }) .on('after-format::validate.permission', ({ permission }) => { if (permission.action === 'update') return false; }) .on('format.permission', ({ permission }) => { if (permission.action === 'update') { return { ...permission, action: 'modify' }; } if (permission.action === 'delete') { return { ...permission, action: 'remove' }; } return permission; }); const ability = await engine.generateAbility([{ action: 'update' }, { action: 'delete' }]); ability.can('update'); // false ability.can('modify'); // true,因为 format.permission 把它改成了 'modify' ability.can('delete'); // false,被改写成了 'remove' ability.can('remove'); // true这个例子精确演示了执行时序:before-format::validate看到改写之前的动作名(所以拦截'modify'不影响由'delete'改出的'remove'),而after-format::validate看到改写之后的动作名。文档中的注释 “before-format::validate.permission validates before format.permission changed it” 说的正是这一点。
注册前追加条件:before-register 的上下文能力
createWillRegisterContext(src/engine/hooks.ts)在before-register.permission阶段提供了比文档示例更进一步的能力——直接向即将注册的规则追加查询条件:
engine.on('before-register.permission', (ctx) => { // 给该权限追加 $and 条件:只有满足查询的主体才放行 ctx.condition.and({ role: { $in: ['admin', 'editor'] } }); // 或追加 $or 条件 ctx.condition.or({ id: { $eq: 1 } }); });CASL Builder 细节:subject 归一化、字段限制与条件匹配器
默认 builder 实现位于 src/engine/abilities/casl-ability.ts,有三个关键设计:
- subject 为
null/undefined时注册为'all',properties.fields直接作为 CASL 的字段参数传入can(action, subject, fields, condition)——这就是为什么{ action: 'read' }能对任意主体生效,而{ action: 'update', subject: 'bar', properties: { fields: ['foobar'] } }只允许更新foobar字段; - 参数化动作(parametrized action):
PermissionRule.action允许{ name, params }形式(类型定义见 src/types.ts),builder 会将其序列化为'actionName?key=value'字符串,并且build()后返回的 Ability 的can方法被装饰(decorate),调用ability.can({ name, params }, subject)时同样会自动序列化,保证注册与查询两端格式一致; - 内存条件匹配器:
build({ conditionsMatcher })中用sift创建查询测试器,且只开放了一组白名单操作符($or、$and、$eq、$ne、$in、$nin、$lt、$lte、$gt、$gte、$exists、$elemMatch)。若条件里使用了白名单之外的操作符(如$startsWith),会抛出明确的错误:RBAC condition uses unsupported operator ...。单元测试中unsupportedOperator条件正是覆盖此边界。
CustomAbilityBuilder接口(casl-ability.ts)要求can、buildParametrizedAction、build三个成员,这就是abilityBuilderFactory定制点需要满足的契约。
权限领域对象(domain)
permissions.domain暴露了权限的构造与操作函数(src/domain/permission/index.ts):
Permission接口字段:action(必填)、subject、properties、conditions、actionParameters;create(attributes):用_.pick只保留这四个字段并合并默认值(conditions: []、properties: {}、subject: null),等价于权限入参的「消毒」;addCondition(condition, permission):向conditions数组去重追加条件,before-evaluate.permission上下文的addCondition方法内部调用的就是它(src/engine/hooks.ts);getProperty(property, permission):从properties中取嵌套值,如getProperty('fields', permission)。
真实集成:Strapi Admin 后端如何驱动这个引擎
@strapi/permissions并非孤立存在——Strapi 管理面板的 RBAC 直接构建在它之上,典型集成代码见 packages/core/admin/server/src/services/permission/engine.ts,它恰好示范了文档中各 Hook 的典型用途:
const engineInstance = engine .new({ providers }) // 1. 校验动作是否在 action 注册表中存在,不存在则拦截 .on('before-format::validate.permission', ({ permission }) => { const action = providers.action.get(permission.action); if (!action) { strapi.log.debug(`Unknown action "${permission.action}" ...`); return false; } }) // 2. 按 action.applyToProperties 清掉不允许的 properties .on('format.permission', (permission) => { /* ... */ }) // 3. fields 为空数组(不授权任何字段)时整条权限作废 .on('after-format::validate.permission', ({ permission }) => { const { fields } = permission.properties; if (isArray(fields) && isEmpty(fields)) { return false; } });该服务最终对外提供三个方法:generateUserAbility(user)(查出用户的角色权限集后调用generateAbility(permissions, user),把用户对象作为 options 传给条件 handler)、generateTokenAbility(tokenPermissions, owner)(管理端 Token 场景)和checkMany(ability, permissions)(批量ability.can判断)。这也解释了文档示例中generateAbility(permissions)之外实际还有一个options参数的用途——Admin 场景下它就是当前用户,条件 handler 可以基于它判断「是否作者」「是否所有者」等。
小结:什么时候用引擎,什么时候只用 Ability
结合文档与源码,这个包的使用可以归纳为两层:
- 权限定义/生成层(使用
engine.new+generateAbility):把结构化的权限规则(含条件、字段)编译成 Ability,适合插件/后端在启动或鉴权前构建能力集,配合 providers 管理动作与条件; - 运行时判定层(只使用生成的
ability.can(action, subject, fields)):在路由控制器或策略中做细粒度判断,支持字段级(properties.fields)与条件查询级的授权。
需要注意的适用前提:该包要求 Node.js>=20.0.0(见 package.json 的engines);条件匹配是在内存中通过sift白名单操作符完成的,不能用于数据库层查询;条件 handler 返回的查询对象会被包进$and/$or结构,使用非白名单操作符会直接抛错。完整行为边界可以参考 引擎单元测试 与 权限领域测试。
【免费下载链接】strapi🚀 Strapi is the leading open-source headless CMS. It’s 100% JavaScript/TypeScript, fully customizable, and developer-first.项目地址: https://gitcode.com/GitHub_Trending/st/strapi
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考