Strapi @strapi/permissions 权限引擎实战:从 CASL Ability 构建到 Hook 拦截机制
@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. 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.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 结构,使用非白名单操作符会直接抛错。完整行为边界可以参考 引擎单元测试 与 权限领域测试。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00