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 的治理是“约束用户的行为”,后者难得多。

关联