38.3 授权 (RBAC / ABAC / 权限模型)
一句话
授权选型不是“谁更先进”,而是表达力、判定成本、治理成本的三角:RBAC 判定最便宜但角色数按维度乘积爆炸,ABAC 能表达任意条件但依赖可信属性,ReBAC 擅长“关系”但查询与治理都更贵——90% 的系统只需要 RBAC + 数据范围。
三种模型能表达什么
| 模型 | 判定依据 | 能表达 | 表达不了 | 判定成本 |
|---|---|---|---|---|
| RBAC | 用户 → 角色 → 权限 | “财务能看报销单” | “财务只能看自己部门的报销单”(要靠数据范围补) | 查表 O(1) |
| ABAC | 主体/资源/环境/操作的属性 + 策略 | “部门经理能审批金额 < 1 万且属于本部门的单” | 表达本身没问题,难在属性质量与策略测试 | 求值 + 取属性(可能 1–2 次外部查询) |
| ReBAC | 关系图(user → group → folder → doc) | “文档的协作者、协作者所在组的成员都能看” | 复杂数值/时间条件 | 图查询(可能多跳) |
🔑 判据:权限的“形状”决定模型。权限是“某人能做某类事”→ RBAC;权限是“满足条件才能做”→ ABAC;权限是“能访问我共享出去的东西”→ ReBAC。
RBAC 的落地细节(权限矩阵四维、PDP/PEP 部署形态、延迟差 400 倍的功能点)在 49.3 权限中心与权限矩阵;多租户下的越权形态在 49.4 多租户权限模型。本节只算“角色爆炸”这笔账。
角色爆炸:一张乘法表
RBAC 的角色本质是“权限点的组合”。一旦权限需要按多个维度区分,角色数就是维度取值的笛卡尔积:
package main
import "fmt"
type dim struct {
name string
values int
}
func product(ds []dim) int {
n := 1
for _, d := range ds {
n *= d.values
}
return n
}
func main() {
base := []dim{{"资源", 10}, {"操作", 5}, {"数据范围", 3}}
scenarios := []struct {
name string
ds []dim
}{
{"资源10 × 操作5 × 范围3", base},
{"+ 租户类型 3 种", append(append([]dim{}, base...), dim{"租户类型", 3})},
{"+ 项目角色 4 种", append(append([]dim{}, base...), dim{"租户类型", 3}, dim{"项目角色", 4})},
{"+ 区域 5 种", append(append([]dim{}, base...), dim{"租户类型", 3}, dim{"项目角色", 4}, dim{"区域", 5})},
}
fmt.Println("=== RBAC:角色数 = 各维度取值的乘积 ===")
fmt.Printf("%-26s %-8s %-12s %s\n", "维度组合", "角色数", "权限码字节", "新增1个资源要加的角色")
for _, s := range scenarios {
roles := product(s.ds)
// 每个权限码按 12 字节估算(形如 order:read:dept)
fmt.Printf("%-26s %-8d %-12d %d\n",
s.name, roles, roles*12, roles/product([]dim{s.ds[0]}))
}
// ABAC:策略数按“资源 × 操作”,租户/项目/区域作为条件写进策略
abacPolicies := 10 * 5
fmt.Printf("\n=== ABAC:策略数不随租户/项目/区域增长 ===\n")
fmt.Printf("策略数 %d(每资源每操作一条,其余维度写成条件)\n", abacPolicies)
fmt.Printf("条件求值需要:主体属性(1) + 资源属性(1) + 环境属性(0–1) ≈ 2–3 次取数\n")
fmt.Println("\n=== 授权判定要带的凭证体积 ===")
for _, s := range scenarios {
roles := product(s.ds)
fmt.Printf("%-26s 若把权限码塞进 token:%6.1f KB;只放 sub+属性:约 0.2 KB\n",
s.name, float64(roles*12)/1024)
}
}
本机实测(macOS,Apple M1,Go 1.27):
=== RBAC:角色数 = 各维度取值的乘积 ===
维度组合 角色数 权限码字节 新增1个资源要加的角色
资源10 × 操作5 × 范围3 150 1800 15
+ 租户类型 3 种 450 5400 45
+ 项目角色 4 种 1800 21600 180
+ 区域 5 种 9000 108000 900
=== ABAC:策略数不随租户/项目/区域增长 ===
策略数 50(每资源每操作一条,其余维度写成条件)
条件求值需要:主体属性(1) + 资源属性(1) + 环境属性(0–1) ≈ 2–3 次取数
=== 授权判定要带的凭证体积 ===
资源10 × 操作5 × 范围3 若把权限码塞进 token: 1.8 KB;只放 sub+属性:约 0.2 KB
+ 租户类型 3 种 若把权限码塞进 token: 5.3 KB;只放 sub+属性:约 0.2 KB
+ 项目角色 4 种 若把权限码塞进 token: 21.1 KB;只放 sub+属性:约 0.2 KB
+ 区域 5 种 若把权限码塞进 token: 105.5 KB;只放 sub+属性:约 0.2 KB
读数:
| 读数 | 含义 | 动作 |
|---|---|---|
| 基础维度就有 150 个角色 | 10 资源 × 5 操作 × 3 范围,还没算租户/项目 | 说明“按组合建角色”不可持续 |
| 加租户类型 → 450,加项目角色 → 1800 | 每多一个需要区分的维度,角色数乘以它的取值数 | 这个维度该写成策略条件,不是角色 |
| 新增一个资源要加 45 个角色(已含租户维度) | 变更成本随维度线性叠加 | ABAC 只需加 5 条策略(10 资源 × 5 操作中的一组) |
| 每角色 12 字节权限码,1800 角色 = 21.1 KB | 把权限塞进 JWT 会让每个请求都带 21 KB | token 只放身份,权限在服务端查(38.2) |
🔑 这张表解释了为什么“角色当权限点用”一定会崩:角色是给人分配的,权限点是给代码判断的。当角色数由维度乘积决定时,你不是在建角色,你是在把权限矩阵编码成一个个名字——而矩阵本身才是你需要的结构。
ABAC 的三个前提(缺一个就别用)
| 前提 | 含义 | 不满足的后果 |
|---|---|---|
| 属性可信 | 部门、职级、资源归属不能被用户篡改 | 属性可伪造 = 任意越权(比 RBAC 更危险,因为看起来“有策略”) |
| 属性可得 | 判定时能低延迟拿到属性(本地缓存/Token 声明/一次查询) | 每次判定 2–3 次外部查询,P99 直接崩(49.3 的 PDP/PEP 延迟) |
| 策略可测 | 策略能写单测、能回归、能解释“为什么拒绝” | 策略变成“只有写的人懂”的规则堆,出事无法复盘 |
⚠️ ABAC 最常见的失败不是“表达不了”,而是属性过期:员工调岗后
dept属性没同步,策略仍按旧部门放行。缓解手段是属性带版本/时间戳,并在判定时校验属性新鲜度。
数据权限:模型之外的第二层
功能权限(能不能进这个页面)与数据权限(能看哪些行/列)是两层,混在一起做会两头失控:
| 层 | 判定问题 | 实现位置 | 常见错误 |
|---|---|---|---|
| 功能权限 | 能不能调用这个接口 | 网关/服务入口(PEP) | 只在前端隐藏按钮 |
| 数据权限(行) | 能看哪些记录 | 查询条件(SQL WHERE)/仓储层 | 靠每个开发者记得加 dept_id = ? |
| 数据权限(列) | 能看哪些字段 | 序列化层 / 字段级脱敏(38.5 数据分级与脱敏) | 靠前端不显示 |
🔑 行级数据权限必须下沉到数据访问层(拦截器/ORM 钩子),否则“某个人忘了加条件”就是一次数据泄露。这条在 49.4 多租户权限模型 里有完整讨论(租户条件必须兜底)。
反模式
| 反模式 | 后果 |
|---|---|
| 每个权限组合建一个角色 | 角色数爆炸(本节表),没人敢删角色,权限只增不减 |
用 isAdmin 布尔字段兜一切 | 越权无法审计,也无法做最小权限;权限该放 token 还是服务端见 49.5 登录态与令牌生命周期 |
| 权限判断散落在每个接口 | 漏一个接口 = 一个越权漏洞(38.6 的越权) |
| 只在前端隐藏按钮 | 直接调接口即可越权 |
| ABAC 策略里读用户提交的属性 | 属性可伪造,策略形同虚设 |
| 把“数据范围”当角色 | order:read:all 这种角色一多,等于把行级权限硬编码进角色 |
| 超级管理员不受审计 | 内部威胁与误操作无法追溯(3.5 审计与取证) |
自检
Q1 一个 SaaS 有 10 类资源、5 种操作、3 种数据范围,还要支持 3 种租户类型和 4 种项目角色。用 RBAC 硬做要多少角色?该怎么做?
▸ 答案:10 × 5 × 3 × 3 × 4 = 1800 个角色,且新增一个资源要加 180 个角色。正确做法是只把“岗位”建角色(如管理员、财务、成员、只读,约 4–6 个),把“数据范围/租户/项目”写成判定条件(RBAC + 数据范围,正是 49.3 的结论):角色给“能做什么”,条件给“对哪些数据做”。需要复杂条件(金额阈值、审批链)时再引入 ABAC 策略,而不是继续造角色。
Q2 为什么说“ABAC 看起来更安全,实际可能更危险”?
▸ 答案:因为 ABAC 把安全边界从“角色分配”挪到了“属性与策略”。角色分配有明确的人工审批痕迹(谁给了谁什么角色),而属性是系统自动同步的:部门错了、职级过期了、资源归属没更新,策略就会静默放行。更糟的是,属性可能来自请求(用户可控),一旦被写进策略条件就是任意越权。所以 ABAC 的前提是“属性可信 + 属性新鲜 + 策略可测”,缺一条就不如老实的 RBAC。
Q3 行级数据权限为什么必须放在数据访问层,而不能放在业务代码里?
▸ 答案:因为业务代码是由很多人写、经常改的,而权限条件必须对每一条查询都成立。放在业务层意味着每个新写的查询都要记得加 WHERE dept_id = ?,漏一次就是泄露,且评审很难发现。放在数据访问层(拦截器/ORM 钩子/视图)是“默认强制”的:新人写查询自动带上条件,只有显式申请豁免才能绕过(且可审计)。这是“安全默认值”原则在授权上的应用。
Q4 一个系统用 ReBAC 表达“文档共享”,有什么代价是 RBAC 没有的?
▸ 答案:① 查询成本:一次判定可能要遍历关系图多跳(用户→组→文件夹→文档),延迟随图深度增长,需要缓存与索引;② 关系一致性:共享关系变更后,缓存与索引要失效,否则出现“已取消共享但仍能访问”;③ 治理复杂度:关系是用户产生的(谁都能共享),容易形成“权限扩散”,需要额外的“共享范围限制/过期时间/审计”。RBAC 的治理是“管理员分配角色”,ReBAC 的治理是“约束用户的行为”,后者难得多。
- 下游:3.5 审计与取证