权限这件事有个特点:它很少在设计阶段出问题,几乎总是在第三次扩展的时候崩掉。前两次你还能靠加字段、加分支扛过去,第三次你会发现每加一种资源,要改的地方是"已有实现数量 × 新资源数量"。
这篇讲清楚三件事:为什么角色不够、为什么必须拆成三条正交的轴、以及在一个已经在跑的系统上怎么换掉它而不出事故。
先承认 RBAC 解决了什么
RBAC 的核心贡献是把"人 → 权限"的 N×M 关系,拆成"人 → 角色 → 权限"的 N+M。这在权限是静态的、和数据无关的场景下非常好用:谁能进后台、谁能点删除按钮。
它的边界也很清楚——RBAC 回答不了任何一个跟"哪一条数据"有关的问题。"运维能查看设备"是角色能表达的;"运维只能查看他负责区域内的设备"不是。
| 问题 | RBAC | 需要什么 |
|---|---|---|
| 能不能调用这个接口 | 能表达 | 角色 → 权限点 |
| 能看到哪些行 | 不能 | 主体属性 × 资源属性 |
| 能看到哪些列 | 不能 | 主体属性 × 字段敏感级 |
| 周末不能导出 | 不能 | 环境属性 |
三条正交的轴
把上面四行归并一下,落到工程上其实是三个不同的作用位置——它们发生在请求生命周期的不同时刻,必须是三套独立的机制。
Figure 01 · 一次请求上的三个判定点
前端隐藏按钮解决不了第 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",是语义从未被定义过
所以"考古清楚再改"这条路走不通——考古结论无法验证,没有线上行为可以对照
可行的路线是:新建一条并行链路,先只判定不拦截。老链路一行不动。
只算,不拦
新链路对每个请求算出"如果开强校验,结果是放行还是拒绝",只打日志和埋点。此时线上行为零变化,风险为零。
用数据说话
跑两周,你会得到一张表:哪些接口会被拒、拒掉多少、拒的是谁。这张表就是老系统语义的答案——不用考古,直接测出来。
按维度逐个开
先开功能准入(影响面最清晰),再开数据范围,最后开字段可见性。每个维度一个开关,配置中心秒级生效、秒级回退。
老代码退场
某一维度新链路稳定运行一个月后,才删对应的老实现。删之前先把它改成只打日志不生效,观察一周——这一步救过我一次。
| 原地改造 | 并行链路 | |
|---|---|---|
| 起点 | 先考古清楚每条存量数据的含义 | 不需要,新链路自己定义语义 |
| 中间态 | 行为可能变,但没有基线可对 | 老链路一行不动,新链路只判定 |
| 怎么验证 | 靠人回归、靠用户投诉 | 观测数据直接给出差异清单 |
| 回退 | 把代码改回去,而且要改对 | 关一个开关,秒级 |
| 成本 | 前期低,后期不可控 | 前期多写一套,后期确定 |
几条踩过的经验
默认值必须是"拒绝",但上线第一版不能是
模型层面默认拒绝是对的。但如果你在阶段 3 直接把默认改成拒绝,任何一条没配到的规则都会变成线上故障。做法是:默认拒绝 + 一份显式的临时放行清单,清单里的每一条都带过期时间和负责人,逼着它慢慢清零。
不要在判定链路里查数据库
权限判定在每个请求上都会执行,一旦它自己要查库,你就给所有接口加了一次 IO。主体的权限快照应该在鉴权后一次性装配好放进上下文,判定过程必须是纯内存计算。快照失效走消息,不走轮询。
把"为什么被拒"打出来
一个只返回 403 的权限系统会消耗掉你大量的排查时间。判定结果里带上命中的规则 ID 和缺失的权限点,通过内部 header 或调试接口暴露给开发。这个投入的回报率高得离谱。
最后一句:权限系统的成功标准不是"拦住了多少非法请求",是业务方接入一个新资源时,不需要来找你。做到这一点,模型就算对了。