首页
/ Strapi Permission Checker 源码解析:content-manager 插件的权限校验、数据净化与查询强制

Strapi Permission Checker 源码解析:content-manager 插件的权限校验、数据净化与查询强制

2026-09-05 13:43:34作者:廉彬冶Miranda

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 行):

  1. 通过 strapi.service('admin::permission').createPermissionsManager({ ability: userAbility, model }) 创建权限管理器。真正执行字段过滤与查询构建的重活全部委托给该管理器(实现位于 permissions-manager/index.ts);
  2. 取出 strapi.service('admin::permission')actionProvider,用于在权限检查时解析动作别名。

此外还有一个 toSubject 辅助函数:如果传入了具体实体对象,则用 permissionsManager.toSubject(entity, model) 构造 CASL subject(携带实体数据);否则直接用 model 字符串作为 subject。这个区别决定了“检查的是该用户对这个具体实体的权限,还是对整个内容类型的权限”。

四、权限检查方法:can / cannot 与别名解析

4.1 can.<action>()cannot.<action>()

对外暴露的 cancannot 内部签名均为 (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)) // 原动作和所有别名都拒绝才算拒绝
  );
};

注意 cancannot 并非严格互逆语义的组合:can 只要原动作或任一别名通过即返回 true;cannot 要求原动作与所有别名全部拒绝才返回 true。别名机制使得“新动作名回退到旧动作名”之类的权限迁移场景(例如 unpublishpublish 共用 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.tswrapSanitize 会先用 createPermissionFieldsCache(ability) 根据 action + subject 算出 permittedFields(被授权字段集合;若规则 shouldIncludeAll 则为 null 表示不过滤),然后对数据做异步管线处理:

输出净化 sanitizeOutput(管线见 sanitize.ts 第 164-184 行)按序执行四步:

  1. omitHiddenFields:移除 schema 中配置了 hidden: true 的字段;
  2. pickAllowedAdminUserFields:对指向 admin::user 的关联字段只保留 ADMIN_USER_ALLOWED_FIELDS 白名单内的字段,避免泄露后台用户敏感信息;
  3. removeDisallowedFields(permittedFields):执行 RBAC 字段级过滤——未被授权读取的字段直接删除;
  4. sanitizePasswords:清除所有 password 类型字段。

输入净化 sanitizeInput 的管线是:剔除隐藏字段 → RBAC 字段过滤 → omitCreatorRoles(去掉 createdBy.rolesupdatedBy.roles,防止通过 API 伪造创建/更新者的角色信息)。

查询净化 sanitizeQuery 使用 traverse 工具分别遍历 filters / sort / populate / fields 四类查询片段(sanitize.ts 第 65-162 行),对每个片段做同样的“剔除无权限字段 + 剔除隐藏字段 + 剔除密码字段”处理,且会递归处理 populate 内部的嵌套 filters/sort/fields。有一个细节值得注意:getQueryFields 在计算查询允许字段时,会把 iddocId__component、时间戳/审计字段(createdAtupdatedAtpublishedAtcreatedByupdatedBy)以及“不可见但可写”的属性始终放行(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 权限管理器中:

  1. buildCaslQueryquery-builders.ts 第 27-30 行)调用 @casl/ability/extrarulesToQuery(ability, action, model, (o) => o.conditions),把 Ability 中该动作规则的 conditions 转成 Mongo 风格的 CASL 查询;
  2. 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 同名
  1. addPermissionsQueryTopermissions-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.tspermissions-managerpermission.ts 的权限注册 这三处源码,再对照 单元测试collection-types.ts 中的真实调用链,即可完整掌握 Strapi 内容管理权限体系的服务端执行机制。

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