Strapi Permission Checker 源码解析:content-manager 插件的权限校验、数据净化与查询强制
Permission Checker 是 Strapi content-manager 插件的核心服务端服务,负责在内容管理的所有 CRUD 与发布操作前完成“用户能否做、数据能不能存、结果能不能看、查询能不能查”四个层面的访问控制。本文基于文档 docs/docs/docs/01-core/content-manager/services/00-permission-checker.md 的骨架展开,并结合 permission-checker 源码 与 admin 权限管理器 的实现,讲清它的七种动作标识、实例化方式、全部方法签名以及底层基于 CASL Ability 的字段级权限执行链路。
一、Permission Checker 的定位
从文档定义看,Permission Checker 是 Strapi 中用于强制执行访问控制策略的服务,通过校验用户对内容实体执行各类操作时的权限来保障安全。它对外提供四类能力:
- Permission Checking(权限检查):判断用户对某个动作是否拥有必要权限(
can/cannot); - Sanitization(数据净化):从输入与输出数据中移除未被授权的字段;
- Validation(校验):确保查询和输入数据符合权限规则,不符合时抛出错误;
- Query Enforcement(查询强制):自动修改查询,把权限约束条件注入到查询过滤器中。
它是典型的“请求作用域”服务:每个 content-manager API 请求在处理时都会用当前请求的 userAbility(从请求上下文 ctx.state.userAbility 取得)与目标内容类型的 model 各创建一个 checker 实例,用完即弃,不做全局缓存。这一模式在控制器中随处可见,例如 collection-types.ts 中的创建接口:
const createDocument = async (ctx, opts) => {
const { userAbility, user } = ctx.state;
const { model } = ctx.params;
const permissionChecker = getService('permission-checker').create({ userAbility, model });
if (permissionChecker.cannot.create()) {
throw new errors.ForbiddenError();
}
const pickPermittedFields = permissionChecker.sanitizeCreateInput;
const sanitizedBody = await pickPermittedFields(ctx.request.body);
// ... 交给 documentManager 落库
};
二、ACTIONS:七种内容操作与权限标识的映射
Permission Checker 在源码开头定义了一个 ACTIONS 常量(源码第 6-14 行),把内容管理器的语义动作映射到具体的权限 uid:
const ACTIONS = {
read: 'plugin::content-manager.explorer.read',
create: 'plugin::content-manager.explorer.create',
update: 'plugin::content-manager.explorer.update',
delete: 'plugin::content-manager.explorer.delete',
publish: 'plugin::content-manager.explorer.publish',
unpublish: 'plugin::content-manager.explorer.publish', // 注意:与 publish 共用同一权限
discard: 'plugin::content-manager.explorer.update', // 注意:丢弃草稿等价于 update
} as const;
几个值得注意的映射细节:
| 语义动作 | 映射到的权限 uid | 说明 |
|---|---|---|
| read | plugin::content-manager.explorer.read |
读取内容 |
| create | plugin::content-manager.explorer.create |
创建内容 |
| update | plugin::content-manager.explorer.update |
更新内容 |
| delete | plugin::content-manager.explorer.delete |
删除内容 |
| publish | plugin::content-manager.explorer.publish |
发布 |
| unpublish | plugin::content-manager.explorer.publish |
取消发布复用 publish 权限 |
| discard | plugin::content-manager.explorer.update |
丢弃草稿复用 update 权限 |
这种设计意味着:在后台给用户配置了 explorer.publish 权限,同时也就获得了 unpublish 能力;配置了 explorer.update,也就覆盖了丢弃草稿(discard)场景。这些权限动作本身由 content-manager 在启动时注册到权限系统,见 permission.ts 的 registerPermissions():
const actions = [
{
section: 'contentTypes',
displayName: 'Create',
uid: 'explorer.create',
pluginName: 'content-manager',
subjects: contentTypesUids,
options: { applyToProperties: ['fields'] }, // 支持“字段级”权限
},
// explorer.read / explorer.update 同样带 applyToProperties: ['fields']
// explorer.delete 与 explorer.publish 为实体级权限
// ...
];
await strapi.service('admin::permission').actionProvider.registerMany(actions);
其中 applyToProperties: ['fields'] 表明 create/read/update 三类权限支持按字段授权——这正是后文 sanitize/validate 能做字段级过滤的注册来源。
三、实例化:create({ userAbility, model })
服务默认导出遵循 Strapi 标准的服务工厂形态(源码第 158-160 行):
export default ({ strapi }: { strapi: Core.Strapi }) => ({
create: createPermissionChecker(strapi),
});
调用方式有等价两种写法:
// 文档中的通用写法
const permissionChecker = strapi
.plugin('content-manager')
.service('permission-checker')
.create({ userAbility, model });
// content-manager 控制器内部的实际写法(getService 是插件内服务访问的封装)
const permissionChecker = getService('permission-checker').create({ userAbility, model });
参数说明:
userAbility:当前请求用户的 CASL Ability 对象,包含该用户角色翻译而来的全部权限规则;在 content-manager 控制器中统一从ctx.state.userAbility读取。model:要校验的内容类型 uid,例如api::article.article。
实例化时会完成两件关键初始化(源码第 23-35 行):
- 通过
strapi.service('admin::permission').createPermissionsManager({ ability: userAbility, model })创建权限管理器。真正执行字段过滤与查询构建的重活全部委托给该管理器(实现位于 permissions-manager/index.ts); - 取出
strapi.service('admin::permission')的actionProvider,用于在权限检查时解析动作别名。
此外还有一个 toSubject 辅助函数:如果传入了具体实体对象,则用 permissionsManager.toSubject(entity, model) 构造 CASL subject(携带实体数据);否则直接用 model 字符串作为 subject。这个区别决定了“检查的是该用户对这个具体实体的权限,还是对整个内容类型的权限”。
四、权限检查方法:can / cannot 与别名解析
4.1 can.<action>() 与 cannot.<action>()
对外暴露的 can 与 cannot 内部签名均为 (action, entity?, field?),并针对 ACTIONS 的每个键生成了快捷方法(源码第 130-138 行)。因此文档中的示例等价于:
permissionChecker.can.create(); // => can('plugin::content-manager.explorer.create', undefined, undefined)
permissionChecker.can.publish(document); // 针对具体实体检查发布权限
permissionChecker.cannot.delete();
检查的判定逻辑包含一层别名(alias)回退(源码第 37-59 行):
const can = (action, entity, field) => {
const subject = toSubject(entity);
const aliases = actionProvider.unstable_aliases(action, model);
return (
userAbility.can(action, subject, field) ||
aliases.some((alias) => userAbility.can(alias, subject, field)) // 任一别名通过即通过
);
};
const cannot = (action, entity, field) => {
const subject = toSubject(entity);
const aliases = actionProvider.unstable_aliases(action, model);
return (
userAbility.cannot(action, subject, field) &&
aliases.every((alias) => userAbility.cannot(alias, subject, field)) // 原动作和所有别名都拒绝才算拒绝
);
};
注意 can 与 cannot 并非严格互逆语义的组合:can 只要原动作或任一别名通过即返回 true;cannot 要求原动作与所有别名全部拒绝才返回 true。别名机制使得“新动作名回退到旧动作名”之类的权限迁移场景(例如 unpublish 与 publish 共用 uid)在检查时依然成立。
典型用法就是文档示例中的守卫模式,真实代码中大量出现于 collection-types.ts:
if (permissionChecker.cannot.delete()) {
throw new errors.ForbiddenError();
}
4.2 requiresEntity(action):是否需要实体数据才能判定
源码中还暴露了一个文档未列出但实际被使用的辅助方法 requiresEntity(源码第 77-85 行):
const requiresEntity = (action: string) => {
const rules = getRulesForAction(action); // 汇总原动作 + 全部别名的 rulesFor 结果
return rules.some((rule) => rule.conditions && !isEmpty(rule.conditions));
};
它通过 userAbility.rulesFor(action, model) 检查该动作的规则中是否存在非空 conditions:如果权限是在实体字段上配置了条件(比如“仅能读取 published === true 的记录”),则必须拿到实体数据后才能做出准确判断。单元测试 明确验证了这一行为:规则带 conditions: { locale: 'en' } 时 requiresEntity('read') 为 true,规则无条件时为 false。
五、净化方法:sanitizeOutput / sanitizeQuery / sanitizeInput
净化(sanitize)的原则是“静默剔除未被授权的字段”,不抛错。permission-checker 中的三个方法都是对权限管理器的薄封装,只是传入不同的 subject:
const sanitizeOutput = (data, { action = ACTIONS.read } = {}) =>
permissionsManager.sanitizeOutput(data, { subject: toSubject(data), action });
const sanitizeQuery = (query, { action = ACTIONS.read } = {}) =>
permissionsManager.sanitizeQuery(query, { subject: model, action });
const sanitizeInput = (action, data, entity?) =>
permissionsManager.sanitizeInput(data, {
subject: entity ? toSubject(entity) : model,
action,
});
由此派生出两个常用快捷方法(源码第 109-111 行):
const sanitizedCreateData = permissionChecker.sanitizeCreateInput(inputData);
// 柯里化:先绑定实体,再返回一个处理 data 的函数
const sanitizedUpdateData = permissionChecker.sanitizeUpdateInput(existingEntity)(inputData);
净化管线内部做了什么
权限管理器的净化实现位于 permissions-manager/sanitize.ts。wrapSanitize 会先用 createPermissionFieldsCache(ability) 根据 action + subject 算出 permittedFields(被授权字段集合;若规则 shouldIncludeAll 则为 null 表示不过滤),然后对数据做异步管线处理:
输出净化 sanitizeOutput(管线见 sanitize.ts 第 164-184 行)按序执行四步:
omitHiddenFields:移除 schema 中配置了hidden: true的字段;pickAllowedAdminUserFields:对指向admin::user的关联字段只保留ADMIN_USER_ALLOWED_FIELDS白名单内的字段,避免泄露后台用户敏感信息;removeDisallowedFields(permittedFields):执行 RBAC 字段级过滤——未被授权读取的字段直接删除;sanitizePasswords:清除所有password类型字段。
输入净化 sanitizeInput 的管线是:剔除隐藏字段 → RBAC 字段过滤 → omitCreatorRoles(去掉 createdBy.roles 与 updatedBy.roles,防止通过 API 伪造创建/更新者的角色信息)。
查询净化 sanitizeQuery 使用 traverse 工具分别遍历 filters / sort / populate / fields 四类查询片段(sanitize.ts 第 65-162 行),对每个片段做同样的“剔除无权限字段 + 剔除隐藏字段 + 剔除密码字段”处理,且会递归处理 populate 内部的嵌套 filters/sort/fields。有一个细节值得注意:getQueryFields 在计算查询允许字段时,会把 id、docId、__component、时间戳/审计字段(createdAt、updatedAt、publishedAt、createdBy、updatedBy)以及“不可见但可写”的属性始终放行(sanitize.ts 第 304-321 行),保证系统必需字段不因权限配置被误删。
六、校验方法:validateQuery / validateInput
校验(validate)与净化的差别在于:遇到无权限或非法的字段时抛出 ValidationError 而非静默剔除,适用于希望明确告知调用方“你试图操作了不允许的字段”的场景。
// 确保查询符合权限规则
permissionChecker.validateQuery({ page: '1', pageSize: '10' });
// 确保输入数据可保存(update 时传入已有实体以获得 subject 级判定)
permissionChecker.validateInput('update', inputData, existingEntity);
其内部实现位于 permissions-manager/validate.ts,与净化管线一一对应,只是 visitor 换成了抛错版本:
throwDisallowedFields(permittedFields):触及未被授权字段 → 抛出ValidationError: Invalid key <field>;throwHiddenFields:触及hidden字段 → 抛错;throwPassword:试图在查询中使用密码字段 → 抛错;- 还会拒绝空的过滤对象等非法结构(
throwInvalidKey)。
validateQuery 额外说明:populate: '*' 被视为永远合法(其展开由实体服务负责,见 validate.ts 第 134-137 行)。
七、查询强制:sanitizedQuery.() 如何把权限注入查询
这是 Permission Checker 最具特色的能力:sanitizedQuery.<action>(query) 会在净化查询之后,把该用户在目标动作下的权限条件自动翻译成查询过滤器并合并进 filters。
7.1 两步管线
const buildPermissionQuery = (query, action = {}) =>
permissionsManager.addPermissionsQueryTo(query, action);
const sanitizedQuery = (query, action = {}) =>
async.pipe(
(q) => sanitizeQuery(q, action), // 第一步:剔除无权限字段
(q) => buildPermissionQuery(q, action) // 第二步:注入权限条件
)(query);
// 为每个动作生成快捷方法
Object.keys(ACTIONS).forEach((action) => {
sanitizedQuery[action] = (query) => sanitizedQuery(query, ACTIONS[action]);
});
因此文档中的用法对应:
const securedQuery = permissionChecker.sanitizedQuery.read({ sort: 'createdAt:desc' });
// 等价于 sanitizedQuery(query, 'plugin::content-manager.explorer.read')
7.2 权限条件从 CASL 规则到 Strapi 过滤器
翻译工作分三步完成,全部位于 admin 权限管理器中:
buildCaslQuery(query-builders.ts 第 27-30 行)调用@casl/ability/extra的rulesToQuery(ability, action, model, (o) => o.conditions),把 Ability 中该动作规则的conditions转成 Mongo 风格的 CASL 查询;buildStrapiQuery(unwrapDeep) 深度解包 CASL 查询,并将操作符映射为 Strapi 查询操作符(query-builders.ts 第 5-18 行):
| CASL 操作符 | Strapi 操作符 |
|---|---|
$in |
$in |
$nin |
$notIn |
$exists |
$notNull |
$gte / $gt / $lte / $lt |
同名 |
$eq / $ne |
$eq / $ne |
$and / $or / $not |
同名 |
addPermissionsQueryTo(permissions-manager/index.ts 第 35-48 行)把权限查询与原查询的filters以$and组合,若原查询没有 filters 则直接替换:
if (isPlainObject(query.filters)) {
newQuery.filters = permissionQuery ? { $and: [query.filters, permissionQuery] } : query.filters;
} else {
newQuery.filters = permissionQuery;
}
这意味着:即使用户在请求中伪造或遗漏了过滤条件,最终发往实体服务的查询也必然带上权限约束,无法“越权读到”条件之外的记录。
7.3 控制器中的完整读链路示例
以 collection-types.ts 的 findOne/find 流程 为典型,一次读请求经过三道关卡:
const permissionChecker = getService('permission-checker').create({ userAbility, model });
// 关卡 1:动作级权限
if (permissionChecker.cannot.read()) {
throw new errors.ForbiddenError();
}
// 关卡 2 + 3:净化查询并注入权限过滤器
const permissionQuery = await permissionChecker.sanitizedQuery.read(queryWithoutStatusSort);
// ... 用 permissionQuery 查询实体
// 返回前再净化输出
ctx.body = await permissionChecker.sanitizeOutput(result);
create / update / delete / publish / unpublish 各接口都遵循“cannot.<action>() 守卫 → sanitizedQuery.<action>(ctx.query) 或 sanitizeCreateInput/sanitizeUpdateInput 净化 → sanitizeOutput 净化响应”的同构模式(可对照 collection-types.ts 中 create/delete 的实现)。
八、导出形态与使用方式汇总
回到文档给出的“Exported Service”:
export default ({ strapi }: { strapi: Core.Strapi }) => ({
create: createPermissionChecker(strapi),
});
结合源码第 140-155 行的返回对象,一个 checker 实例的完整方法面如下:
| 分类 | 方法 | 行为 |
|---|---|---|
| 权限检查 | can.<action>(entity?, field?) |
原动作或任一别名通过即 true |
| 权限检查 | cannot.<action>(entity?, field?) |
原动作与所有别名均拒绝才 true |
| 权限检查 | requiresEntity.<action>() |
该动作是否存在带 conditions 的规则(决定是否要实体数据) |
| 净化 | sanitizeOutput(data, { action }) |
按 read 权限剔除隐藏/RBAC 不允许/密码字段 |
| 净化 | sanitizeQuery(query, { action }) |
净化 filters/sort/populate/fields |
| 净化 | sanitizeCreateInput(data) |
sanitizeInput(ACTIONS.create, data) 的快捷方式 |
| 净化 | sanitizeUpdateInput(entity)(data) |
柯里化的 sanitizeInput(ACTIONS.update, data, entity) |
| 校验 | validateQuery(query, { action }) |
违规字段抛 ValidationError |
| 校验 | validateInput(action, data, entity?) |
输入违规抛 ValidationError |
| 查询强制 | sanitizedQuery.<action>(query) |
净化 + 注入权限过滤器($and 合并) |
文档末尾给出的最简示例:
const canCreate = strapi.plugin('content-manager').service('permission-checker').can.create();
if (!canCreate) {
throw new errors.ForbiddenError('User does not have permission to create content');
}
需要注意其适用前提:该写法只有在插件服务上下文可用(例如其他插件的服务、生命周期钩子中持有 strapi 实例)时成立;而在 content-manager 自身的控制器里,标准做法仍是上文第三节所示的、基于 ctx.state.userAbility 的按请求创建实例。
九、小结
Permission Checker 把 content-manager 的访问控制收敛到一个请求作用域的协调器上:动作级判断(can/cannot,含别名回退)守住入口,净化管线(sanitize*)保证“看不到的字段存不进去、也带不出来”,校验管线(validate*)提供显式的违规反馈,而 sanitizedQuery 借助 CASL rulesToQuery 与操作符映射把实体级权限条件织入查询过滤器,形成查询层的强制约束。理解 permission-checker.ts、permissions-manager 与 permission.ts 的权限注册 这三处源码,再对照 单元测试 与 collection-types.ts 中的真实调用链,即可完整掌握 Strapi 内容管理权限体系的服务端执行机制。
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