首页
/ Strapi @strapi/permissions 权限引擎实战:从 CASL Ability 构建到 Hook 拦截机制

Strapi @strapi/permissions 权限引擎实战:从 CASL Ability 构建到 Hook 拦截机制

2026-09-06 13:03:42作者:平淮齐Percy

@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/ability 6.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 a providers object property.” 从源码看,condition provider 会在条件求值阶段被 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/ability builder.”

一个最小可用的完整示例(文档示例 + 测试文件中 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. See lib/engine/hooks.js -> createEngineHooks for 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.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 的上下文能力

createWillRegisterContextsrc/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.jsonengines);条件匹配是在内存中通过 sift 白名单操作符完成的,不能用于数据库层查询;条件 handler 返回的查询对象会被包进 $and/$or 结构,使用非白名单操作符会直接抛错。完整行为边界可以参考 引擎单元测试权限领域测试

登录后查看全文
热门项目推荐
相关项目推荐