Ghost api-framework 权限系统详解:api-framework 控制器的四种 permissions 模式与数据库权限落地
2026/9/7 3:24:03 网站建设 项目流程

Ghost api-framework 权限系统详解:api-framework 控制器的四种 permissions 模式与数据库权限落地

【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost

本文基于 Ghost 仓库中的 API 控制器权限指南(.agents/skills/add-admin-api-endpoint/permissions.md),系统讲解 Ghost 后端 api-framework 权限体系的五种请求处理阶段、四种permissions配置模式、Frame 上下文对象,以及默认的数据库权限处理器(permissions: true背后的查表机制)与通过迁移脚本落地新权限的完整流程。读完后你可以为新管理端 API 端点正确配置权限,并通过迁移把权限记录写入数据库,避免安全漏洞。

一、权限在请求处理管线中的位置

Ghost 的后端 API 层基于@tryghost/api-framework构建。每个控制器(controller)文件在 endpoints/index.js 中通过apiFramework.pipeline(require('./posts'), localUtils)这样的方式接入框架,框架将请求拆分为五个处理阶段:

  1. 输入校验(Input validation)
  2. 输入序列化(Input serialisation)
  3. 权限(Permissions)← 本文焦点
  4. 查询执行(Query,即控制器的query方法)
  5. 输出序列化(Output serialisation)

关键约束:每个控制器方法都必须显式声明permissions属性。这是一项安全要求——显式声明防止了"忘记写权限"造成的安全漏洞,也让每个端点的鉴权策略一目了然。如果缺少permissions属性,框架会抛出IncorrectUsageError

// 这会抛出 IncorrectUsageError edit: { query(frame) { return models.Post.edit(frame.data, frame.options); } // 缺少 permissions 属性! }

仓库中真实控制器都遵循这一约定,例如 automated-emails.js 中每个方法都显式带有permissions: truepermissions: { ... }

二、四种权限配置模式

模式 1:布尔值true—— 默认权限检查

最常用的模式,将权限判断委托给默认权限处理器:

edit: { headers: { cacheInvalidate: true }, options: ['include'], validation: { options: { include: { required: true, values: ['tags'] } } }, permissions: true, query(frame) { return models.Post.edit(frame.data, frame.options); } }

适用场景

  • 标准 CRUD 操作;
  • 默认权限处理器即可满足需求;
  • 绝大多数需要登录的端点。
默认权限处理器的工作原理

设置permissions: true后,框架会调用位于 api/endpoints/utils/permissions.js 的默认处理器。阅读该文件的nonePublicAuth函数(L18-L83),实际流程如下:

  1. 单数形式推导:处理器将docName转换为单数——

    • postspost
    • automated_emailsautomated_email
    • categoriescategory(源码中特判了iesy的替换)

    对应源码:

    if (apiConfig.docName.match(/ies$/)) { singular = apiConfig.docName.replace(/ies$/, 'y'); } else { singular = apiConfig.docName.replace(/s$/, ''); }
  2. 权限标识符(identifier):默认取frame.options.id;控制器可通过apiConfig.identifier(frame)覆盖它(比如编辑设置时用设置的 key,改密码时用 body 中的 user id)。

  3. 权限检查调用

    permissions.canThis(frame.options.context)[method]singular

    docName: 'posts'、方法edit为例,实际调用的是permissions.canThis(context).edit.post(postId, unsafeAttrs)

  4. 数据库查表:权限服务(ghost/core/core/server/services/permissions)在permissionspermissions_roles两张表中查找action_type匹配方法(如edit)、object_type匹配单数 docName(如post)的权限记录,并校验当前用户的角色是否被授予该权限。

源码中还有一处值得注意的细节:权限检查的返回值可以携带excludedAttrs列表,处理器会把这些属性从请求数据中_.omit掉(与直接抛NoPermissionErrorunsafeAttrs不同,它只是静默排除字段)。源码注释说明这个机制目前主要为 posts 模型与 contributor 角色服务。

默认处理器依赖的数据库配置

要让permissions: true正常工作,数据库中必须存在对应记录:

  1. permissions表中的权限记录

    INSERT INTO permissions (name, action_type, object_type) VALUES ('Browse posts', 'browse', 'post'), ('Read posts', 'read', 'post'), ('Edit posts', 'edit', 'post'), ('Add posts', 'add', 'post'), ('Delete posts', 'destroy', 'post');
  2. permissions_roles表中的角色-权限映射:将上述权限授予 Administrator、Editor 等角色。

这两类记录通常通过以下两种方式写入:

  • 初始 fixtures:ghost/core/core/server/data/schema/fixtures/fixtures.json;
  • 数据库迁移:使用 ghost/core/core/server/data/migrations/utils/permissions.js 中的addPermissionWithRoles()工具(见本文第四节)。

另外,从 utils/permissions.js 的handle函数可以看到两个前置逻辑:框架先调用permissions.parseContext(frame.options.context)解析上下文;若上下文标记为public(Content API 与 Members API 的公开访问),则直接放行,不做权限检查。

模式 2:布尔值false—— 跳过权限检查

完全绕过权限阶段:

browse: { options: ['page', 'limit'], permissions: false, query(frame) { return models.PublicResource.findAll(frame.options); } }

适用场景

  • 不需要认证的公开端点;
  • 健康检查、状态查询类端点;
  • 对所有人可见的资源。

警告:务必谨慎使用。只有在确定端点应当公开可访问时才关闭权限检查。

模式 3:函数 —— 自定义权限逻辑

完全掌控权限校验过程,函数接收frame,通过Promise.resolve()放行、Promise.reject()(或throw)拦截:

delete: { options: ['id'], permissions: async function(frame) { // 确保用户已认证 if (!frame.user || !frame.user.id) { const UnauthorizedError = require('@tryghost/errors').UnauthorizedError; return Promise.reject(new UnauthorizedError({ message: 'You must be logged in to perform this action' })); } // 仅资源所有者或管理员可删除 const resource = await models.Resource.findOne({id: frame.options.id}); if (resource.get('author_id') !== frame.user.id && frame.user.role !== 'admin') { const NoPermissionError = require('@tryghost/errors').NoPermissionError; return Promise.reject(new NoPermissionError({ message: 'You do not have permission to delete this resource' })); } return Promise.resolve(); }, query(frame) { return models.Resource.destroy(frame.options); } }

适用场景

  • 依赖具体资源状态变化的复杂权限逻辑;
  • 基于所有者的权限(owner-based);
  • 超出默认处理器的基于角色的访问控制;
  • 权限决策需要查询数据库。

模式 4:配置对象 —— 默认处理 + 钩子

将默认权限处理与配置选项、钩子函数结合:

edit: { options: ['include'], permissions: { unsafeAttrs: ['author', 'status'], before: async function(frame) { // 预加载权限检查所需的额外用户数据 frame.user.permissions = await loadUserPermissions(frame.user.id); } }, query(frame) { return models.Post.edit(frame.data, frame.options); } }

适用场景:默认权限处理器足够但需要配置;存在需要特殊权限处理的字段;需要在权限检查前准备数据。

三、Frame 对象与配置对象属性

Frame 对象

所有权限处理函数都接收一个frame对象,其中包含完整的请求上下文:

Frame { // 请求数据 original: {}, // 原始未转换的输入 options: {}, // 查询/URL 参数 data: {}, // 请求体 // 用户上下文 user: {}, // 登录用户对象 // 文件上传 file: {}, // 单个上传文件 files: [], // 多个上传文件 // API 上下文 apiType: String, // 'content' 或 'admin' docName: String, // 端点名称(如 'posts') method: String, // 方法名(如 'browse'、'add'、'edit') // HTTP 上下文(由 HTTP 包装层注入) context: { api_key: {}, // API key 信息 user: userId, // 用户 ID 或 null integration: {}, // 集成详情 member: {} // 会员信息或 null } }

配置对象属性(模式 4)

unsafeAttrs(Array):声明需要特殊权限处理的属性。

permissions: { unsafeAttrs: ['author', 'visibility', 'status'] }

从源码看,处理器会执行_.pick(frame.data[apiConfig.docName][0], apiConfig.unsafeAttrs),把这些属性挑出来传给权限检查函数做额外校验。适用于"只有特定用户才能修改"的字段(例如只有管理员可以更换文章的作者)。

before(Function):在默认权限处理器之前运行的钩子。

permissions: { before: async function(frame) { // 准备权限检查所需的数据 const membership = await loadMembership(frame.user.id); frame.user.membershipLevel = membership.level; } }

四、完整的控制器示例

示例 1:公开浏览端点

module.exports = { docName: 'articles', browse: { options: ['page', 'limit', 'filter'], validation: { options: { limit: { values: [10, 25, 50, 100] } } }, permissions: false, query(frame) { return models.Article.findPage(frame.options); } } };

示例 2:需要认证的 CRUD 控制器

module.exports = { docName: 'posts', browse: { options: ['include', 'page', 'limit', 'filter', 'order'], permissions: true, query(frame) { return models.Post.findPage(frame.options); } }, read: { options: ['include'], data: ['id', 'slug'], permissions: true, query(frame) { return models.Post.findOne(frame.data, frame.options); } }, add: { headers: { cacheInvalidate: true }, options: ['include'], permissions: { unsafeAttrs: ['author_id'] }, query(frame) { return models.Post.add(frame.data.posts[0], frame.options); } }, edit: { headers: { cacheInvalidate: true }, options: ['include', 'id'], permissions: { unsafeAttrs: ['author_id', 'status'] }, query(frame) { return models.Post.edit(frame.data.posts[0], frame.options); } }, destroy: { headers: { cacheInvalidate: true }, options: ['id'], permissions: true, statusCode: 204, query(frame) { return models.Post.destroy(frame.options); } } };

示例 3:基于所有者的权限

module.exports = { docName: 'user_settings', read: { options: ['user_id'], permissions: async function(frame) { // 用户只能读取自己的设置 if (frame.options.user_id !== frame.user.id) { const NoPermissionError = require('@tryghost/errors').NoPermissionError; return Promise.reject(new NoPermissionError({ message: 'You can only view your own settings' })); } return Promise.resolve(); }, query(frame) { return models.UserSetting.findOne({user_id: frame.options.user_id}); } }, edit: { options: ['user_id'], permissions: async function(frame) { // 用户只能编辑自己的设置 if (frame.options.user_id !== frame.user.id) { const NoPermissionError = require('@tryghost/errors').NoPermissionError; return Promise.reject(new NoPermissionError({ message: 'You can only edit your own settings' })); } return Promise.resolve(); }, query(frame) { return models.UserSetting.edit(frame.data, frame.options); } } };

示例 4:基于角色的访问控制

module.exports = { docName: 'admin_settings', browse: { permissions: async function(frame) { const allowedRoles = ['Owner', 'Administrator']; if (!frame.user || !allowedRoles.includes(frame.user.role)) { const NoPermissionError = require('@tryghost/errors').NoPermissionError; return Promise.reject(new NoPermissionError({ message: 'Only administrators can access these settings' })); } return Promise.resolve(); }, query(frame) { return models.AdminSetting.findAll(); } }, edit: { permissions: async function(frame) { // 只有站点 owner 可以编辑管理设置 if (!frame.user || frame.user.role !== 'Owner') { const NoPermissionError = require('@tryghost/errors').NoPermissionError; return Promise.reject(new NoPermissionError({ message: 'Only the site owner can modify these settings' })); } return Promise.resolve(); }, query(frame) { return models.AdminSetting.edit(frame.data, frame.options); } } };

示例 5:带数据准备的权限

module.exports = { docName: 'premium_content', read: { options: ['id'], permissions: { before: async function(frame) { // 加载用户的订阅状态 if (frame.user) { const subscription = await models.Subscription.findOne({ user_id: frame.user.id }); frame.user.subscription = subscription; } } }, async query(frame) { // query 中即可使用 frame.user.subscription const content = await models.Content.findOne({id: frame.options.id}); if (content.get('premium') && !frame.user?.subscription?.active) { const NoPermissionError = require('@tryghost/errors').NoPermissionError; throw new NoPermissionError({ message: 'Premium subscription required' }); } return content; } } };

五、最佳实践

1. 始终显式声明权限

// 好 —— 明确声明为公开 permissions: false // 好 —— 明确声明需要认证 permissions: true // 坏 —— 缺少 permissions(会抛错) // permissions: undefined

2. 选择恰当的模式

场景推荐模式
公开端点permissions: false
标准认证 CRUDpermissions: true
需要跟踪敏感字段permissions: { unsafeAttrs: [...] }
复杂自定义逻辑permissions: async function(frame) {...}
需要预处理数据permissions: { before: async function(frame) {...} }

3. 权限函数保持职责单一

权限函数只做权限检查,不要夹带业务逻辑:

// 好 —— 只检查权限 permissions: async function(frame) { if (!frame.user || frame.user.role !== 'admin') { throw new NoPermissionError(); } } // 坏 —— 混入业务逻辑 permissions: async function(frame) { if (!frame.user) throw new NoPermissionError(); // 不要在权限里做这些! frame.data.processed = true; await sendNotification(frame.user); }

4. 使用有意义的错误信息

permissions: async function(frame) { if (!frame.user) { throw new UnauthorizedError({ message: 'Please log in to access this resource' }); } if (frame.user.role !== 'admin') { throw new NoPermissionError({ message: 'Administrator access required for this operation' }); } }

5. 校验资源所有权

当资源归属于特定用户时,务必验证所有权:

permissions: async function(frame) { const resource = await models.Resource.findOne({id: frame.options.id}); if (!resource) { throw new NotFoundError({message: 'Resource not found'}); } const isOwner = resource.get('user_id') === frame.user.id; const isAdmin = frame.user.role === 'admin'; if (!isOwner && !isAdmin) { throw new NoPermissionError({ message: 'You do not have permission to access this resource' }); } }

6. 用unsafeAttrs标记敏感字段

permissions: { unsafeAttrs: [ 'author_id', // 只有管理员应能更换作者 'status', // 发布需要特殊权限 'visibility', // 修改可见性受限 'featured' // 只有编辑可置顶内容 ] }

六、错误类型

权限逻辑应使用@tryghost/errors中语义匹配的错误类型:

  • UnauthorizedError—— 用户未认证;
  • NoPermissionError—— 用户已认证但缺乏权限(默认处理器在查表失败时抛出的也是它,并会把消息改写为 "You do not have permission to {method} {docName}",见 utils/permissions.js);
  • NotFoundError—— 资源不存在(慎用,避免信息泄露);
  • ValidationError—— 输入校验失败。
const { UnauthorizedError, NoPermissionError, NotFoundError } = require('@tryghost/errors');

七、通过迁移落地新端点的权限

当你新建一个使用默认权限处理器(permissions: true)的 API 端点时,必须把对应权限写入数据库。Ghost 在 ghost/core/core/server/data/migrations/utils/permissions.js 中提供了addPermissionWithRoles工具(该文件 L279 起定义)。

迁移工具导入

const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils');

示例:为新资源添加一套 CRUD 权限

// ghost/core/core/server/data/migrations/versions/X.X/YYYY-MM-DD-HH-MM-SS-add-myresource-permissions.js const {combineTransactionalMigrations, addPermissionWithRoles} = require('../../utils'); module.exports = combineTransactionalMigrations( addPermissionWithRoles({ name: 'Browse my resources', action: 'browse', object: 'my_resource' // docName 的单数形式 }, [ 'Administrator', 'Admin Integration' ]), addPermissionWithRoles({ name: 'Read my resources', action: 'read', object: 'my_resource' }, [ 'Administrator', 'Admin Integration' ]), addPermissionWithRoles({ name: 'Edit my resources', action: 'edit', object: 'my_resource' }, [ 'Administrator', 'Admin Integration' ]), addPermissionWithRoles({ name: 'Add my resources', action: 'add', object: 'my_resource' }, [ 'Administrator', 'Admin Integration' ]), addPermissionWithRoles({ name: 'Delete my resources', action: 'destroy', object: 'my_resource' }, [ 'Administrator', 'Admin Integration' ]) );

可分配的角色

  • Administrator—— 完整管理端权限;
  • Admin Integration—— 具有 admin 范围的 API 集成;
  • Editor—— 可管理所有内容;
  • Author—— 可管理自己的内容;
  • Contributor—— 只能创建草稿;
  • Owner—— 站点所有者(继承全部 Administrator 权限)。

权限命名约定

  • name:人类可读,例如'Browse automated emails'
  • action:API 方法名 ——browsereadeditadddestroy
  • objectdocName的单数形式 ——automated_email(而不是automated_emails),必须与默认处理器在 utils/permissions.js 中推导出的单数形式一致,否则查表会匹配不到。

仅限管理员的端点

若端点只允许管理员访问(Editor、Author 等角色不能访问),只将权限授予AdministratorAdmin Integration两个角色:

addPermissionWithRoles({ name: 'Browse sensitive data', action: 'browse', object: 'sensitive_data' }, [ 'Administrator', 'Admin Integration' ])

八、小结

Ghost 的 api-framework 把权限做成请求管线中的显式一环:控制器方法必须声明permissionstrue走基于permissions/permissions_roles两张数据表的默认检查,false显式跳过,函数与配置对象则分别提供完全自定义和"默认 + 钩子"的折中方案。新端点的开发闭环是:控制器中声明权限 → 确认docName单数形式 → 用addPermissionWithRoles迁移写入权限与角色映射 → 由 默认处理器 在每次请求时完成鉴权。完整指南可参阅 permissions.md。

【免费下载链接】GhostIndependent technology for modern publishing, memberships, subscriptions and newsletters.项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询