Backstage 插件开发:如何对分页数据(Paginated Data)进行条件授权与数据源级过滤
【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage
本篇技术指南聚焦 Backstage 权限框架(Permission Framework)中的高级场景:如何为插件中返回列表/分页数据的端点(如GET /todos)实施基于资源特征的授权。它将带你从"逐个资源批量授权"的朴素方案出发,逐步演进到借助PermissionsService.authorizeConditional与createConditionTransformer、把条件决策下推到数据源内部过滤的实现方式。读完本文,你将掌握todoListReadPermission的注册、条件决策的处理、条件到查询过滤器的转换(Condition Transformer),以及策略端如何返回条件决策——这套能力同样适用于 catalog、scaffolder 等任意返回资源列表的 Backstage 插件。
问题背景:列表端点为什么比单资源端点更棘手
在上一节(03-adding-a-resource-permission-check.md)中,我们已经为PUT /todos这类单资源操作建立了基于resourceRef的授权:请求携带 todo 的id,权限框架据此对这一个资源做出ALLOW/DENY决策。
而GET /todos这类端点返回的是一整批资源,授权逻辑发生了质的变化:
- 需要根据每个资源自身的特征(例如"只有作者本人才能看到自己创建的 todo")来决定是否可见;
- 要对一组资源逐一授权,而不是单个资源;
- 还要考虑分页(pagination)、排序等原本由数据源负责的问题,不能因为授权而破坏数据源的既有能力。
原文档明确指出了两种实现路径:一种是在应用层"批处理 + 过滤",另一种是让权限框架把条件决策下发到数据源,由数据源自身完成过滤。本文将依次展开。
方案一:批量授权(Batch Authorize)与它的局限
最直观的想法是复用PermissionsService.authorize的批处理能力:把getAll()取回的所有 todo 一次性提交给权限框架,拿到每个资源的决策后,只保留AuthorizeResult.ALLOW的那些:
router.get('/todos', async (req, res) => { const credentials = await httpAuth.credentials(req, { allow: ['user'] }); const items = getAll(); const decisions = await permissions.authorize( items.map(({ id }) => ({ permission: todoListReadPermission, resourceRef: id, })), { credentials }, ); const filteredItems = decisions.filter( decision => decision.result === AuthorizeResult.ALLOW, ); res.json(filteredItems); });这段代码逻辑正确,但原文档点出了它的结构性缺点:
它迫使我们先把所有元素全部取出,再一个一个授权。这会让插件实现去操心分页等本应由数据源处理的问题。
也就是说,当列表规模变大、引入真实的分页/游标机制后,这种"先全量取出再过滤"的做法会破坏数据源已有的分页、排序、索引优化,产生性能和架构上的双重负担。
方案二:让数据源自己过滤(Conditional Decision + Condition Transformer)
为避免上述问题,权限框架提供了在数据源内部过滤条目的能力。核心思路是:
- 插件使用
PermissionsService.authorizeConditional发起"查询式"授权请求; - 策略(Policy)返回条件决策(Conditional Decision),其中是一组用嵌套对象表达的规则条件(conditions),而不是简单的
ALLOW/DENY; - 插件通过
createConditionTransformer把这些条件转换成自身数据源可执行的查询过滤器,交给getAll(filter)等查询函数直接使用。
前置要求:数据源必须支持 AND / OR / NOT 逻辑组合
原文档特别以 note 形式给出一个硬性前提:
要以这种方式执行授权过滤,数据源必须允许过滤器通过AND、OR 和 NOT运算符进行逻辑组合。权限框架返回的条件决策使用嵌套对象(
PermissionCriteria)组合条件。如果你要自己实现一个过滤器 API,建议使用相同的结构以方便互操作;否则,你需要实现一个函数,把嵌套对象转换成你自己的格式。
这一点在示例插件的数据层中得到了印证:todos.ts 里定义了与PermissionCriteria结构一一对应的TodoFilters类型,并实现了递归的matches求值函数:
export type TodoFilters = | { anyOf: TodoFilters[] } | { allOf: TodoFilters[] } | { not: TodoFilters } | TodoFilter; const matches = (todo: Todo, filters?: TodoFilters): boolean => { if (!filters) { return true; } if ('allOf' in filters) { return filters.allOf.every(filter => matches(todo, filter)); } if ('anyOf' in filters) { return filters.anyOf.some(filter => matches(todo, filter)); } if ('not' in filters) { return !matches(todo, filters.not); } return filters.values.includes(todo[filters.property]); };getAll(filter?: TodoFilters)会先用matches对每个元素过滤、再按timestamp倒序排序(todos.ts)。这正是"数据源自身具备过滤能力"的典型形态。
第一步:创建读权限(Read Permission)
在插件公共包(示例中为plugins/todo-list-common/src/permissions.ts)中,新增一个带resourceType的读权限。与基础权限不同,它必须在权限框架看来是"资源型权限",才能在条件决策中使用:
import { createPermission } from '@backstage/plugin-permission-common'; export const TODO_LIST_RESOURCE_TYPE = 'todo-item'; export const todoListCreatePermission = createPermission({ name: 'todo.list.create', attributes: { action: 'create' }, }); export const todoListUpdatePermission = createPermission({ name: 'todo.list.update', attributes: { action: 'update' }, resourceType: TODO_LIST_RESOURCE_TYPE, }); export const todoListReadPermission = createPermission({ name: 'todos.list.read', attributes: { action: 'read' }, resourceType: TODO_LIST_RESOURCE_TYPE, }); export const todoListPermissions = [ todoListCreatePermission, todoListUpdatePermission, todoListReadPermission, ];resourceType字段(这里为'todo-item')告知权限框架:该权限要在某种类型的资源上下文中被授权。你可以使用任意字符串,只要对同一种资源始终使用同一个值(参见上一节 03-adding-a-resource-permission-check.md)。
第二步:把读权限接入资源类型注册
接下来更新plugins/todo-list-backend/src/plugin.ts,通过PermissionsRegistryService.addResourceType把新权限追加到已有的资源类型注册中:
import { TODO_LIST_RESOURCE_TYPE, todoListCreatePermission, todoListUpdatePermission, todoListReadPermission, } from '@internal/plugin-todo-list-common'; // ... permissionsRegistry.addResourceType({ resourceRef: todoListPermissionResourceRef, permissions: [ todoListCreatePermission, todoListUpdatePermission, todoListReadPermission, ], rules: Object.values(rules), getResources: async resourceRefs => { return Promise.all(resourceRefs.map(getTodo)); }, });这里的rules(即上一节定义的{ isOwner })和todoListPermissionResourceRef是条件决策能否被插件识别并转换的关键,其创建方式见上一节 rules.ts 的讲解。
第三步:改用 authorizeConditional 并构建条件转换器
到目前为止我们只用过PermissionsService.authorize——它会在返回结果之前把条件决策交给插件侧评估(对带有resourceRef的请求,框架会调用getResources取回资源并应用规则的apply方法)。而本节我们希望在插件内部自行处理条件决策,因此改用PermissionsService.authorizeConditional。
修改路由处理器
import { createConditionTransformer, ConditionTransformer, } from '@backstage/plugin-permission-node'; import { add, getAll, getTodo, TodoFilter, update } from './todos'; import { todoListPermissionResourceRef } from './rules'; import { todoListCreatePermission, todoListUpdatePermission, todoListReadPermission, } from '@internal/plugin-todo-list-common'; // ... const transformConditions = createConditionTransformer( permissionsRegistry.getPermissionRuleset(todoListPermissionResourceRef) ); router.get('/todos', async (req, res) => { const credentials = await httpAuth.credentials(req, { allow: ['user'] }); const decision = ( await permissions.authorizeConditional([{ permission: todoListReadPermission }], { credentials, }) )[0]; if (decision.result === AuthorizeResult.DENY) { throw new NotAllowedError('Unauthorized'); } if (decision.result === AuthorizeResult.CONDITIONAL) { const filter = transformConditions(decision.conditions); res.json(getAll(filter)); } else { res.json(getAll()); } });处理逻辑清晰分三支:
| 决策结果 | 含义 | 插件行为 |
|---|---|---|
AuthorizeResult.DENY | 明确拒绝 | 直接抛出NotAllowedError('Unauthorized'),一个资源都不返回 |
AuthorizeResult.CONDITIONAL | 需要按条件过滤 | 用transformConditions把条件转成TodoFilter,交给getAll(filter) |
AuthorizeResult.ALLOW | 明确放行 | 返回getAll()全部数据 |
createConditionTransformer 的底层原理
原文档这样解释createConditionTransformer的作用:
为了让处理条件决策更简单,权限框架提供了
createConditionTransformer辅助函数。它接收一组权限规则,返回一个转换函数,该函数通过每个规则上定义的toQuery方法把条件转换成插件需要的格式。
在源码层面,这个辅助函数位于 plugins/permission-node/src/integration/createConditionTransformer.ts,其核心是一个递归的mapConditions:
- 对
allOf(AND)递归映射每个子条件,保持{ allOf: [...] }包裹结构; - 对
anyOf(OR)同理,保持{ anyOf: [...] }; - 对
not递归映射被否定的子条件,保持{ not: ... }; - 对叶子条件,按规则名取出规则(
getRuleByName),校验参数后调用rule.toQuery(criteria.params)得到查询片段。
这与上一节rules.ts中isOwner规则的toQuery定义完全对应:
toQuery: ({ userId }) => { return { property: 'author', values: [userId], }; },也就是说,isOwner({ userId })这条条件会被转换成{ property: 'author', values: [userId] }这样一个TodoFilter查询片段。测试用例 createConditionTransformer.test.ts 系统性地验证了各种嵌套组合(单条件、anyOf、allOf、not、深层嵌套)下转换结果的正确性,也验证了参数 Schema 校验逻辑——参数非法时会抛出'Parameters to rule are invalid'。
为什么能直接传给 getAll
原文档强调了一个便利点:
由于我们插件使用的
TodoFilter与条件对象的结构一致,我们可以直接把条件转换器的输出传给 API。如果过滤器结构不同,就需要在传给 API 之前再进一步转换。
结合 todos.ts 可以看到:TodoFilter就是{ property, values }形式的叶子片段,TodoFilters又完整支持anyOf/allOf/not包裹——与PermissionCriteria的嵌套对象结构同构,因此无需二次适配。
第四步:在策略中返回条件决策并验证效果
最后,修改我们在 Getting Started 阶段创建的CustomPolicy类(位于权限策略模块的src/policy/),让todoListReadPermission也走条件决策。这里可以直接复用todoListUpdatePermission返回的isOwner条件:
import { AuthorizeResult, PolicyDecision, isPermission, } from '@backstage/plugin-permission-common'; import { PermissionPolicy, PolicyQuery, PolicyQueryUser, } from '@backstage/plugin-permission-node'; import { UserInfoService } from '@backstage/backend-plugin-api'; import { todoListCreatePermission, todoListUpdatePermission, todoListReadPermission, } from '@internal/plugin-todo-list-common'; import { todoListConditions, createTodoListConditionalDecision, } from '@internal/plugin-todo-list-backend'; export class CustomPolicy implements PermissionPolicy { constructor(private readonly userInfo: UserInfoService) {} async handle( request: PolicyQuery, user?: PolicyQueryUser, ): Promise<PolicyDecision> { if (isPermission(request.permission, todoListCreatePermission)) { return { result: AuthorizeResult.ALLOW, }; } if ( isPermission(request.permission, todoListUpdatePermission) || isPermission(request.permission, todoListReadPermission) ) { const userEntityRef = user ? (await this.userInfo.getUserInfo(user.credentials)).userEntityRef : ''; return createTodoListConditionalDecision( request.permission, todoListConditions.isOwner({ userId: userEntityRef, }), ); } return { result: AuthorizeResult.ALLOW, }; } }策略对权限框架说的话可以理解为:
我无法独自做出决定,请带着这些条件去
todolist插件,让它把条件应用到它的数据源上。
其中todoListConditions.isOwner({ userId })与createTodoListConditionalDecision来自上一节创建的 conditionExports.ts(createConditionExports生成的导出物)。
保存策略改动后重新运行 Backstage,UI 中应当只显示你自己创建的 todo 条目——非本人创建的条目会被数据源级过滤悄悄隐藏(对GET /todos而言是过滤,而上一节的PUT /todos则是对非本人条目直接报错,两者行为差异正体现了列表端点与单资源端点的不同授权形态)。
深入:authorizeConditional 在服务端的完整行为
PermissionsService的标准实现是 ServerPermissionClient,阅读其authorizeConditional(L77-L92)可以发现三个值得注意的行为分支,帮助你理解本方案的适用前提:
- 服务主体(service principal)直接出定论:如果调用方凭证是服务主体,框架依据其
accessRestrictions(权限名 / action 限制)立即返回ALLOW或DENY,不会产生CONDITIONAL(见#servicePrincipalDecision)。因此条件决策主要面向用户凭证。 - 权限系统未启用时全部放行:当配置中
permission.enabled为false(或未配置)时,authorizeConditional直接为每个请求返回ALLOW,插件逻辑随之走getAll()全量分支——这正是 Getting Started 中要求先在app-config.yaml设置permission.enabled: true的原因。 - 正常情况:框架以用户凭证的 on-behalf-of token 调用权限后端,由策略做出决策;若返回
CONDITIONAL,则把条件原样交回插件。
这也解释了为什么本文方案能保持数据源的分页能力:插件从未"全量取出再授权",而是把过滤下沉到了getAll(filter)内部,分页、排序等仍由数据源统一处理。
小结与后续路线
本文完成了从"批量授权 + 应用层过滤"到"条件决策 + 数据源级过滤"的演进:
- 定义一个带
resourceType的读权限todoListReadPermission; - 通过
addResourceType把它注册进资源类型,复用已有规则集; - 端点改用
permissions.authorizeConditional,对DENY直接报错、对CONDITIONAL用createConditionTransformer转成查询过滤器交给数据源、对ALLOW返回全部; - 策略端用
createTodoListConditionalDecision+todoListConditions.isOwner返回条件决策。
上述能力同样支撑着 Backstage 官方插件中更复杂的授权形态——例如 catalog-backend 的 AuthorizedEntitiesCatalog 与 scaffolder-backend 的路由层 都使用了createConditionTransformer来把权限条件转换为各自的数据查询。
至此,插件后端的授权逻辑已经覆盖了"基础权限 → 资源权限 → 分页数据条件授权"三个层次。下一步可以进入前端授权部分 05-frontend-authorization.md,学习如何在前端 UI 中同步这些权限决策(例如按权限隐藏按钮或卡片)。
【免费下载链接】backstageBackstage is an open framework for building developer portals项目地址: https://gitcode.com/GitHub_Trending/ba/backstage
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考