Strapi @strapi/permissions 权限引擎实战:从 CASL Ability 构建到 Hook 拦截机制
2026/9/7 7:34:47 网站建设 项目流程

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; }

两个硬性要求:

  1. 必须同时提供actioncondition两个 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 不是函数,该条件会被静默过滤。
  2. 可选传入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),其中条件求值是最复杂的分支,源码逻辑可以归纳为四步:

  1. 解析resolveConditionsproviders.condition.get(id)conditions: ['isAuthor']之类的字符串 ID 解析为{ name, handler }对象,并过滤掉不存在或 handler 非函数的无效条件;
  2. 执行:每个条件的 handler 以_.merge(options, { permission: cloneDeep(permission) })为入参并行执行(Promise.all),即 handler 可以拿到generateAbility(permissions, options)传入的第二个参数(在 Admin 中通常是当前用户对象)以及被克隆的权限对象本身;
  3. 过滤结果:只保留返回booleanobject的结果;
  4. 三种分支注册(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.permissionAsyncBailHook返回false立即废弃该权限;上下文提供只读permission克隆
format.permissionAsyncSeriesWaterfallHook返回新对象可改写权限(前一个 handler 的输出作为下一个的输入)
after-format::validate.permissionAsyncBailHook格式化后再校验,返回false废弃
before-evaluate.permissionAsyncSeriesHook上下文额外提供addCondition(condition)方法,可向权限动态追加条件
before-register.permissionAsyncSeriesHook注册前最后拦截;上下文提供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.permissioncreateRegisterFunction包装的 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,有三个关键设计:

  1. subject 为null/undefined时注册为'all'properties.fields直接作为 CASL 的字段参数传入can(action, subject, fields, condition)——这就是为什么{ action: 'read' }能对任意主体生效,而{ action: 'update', subject: 'bar', properties: { fields: ['foobar'] } }只允许更新foobar字段;
  2. 参数化动作(parametrized action)PermissionRule.action允许{ name, params }形式(类型定义见 src/types.ts),builder 会将其序列化为'actionName?key=value'字符串,并且build()后返回的 Ability 的can方法被装饰(decorate),调用ability.can({ name, params }, subject)时同样会自动序列化,保证注册与查询两端格式一致;
  3. 内存条件匹配器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)要求canbuildParametrizedActionbuild三个成员,这就是abilityBuilderFactory定制点需要满足的契约。

权限领域对象(domain)

permissions.domain暴露了权限的构造与操作函数(src/domain/permission/index.ts):

  • Permission接口字段:action(必填)、subjectpropertiesconditionsactionParameters
  • 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询