yangshan.link / 山之志 · 后端工程笔记

首页/文章/RBAC 到 ABAC

架构

从 RBAC 到 ABAC:一套权限模型怎么长出三个维度

角色一直够用,直到资源类型从"只有设备"扩到组织、配置、人员。那一刻要写的不是一套,是六套乘四种。

权限这件事有个特点:它很少在设计阶段出问题,几乎总是在第三次扩展的时候崩掉。前两次你还能靠加字段、加分支扛过去,第三次你会发现每加一种资源,要改的地方是"已有实现数量 × 新资源数量"。

这篇讲清楚三件事:为什么角色不够、为什么必须拆成三条正交的轴、以及在一个已经在跑的系统上怎么换掉它而不出事故。

先承认 RBAC 解决了什么

RBAC 的核心贡献是把"人 → 权限"的 N×M 关系,拆成"人 → 角色 → 权限"的 N+M。这在权限是静态的、和数据无关的场景下非常好用:谁能进后台、谁能点删除按钮。

它的边界也很清楚——RBAC 回答不了任何一个跟"哪一条数据"有关的问题。"运维能查看设备"是角色能表达的;"运维只能查看他负责区域内的设备"不是。

问题RBAC需要什么
能不能调用这个接口能表达角色 → 权限点
能看到哪些行不能主体属性 × 资源属性
能看到哪些列不能主体属性 × 字段敏感级
周末不能导出不能环境属性

三条正交的轴

把上面四行归并一下,落到工程上其实是三个不同的作用位置——它们发生在请求生命周期的不同时刻,必须是三套独立的机制。

Figure 01 · 一次请求上的三个判定点

1 2 3 功能准入 进入方法之前 数据范围 拼 SQL 的时候 字段可见性 序列化之前 你能不能调 你能看哪些行 你能看哪些列 拒绝 → 403 拒绝 → 空结果 拒绝 → 掩码 请求 响应 三个点互不替代 —— 少任何一个都会漏

前端隐藏按钮解决不了第 1 点,因为它只是不显示,不是不放行。

功能准入

Can I call this?

接口级的开关。判定输入只有"主体的权限点集合"和"接口需要的权限点",跟数据无关,所以可以缓存、可以在网关做。

数据范围

Which rows?

必须下推到查询条件里。任何"查出来再过滤"的实现,在分页面前都会立刻暴露——第一页十条过滤剩三条。

字段可见性

Which columns?

作用在序列化环节。手写掩码的问题不是不能用,是每新增一个 VO 就得重写一遍,漏一个就是数据泄露。

数据范围这一轴的关键:下推

三条轴里,只有数据范围有个绕不开的工程难点:它必须变成 SQL 的一部分。

错误做法 —— 查完再过滤

public Page<Device> list(Query q) {
    Page<Device> page = deviceMapper.selectPage(q);   // 数据库返回 10 条
    page.setRecords(page.getRecords().stream()
        .filter(d -> scope.contains(d.getRegion()))    // 过滤掉 7 条
        .toList());
    return page;   // total 还是原来的总数 → 分页彻底错乱
}

正确做法 —— 条件下推

@DataScope(resource = "DEVICE", alias = "d")
public Page<Device> list(Query q) {
    return deviceMapper.selectPage(q);
}

// 拦截器在 SQL 到达数据库之前追加条件:
//   ... WHERE 1=1 AND d.region_id IN (?, ?, ?) 
// count 语句同样被改写,所以 total 是对的
为什么要有 alias。下推的条件要挂到正确的表别名上。多表 join 的查询里,region_id 可能同时存在于设备表和站点表,挂错表结果就是错的——而且是静默错的,不会报任何异常。这是这套机制最容易埋雷的地方,所以别名必须显式写,不允许框架去猜。

范围表达式:不要发明 DSL

见过不少团队在这一步给自己挖坑:设计一套自研表达式语言来描述范围。三个月后没人能读懂线上存的那些串,也没有工具能校验它。

够用的模型比想象中简单——一条范围规则 = 资源类型 + 属性 + 操作符 + 值集合,多条之间做并集:

{
  "resource": "DEVICE",
  "rules": [
    { "attr": "regionId",  "op": "IN",     "values": ["CN-EAST", "CN-SOUTH"] },
    { "attr": "snPrefix",  "op": "PREFIX", "values": ["AL7"] },
    { "attr": "orgPath",   "op": "SUBTREE","values": ["/root/cn/east"] }
  ],
  "combine": "UNION"
}

四五个操作符能覆盖九成场景:IN、PREFIX、SUBTREE、ALL、NONE。覆盖不了的那一成,让它走代码,不要为了它把模型撑大。

换掉老实现:并行链路

真正的难题从来不是设计新模型,是在一个已经跑着的系统上换掉旧的。老系统里往往散着六七套硬编码判断,而且没有人能说清它们的优先级——因为那从来没被定义过。

做这件事最重要的一个认识

老实现的问题不是"有 bug",是语义从未被定义过

所以"考古清楚再改"这条路走不通——考古结论无法验证,没有线上行为可以对照

可行的路线是:新建一条并行链路,先只判定不拦截。老链路一行不动。

阶段 1 · 影子

只算,不拦

新链路对每个请求算出"如果开强校验,结果是放行还是拒绝",只打日志和埋点。此时线上行为零变化,风险为零。

阶段 2 · 观测

用数据说话

跑两周,你会得到一张表:哪些接口会被拒、拒掉多少、拒的是谁。这张表就是老系统语义的答案——不用考古,直接测出来。

阶段 3 · 灰度

按维度逐个开

先开功能准入(影响面最清晰),再开数据范围,最后开字段可见性。每个维度一个开关,配置中心秒级生效、秒级回退。

阶段 4 · 摘除

老代码退场

某一维度新链路稳定运行一个月后,才删对应的老实现。删之前先把它改成只打日志不生效,观察一周——这一步救过我一次。

原地改造并行链路
起点先考古清楚每条存量数据的含义不需要,新链路自己定义语义
中间态行为可能变,但没有基线可对老链路一行不动,新链路只判定
怎么验证靠人回归、靠用户投诉观测数据直接给出差异清单
回退把代码改回去,而且要改对关一个开关,秒级
成本前期低,后期不可控前期多写一套,后期确定

几条踩过的经验

默认值必须是"拒绝",但上线第一版不能是

模型层面默认拒绝是对的。但如果你在阶段 3 直接把默认改成拒绝,任何一条没配到的规则都会变成线上故障。做法是:默认拒绝 + 一份显式的临时放行清单,清单里的每一条都带过期时间和负责人,逼着它慢慢清零。

不要在判定链路里查数据库

权限判定在每个请求上都会执行,一旦它自己要查库,你就给所有接口加了一次 IO。主体的权限快照应该在鉴权后一次性装配好放进上下文,判定过程必须是纯内存计算。快照失效走消息,不走轮询。

把"为什么被拒"打出来

一个只返回 403 的权限系统会消耗掉你大量的排查时间。判定结果里带上命中的规则 ID 和缺失的权限点,通过内部 header 或调试接口暴露给开发。这个投入的回报率高得离谱。

最后一句:权限系统的成功标准不是"拦住了多少非法请求",是业务方接入一个新资源时,不需要来找你。做到这一点,模型就算对了。

← 上一篇Hermes 搭建指南