0%

把权限从菜单里拆出来:授权内核的几个设计取舍

🌐 语言: 中文版 | English Version

很多后台系统的权限是从菜单开始长出来的:先有菜单,再给菜单挂一个访问级别,最后把角色和菜单绑在一起。这个顺序很自然,因为最早的需求就是”不同的人看到不同的菜单”。

问题出现在需求往下一步走的时候。页面上的按钮要单独控制,对外暴露的接口也要单独控制,而这两样东西在菜单模型里没有位置。于是一个本来还算清楚的模型开始不断打补丁。

最近在把授权部分收敛成独立组件时,重新把这些取舍梳理了一遍。这篇记录其中影响比较大的几个决策,以及当时为什么没选另一条路。

认证解决”你是谁”,授权解决”你能做什么”。前面写的几篇身份治理文章都在认证这一侧,这篇是同一条线上的授权侧配套——两者共用一套身份与组织真源,边界分开划(见《基于 OAuth2/OIDC 协议的企业级统一身份治理架构实践》)。

  • 权限主模型从菜单换成独立的”权限项”,菜单退回为权限的一个消费方。
  • 稳定标识与显示名称分离,标识生成后不可变。
  • 授权覆盖采用”先声明范围,再覆盖”的语义,避免多处共管一份配置时互相踩踏。
  • deny 只保留一种场景,其余明确推迟。
  • 身份与组织不落本地,群组权限不做物化。
  • 审计做成扩展点,而不是内置功能。

起点:权限长在菜单上

改造前的模型大致是三件套:

  • 角色与菜单、权限的绑定关系;
  • 菜单上的一个字段,用来表达访问级别;
  • 一个硬编码的管理员标识,在若干判断处直接短路。

这三样东西各自都能工作,放在一起会暴露三个问题。

第一,按钮和接口权限无处安放。菜单描述的是导航项。一个页面里的”导出”按钮、一个给第三方调用的接口,都不是导航项。硬塞进菜单,就要靠约定去区分哪些菜单行其实是按钮,模型开始失去解释力。

第二,菜单的调整会影响权限语义。菜单既要负责展示(层级、排序、图标、路由),又要负责授权,两个变化频率完全不同的东西被绑在同一条记录上。改一次菜单排序,动的是同一份数据。

第三,硬编码的管理员标识绕过了整个模型。一旦有”如果是管理员就直接放行”的分支,权限体系就不再是可推理的——你没法从数据上回答”这个人为什么能访问”,只能去代码里找。

这三个问题指向同一件事:菜单承担了不该由它承担的职责。

决策一:权限成为主模型,菜单退回消费方

第一个决策是把主模型换成独立的权限项

权限项是最小授权单元,用一个类型字段区分三类用途:菜单访问、页面操作、接口调用。这三类在授权计算上没有区别,区分只是为了管理界面分组、后端校验分类和日志可读性。

菜单则退回成一个普通的导航配置,只通过权限标识引用某个权限项:

flowchart LR
    U[用户] --> R[角色]
    O[组织] --> R
    G[群组] --> R
    R --> P[权限项]
    P -. 引用 .-> M[菜单]

菜单是否可见由两个显式状态决定:登录即可见,或者必须持有某个权限才可见。

这里有个细节当时纠结了一下。有一条原则是:业务语义必须显式建模,不要用”空值”表达业务意义。而”登录即可见”的菜单确实没有关联权限。

两者并不冲突:语义由那个显式的访问状态字段承载,没有关联权限只是表示”这一项不适用”。如果反过来,用”权限为空”来表达”登录即可见”,语义就藏在空值里了——新增第三种访问模式时,这套隐式约定会立刻失效。

这个调整带来的直接收益是,权限不再依赖菜单存在:可以先有权限、后有菜单,也可以有多个菜单指向同一个权限项。菜单变成权限在展示层的一个投影。

决策二:稳定标识与显示名称分离

角色和菜单都拆成两个字段:

  • 稳定标识:供代码、配置、初始化数据、日志引用;
  • 显示名称:随时可改。

规则是:稳定标识生成后不可修改。用户可以随意编辑角色名称、描述、状态,但标识一旦生成就固定下来。

标识由系统生成,而不是让用户填写,并且带一段不可预测的随机后缀,避免同名角色在合并或迁移时撞在一起。

为什么不让用户直接填?因为稳定标识是被引用方。权限判断写在代码里是”导出订单”这样的标识,初始化数据里写的是角色标识,日志里打的也是它。如果允许随意修改,一次重命名就会让代码里的引用失效,而且失效发生在运行期,不会有任何编译期提示。

代价是稳定标识不可读,必须靠名称字段展示。这个代价比运行期引用失效要小得多。

决策三:授权覆盖要先声明范围

角色授权界面有一个绕不开的问题:提交上来的是一份全量清单,还是增量变更?

全量覆盖实现简单,也符合”所见即所得”,但有个前提——这个角色的权限只能由一处管理。只要有两个模块都在给同一个角色配权限,先提交的那一方的修改就会被后提交的一方整体覆盖掉,而且没有任何提示。

增量变更没有这个问题,但它需要区分”新增””删除””未变化”,而且无法处理”清空”这个操作——空集合既可能是”什么都不改”,也可能是”全部撤销”。

这里选择的做法是声明范围后再覆盖

  1. 调用方先声明”我管辖哪些权限”(管辖范围);
  2. 再给出”当前选中哪些”(选中集合);
  3. 写入时只删除管辖范围内的绑定,范围外的绑定保持原样;
  4. 然后按选中集合重建管辖范围内的绑定。

这样每个模块看到的是自己那部分的全量状态——语义上仍然是覆盖,实现简单;同时作用域被限制在自己声明的范围内,不会踩到别人的。清空操作也有了明确含义:选中集合为空,而管辖范围不变。

配套的几条约束:

  • 选中集合必须是管辖范围的子集,越界直接拒绝;
  • 菜单类型的权限不允许走这个入口,避免两条路径改同一份数据;
  • 写入前对角色加锁,保证删除和插入之间不被插队;
  • 管辖范围由服务端确定,不接受前端直接传。

后来在别的地方也碰到过同一类问题:只要出现”多处共管同一份配置”,就会遇到同样的两难——全量覆盖会互相覆盖,增量变更无法表达清空。”管辖范围 + 选中集合”把作用域显式化,两边的好处都能拿到。

还有一个边界上的取舍:权限项注册遇到并发冲突时,不在组件内部重试或覆盖写入,而是报错让调用方重试。原因是注册通常发生在调用方自己的事务里,内部重试会掩盖事务边界——到底是哪一次写入生效了,调用方和组件的理解可能不一致。把决定权交回去,调用方的重试语义是清楚的。

决策四:deny 只保留一种场景

第一版采用纯 allow 的并集模型:所有来源的角色取并集,没有 deny。

唯一的例外是这一个场景:

  • 某个组织被授予了一个角色;
  • 该组织下绝大多数人继承这个角色;
  • 只有某一个人需要被排除。

实现方式是,组织、群组这类来源的绑定都只表示 allow,用户直授的关系里保留一种 deny 类型,命中时把对应角色从并集中移除。

flowchart TD
    A[用户直授] --> U[合并角色集合]
    B[组织继承] --> U
    C[群组继承] --> U
    U --> E[移除用户级 deny 命中的角色]
    E --> F[过滤未启用角色]
    F --> G[展开为权限集合]

明确不做的是:角色对权限的 deny、多规则的优先级仲裁、操作与接口之间独立的 deny 求值、通用表达式策略。

理由是,收集到的真实需求几乎全部集中在上面那一种,而通用的策略引擎会把”这个人最终有哪些权限”从一个可查表的计算,变成一个需要求值器的推理。后者在排障时成本高得多——你得先搞清楚规则是怎么求值的,才能回答一个具体用户为什么没有某个权限。

先只做一种,如果以后需求确实扩散了,再引入求值器也不迟;反过来,做了再想删就难了。

决策五:身份与组织不落本地,群组权限不物化

这个组件的定位是应用内授权内核,身份与组织的真源在统一的身份服务。边界是:

  • 身份服务负责:用户身份、组织结构、系统准入,以及网关侧的令牌校验与头信息透传(隔离方式见《网关层 Per-Client 鉴权隔离》);
  • 授权内核负责:角色、权限、菜单树、绑定关系、权限判断、审计扩展点。

明确禁止在授权侧复制用户主数据或组织主数据。授权表里只存标识引用,不存姓名、部门、层级。

群组(项目组、跨部门协作这类非行政组织集合)的处理有一个特别的取舍:群组的角色不物化到用户直授关系。用户加入或退出群组以后,权限通过查询群组成员关系的当前结果自然生效或失效,不需要跑任何同步任务。

物化能让权限判断变快——只需查一张用户直授表。但它引入了一个更难的问题:成员关系变化后,什么时候、由谁来更新物化结果?同步延迟、同步失败、部分成功,都会让实际权限和配置不一致,而且这种不一致很难被发现。

不物化的代价是每次解析都要多查一次群组成员关系。第一版接受这个代价。

由于不落本地,解析权限时需要访问身份服务。这里用一个适配接口把依赖隔离掉,允许测试环境用桩实现替换——接入方只需要提供这一种实现。

决策六:审计是扩展点,不是内置功能

授权变更需要留痕,但留到哪里各个系统并不一样:有的写自己的审计表,有的发消息队列,有的接外部审计中心。

所以这里的做法是:

  • 只负责在写操作成功后发布标准化的审计事件;
  • 默认提供一个空实现,未接入审计时不影响启动;
  • 不创建审计表,不内置任何业务系统专属的存储逻辑
  • 审计失败不应反向影响授权主流程,容错由接入方自己处理。

事件模型固定了几个字段——操作人、事件类型、目标、结果、时间、链路 ID。事件类型按第一版需要收敛为角色与菜单的增删改,以及各类绑定变更。

关键取舍是**”发布”和”落库”分开**。内核决定什么算一次授权变更、事件里该带哪些字段;落到哪里由接入方决定。要接消息队列或者外部审计中心,新增一个实现即可,不需要动核心逻辑。

被明确推迟的部分

第一版有一份显式的非目标清单,其中影响较大的几项:

  • 分布式缓存与跨节点失效:第一版不引入外部缓存,缓存只作为可选能力,默认是进程内的。权限版本号、跨节点失效广播留到后续版本。
  • 数据权限与查询自动改写:这是行级、列级的另一类问题,和功能权限混在一起会让两边的模型都变复杂。
  • 复杂 deny 与属性化策略:同决策四。
  • 管理界面:内核只提供能力,界面由业务系统自己做。

我现在的习惯是把”不做什么”也写进设计文档。这些项不是没想到,而是知道代价以后主动推迟——后面真要做的时候,至少知道当初是为什么没做。

小结

回过头看,这几个决策有一条共同的线索:先把”什么是什么”定清楚,再谈功能

权限不是菜单的附属属性,而是一个独立的、可被多种消费方引用的东西;稳定标识不是显示名称,而是被代码引用的契约;授权覆盖不是一次写入,而是需要先声明作用域;身份与组织不是可以复制的本地数据,而是需要每次去问的外部真源;审计不是一个功能,而是一个接入点。

这些问题在功能层面都不难绕过——加个字段、加个分支、加张表就能解决眼前的需求。但绕过之后,模型会一点点失去解释力,直到某一天没人能说清楚某个用户为什么有某个权限。

先把决策和当时的理由记录下来,过一段时间再回来看,哪些站住了、哪些想错了,会比对着一个最终方案清楚得多。

延伸阅读