49.3 权限中心与权限矩阵

一句话

权限中心把"谁能对什么做什么"从 N 个服务的 if 里抽出来,变成一份可评审、可审计、可热更新的策略数据;权限矩阵是这份数据的可读形态,不是一张 Excel——它必须由工具从策略表生成,并且能 diff。

授权在整条链路上的位置

认证回答"你是谁",授权回答"你能对这个资源做什么",两者不该由同一个组件承担(分层与放置见 47.4 鉴权与限流应放在哪一层)。身份的产生与登录见 49.2 单点登录 (SSO)。

权限矩阵其实是四维的

维度 回答 例子 加一维的代价
主体 subject 谁 用户、角色、服务、组 主体一多就要支持"组嵌套"
资源 resource 对什么 订单、发票、用户 资源一多就要"资源类型 + 通配"
操作 action 做什么 读、改、退款、禁用 操作粒度决定矩阵格数
数据范围 scope 能作用到哪些行 本人 / 本部门 / 全租户 这一维才是复杂度的真正来源

前三维修剪的是"能不能做这个动作"(功能权限),第四维修剪的是"能对哪几条数据做"(数据权限)。只做前三维的系统,一定会出现这类事故:权限矩阵上写着"部门经理可查看订单",上线后部门经理能看到全公司订单——因为矩阵里没有第四维。

数据范围 判定依据 实现位置 典型错误
本人 self 资源.owner == 主体.uid 服务 用昵称/邮箱比较,改绑即失效
本部门 dept 资源.dept == 主体.dept 服务 + 组织树 只做一级比较,漏了下级部门
本部门及下级 dept_sub 组织树闭包 服务 + 闭包表 每次递归查树,深层级下 P99 劣化
整租户 tenant 资源.tenant_id == 主体.tenant 服务 + 数据层 依赖前端传 tenant_id,见 49.4 多租户权限模型
平台级 global 平台角色 服务 超管绕过所有校验且不入审计

模型选型:90% 的系统只需要 RBAC + 数据范围

模型 表达力 判定延迟 可审计性 变更成本 什么时候值得
ACL 最低(逐资源授权) 极低 高 每加一个资源都要授权 资源极少、授权极精细(如单份合同)
RBAC + 数据范围 中 微秒级(内存查表) 高(人能读懂) 低 绝大多数业务系统,默认选它
ReBAC(Zanzibar 类) 高(图关系、继承、共享) 百微秒~毫秒(图遍历) 中(要画关系图才看懂) 中 共享/继承/层级是核心业务:文档协作、网盘、项目制 SaaS
ABAC 最高(任意属性与条件) 取决于条件复杂度 低(结果难以解释) 高 合规规则驱动(按金额、时间、数据分级动态判定)
PBAC 高(策略即代码) 中 中 中 策略需要频繁调整且有专人维护(OPA / Rego)

🔑 我的判断:先用 RBAC + 数据范围把 90% 的场景做对,等到"共享、继承、临时授权"成为产品功能(而不是补丁)时再上 ReBAC。直接上 Zanzibar 类模型的团队,最常见的结局是——模型很强,但业务方看不懂矩阵,最后所有授权都走"找管理员加个关系"。

把矩阵画出来,它会立刻暴露过度授权

下面这个模型把"功能权限"和"数据范围"压在一行里表达,判定函数只有 20 行,能直接跑:

package main

import "fmt"

type Scope int

const ( // 数据范围由小到大
	Self Scope = iota
	Dept
	DeptSub
	Tenant
	Global
)

func (s Scope) String() string {
	return [...]string{"本人", "本部门", "本部门及下级", "整租户", "平台级"}[s]
}

// 一条策略 = 角色 × 操作 × 资源类型 × 数据范围
type Policy struct{ Role, Action, ResType string; Scope Scope }

type Subject struct{ UID, Dept, Tenant string; Roles []string }
type Resource struct{ Type, Owner, Dept, Tenant string }

var policies = []Policy{
	{"employee", "order:read", "order", Self},
	{"employee", "invoice:read", "invoice", Self},
	{"manager", "order:read", "order", DeptSub},
	{"manager", "order:update", "order", DeptSub},
	{"manager", "order:refund", "order", Dept},
	{"finance", "invoice:read", "invoice", Tenant},
	{"finance", "order:read", "order", Tenant},
	{"admin", "user:disable", "user", Tenant},
}

func covers(scope Scope, s Subject, r Resource) bool {
	if s.Tenant != r.Tenant { // 租户不匹配,任何 scope 都不放行
		return false
	}
	switch scope {
	case Self:
		return s.UID == r.Owner
	case Dept, DeptSub:
		return s.Dept == r.Dept // 真实实现要查部门树闭包
	case Tenant, Global:
		return true
	}
	return false
}

func decide(s Subject, action string, r Resource) (bool, string) {
	for _, p := range policies {
		for _, role := range s.Roles {
			if p.Role == role && p.Action == action && p.ResType == r.Type && covers(p.Scope, s, r) {
				return true, fmt.Sprintf("%s 的 %q(%s)覆盖该资源", role, p.Action, p.Scope)
			}
		}
	}
	return false, "无匹配且覆盖的策略"
}

func main() {
	roles := []string{"employee", "manager", "finance", "admin"}
	actions := []string{"order:read", "order:update", "order:refund", "invoice:read", "user:disable"}
	fmt.Println("| 角色 | " + "order:read | order:update | order:refund | invoice:read | user:disable |")
	fmt.Println("|---|---|---|---|---|---|")
	for _, role := range roles {
		fmt.Printf("| %s ", role)
		for _, a := range actions {
			cell := "·"
			for _, p := range policies {
				if p.Role == role && p.Action == a {
					cell = p.Scope.String()
				}
			}
			fmt.Printf("| %s ", cell)
		}
		fmt.Println("|")
	}

	me := Subject{UID: "u-alice", Dept: "d-east", Tenant: "t-001", Roles: []string{"manager"}}
	cases := []struct {
		name string
		act  string
		res  Resource
		want bool
	}{
		{"本部门订单", "order:read", Resource{"order", "u-bob", "d-east", "t-001"}, true},
		{"别部门订单", "order:read", Resource{"order", "u-carol", "d-west", "t-001"}, false},
		{"本人订单退款", "order:refund", Resource{"order", "u-alice", "d-east", "t-001"}, true},
		{"同部门他人订单退款(部门级授权 = 过宽)", "order:refund", Resource{"order", "u-bob", "d-east", "t-001"}, true},
		{"跨租户", "order:read", Resource{"order", "u-bob", "d-east", "t-002"}, false},
		{"没授权的动作", "user:disable", Resource{"user", "u-bob", "d-east", "t-001"}, false},
	}
	fmt.Println("\n| 用例 | 期望 | 实际 | 命中策略 / 拒绝原因 |")
	fmt.Println("|---|---|---|---|")
	for _, c := range cases {
		got, why := decide(me, c.act, c.res)
		mark := "✅"
		if got != c.want {
			mark = "❌"
		}
		fmt.Printf("| %s %s | %v | %v | %s |\n", mark, c.name, c.want, got, why)
	}
}

输出(本机实测):

| 角色 | order:read | order:update | order:refund | invoice:read | user:disable |
|---|---|---|---|---|---|
| employee | 本人 | · | · | 本人 | · |
| manager | 本部门及下级 | 本部门及下级 | 本部门 | · | · |
| finance | 整租户 | · | · | 整租户 | · |
| admin | · | · | · | · | 整租户 |

| 用例 | 期望 | 实际 | 命中策略 / 拒绝原因 |
|---|---|---|---|
| ✅ 本部门订单 | true | true | manager 的 "order:read"(本部门及下级)覆盖该资源 |
| ✅ 别部门订单 | false | false | 无匹配且覆盖的策略 |
| ✅ 本人订单退款 | true | true | manager 的 "order:refund"(本部门)覆盖该资源 |
| ✅ 同部门他人订单退款(部门级授权 = 过宽) | true | true | manager 的 "order:refund"(本部门)覆盖该资源 |
| ✅ 跨租户 | false | false | 无匹配且覆盖的策略 |
| ✅ 没授权的动作 | false | false | 无匹配且覆盖的策略 |

读法:最后那条 order:refund 是矩阵的产出,不是 bug。矩阵把"能退款"和"能退部门的款"并排放在一格上,任何人一眼就能指着说"退款为什么给到部门级"。如果权限只写在代码里(if user.Role == "manager" { allowRefund() }),这个过度授权永远不会被看见——这就是矩阵存在的理由。

规模感:这张表是 4 角色 × 5 动作 = 20 格。线上角色数 15–40 个是常态,对应 300–800 格。到这个量级,矩阵必须从策略表自动生成并支持 diff(本次变更新增/删除了哪几格、谁的权限变大了),人肉维护 Excel 必然与代码脱节。

数据模型:五张表

CREATE TABLE role (
  id      TEXT PRIMARY KEY,           -- 'manager'
  name    TEXT NOT NULL,
  parent  TEXT REFERENCES role(id),   -- 角色继承,可为空
  builtin INTEGER NOT NULL DEFAULT 0  -- 内置角色禁止删改
);

CREATE TABLE permission (
  code       TEXT PRIMARY KEY,        -- 'order:refund',用字符串不用自增 ID
  res_type   TEXT NOT NULL,           -- 'order'
  action     TEXT NOT NULL,           -- 'refund'
  risk_level INTEGER NOT NULL DEFAULT 1  -- 1 普通 / 2 敏感 / 3 高危
);

CREATE TABLE role_permission (
  role_id TEXT NOT NULL REFERENCES role(id),
  code    TEXT NOT NULL REFERENCES permission(code),
  scope   TEXT NOT NULL,              -- self / dept / dept_sub / tenant / global
  PRIMARY KEY (role_id, code)         -- 同一角色对同一权限只给一个范围
);

CREATE TABLE user_role (
  uid, role_id, tenant_id TEXT NOT NULL,
  granted_by, granted_at  TEXT NOT NULL,   -- 谁授的、什么时候
  PRIMARY KEY (uid, role_id, tenant_id)    -- 租户必须在主键里,否则同一角色跨租户生效
);

CREATE TABLE permission_audit (
  id, actor, subject, code, scope, action, ts
);

三个设计决定的理由:权限用字符串码(order:refund)而不是自增 ID——日志、告警、代码常量、评审文档全部用同一个字符串,不用来回查表;scope 放在 role_permission 上而不是 permission 上——同一个"查看订单"在员工身上是"本人",在财务身上是"整租户";user_role 带 tenant_id——否则同一个角色在 A 租户生效、在 B 租户也生效。

PDP / PEP 分离:三种部署形态,延迟差 400 倍

策略判定点(PDP)与执行点(PEP)分离后,PDP 放哪决定了整个系统的延迟与治理成本:

形态 代表 单次判定延迟 变更生效 语言绑定 适合
库内嵌 casbin、ladon(Go 库)、OPA Go SDK 微秒级 重启或热加载 强绑定 单体、单语言、策略稳定
Sidecar / 本地 agent OPA sidecar、Envoy ext_authz 亚毫秒 轮询(如 30s)或推送 无 多语言、K8s
独立服务 iam-authz-server 的 /v1/authz、Ory Keto、SpiceDB 1 个 RTT(1–5ms) 实时或秒级 无 多语言、策略集中治理、要审计

内嵌 5µs 与远程 2ms 是 400 倍的关系;按 1 万 QPS 算,每次请求都远程判定意味着授权服务要扛 1 万 QPS 且每个请求多 2ms——这与"每跳远程内省"的账是同一笔账(见 47.4 鉴权与限流应放在哪一层)。因此独立服务形态必须配本地缓存:策略版本号 + 进程内快照,命中时延迟回到微秒级,变更靠"版本号变了就全量换快照"。

⚠️ 授权服务不可用时是 fail-open 还是 fail-close?回答"fail-open 更稳"的团队,等于把鉴权服务降级成"一个可被 DDoS 绕过的装饰";回答"fail-close 更安全"的团队,会在鉴权服务抖动时把全站打成 503。可行解是分接口分级:读接口 fail-open + 告警,写接口与高危操作 fail-close,且 fail-open 期间强制降级为"只读 + 不放行高风险动作"。

一个真实的权限中心:iam 的 policy 与热加载

marmotedu/iam (opens new window) 的授权侧是最小可用的权限中心样本,值得逐层拆:

组件 职责 关键实现
iam-apiserver user / secret / policy 的 CRUD,落 MySQL policy 以 ladon DefaultPolicy 的 JSON 存在 policyShadow 列,同时把 name、username 落成独立列以便索引
iam-authz-server 提供 /v1/authz 判定接口 把手写的 ladon.Request 绑进来,Context["username"] 由 JWT 中间件注入,再交给策略引擎判定
缓存层 策略与密钥进 Redis + 进程内存 判定路径不查 MySQL
iam-watcher 变更通知 MySQL 变更 → 向 Redis 频道 iam.cluster.notifications 发布 PolicyChanged / SecretChanged,消息带 SHA256 签名
热加载 收到通知后重载 1 秒 ticker 合并重载请求("最多 1 秒一次全量重载"),避免批量变更把服务打爆
审计 拒绝与放行都留痕 判定结果写入 Redis,iam-pump 再落 MongoDB;policy 表上的 BEFORE DELETE 触发器把删除行归档进 policy_audit

策略的形态就是 ladon 的 DefaultPolicy:subjects + actions + resources + effect + conditions。它比四列表多了 conditions(如"仅工作时间""仅 IP 段内"),代价是条件里的表达式无法用 SQL 索引,只能全量载入内存判。

💡 值得抄的三个决定:① 策略变更走消息通知 + 定时兜底,不依赖"每次都实时推"(推送会丢,ticker 保证最终一致);② 删除策略前先归档(BEFORE DELETE 触发器),否则"谁在什么时候撤销了这个权限"永远查不到;③ 通知消息签名,防止能写 Redis 的进程伪造"策略已变更"来干扰其他节点。

权限治理:矩阵之外的三件事

矩阵只回答"现状是什么",治理回答"该怎么变":

机制 做法 可量化指标
申请-审批-授予-回收(IGA) 每次授权必须有审批单号、有效期、owner 无审批单的授权数 = 0
定期复核(recertification) 每季度由资源 owner 确认,未确认自动回收 复核完成率 ≥ 95%;回收率 10–30%
未使用权限回收 统计 90 天内从未命中的 (role, action) 未使用权限占比,目标压到 < 20%
越权检测 审计日志里"1 分钟内访问 > 50 个不同资源 ID"= 爬取型越权 检出告警数 / 周

🔑 只有 recertification 能真正减少权限。其他机制都在"防止继续增加",而权限的自然演化方向永远是膨胀——人只会申请,不会主动退。

落地:指名 / 可抄 / 可验 / 可退

指名:Go 单体且策略简单用 casbin(模型文件 + 策略文件,几行接入);需要策略即代码与多语言用 OPA(Rego);需要关系型授权(共享、继承)用 SpiceDB 或 Ory Keto;想要"权限中心 + 审计 + 热加载"的整套参考实现,读 iam。

可抄(casbin 的 RBAC + 资源域模型,未验证:本机未安装 casbin 依赖):

# model.conf
[request_definition]
r = sub, dom, obj, act
[policy_definition]
p = sub, dom, obj, act
[role_definition]
g = _, _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub, r.dom) && r.dom == p.dom && r.obj == p.obj && r.act == p.act
# policy.csv:第三个参数是资源域(租户),与 49.4 的租户隔离对齐
p, manager, t-001, order, read
p, finance, t-001, invoice, read
g, u-alice, manager, t-001

可验(零依赖:把上面的 Go 程序存成 pdp/main.go,本机已跑通):

mkdir pdp && cd pdp && go mod init pdp && go run .   # 输出 4×5 权限矩阵 + 6 条判定用例
# 预期:6 条用例全部 ✅;其中"同部门他人订单退款"被放行,是矩阵暴露的过度授权

可退:权限中心迁移最大的风险是"新判定与旧代码判定不一致"。切换顺序固定为三步——① shadow 模式:新 PDP 只判定、只记录,不影响放行,把"新旧结论不一致"的比例做成看板;② 差异率 < 0.1% 且逐条解释后,切读接口;③ 最后切写接口与高危动作。回退开关是"一个开关回退到旧判定路径",而不是回滚发布——发布回滚会把策略数据一起回退,比不回滚更危险。

反模式

反模式 后果
if user.Role == "admin" 散落在业务代码里 加角色要改 N 个仓库;权限变更无法审计;矩阵画不出来
用角色名做判定("是不是经理")而不是权限码("能不能退款") 角色语义一改,所有判定点含义漂移
权限矩阵维护在 Excel,代码里另有一套 两份真相;审计时无法证明"实际生效的权限"
只做功能权限,数据权限靠 SQL 手写 漏一个 WHERE dept_id = ? 就是整表越权读取
超管万能且不记审计 出事无法定位;合规审计直接不通过
授权服务不可用时 fail-open 且无告警 一次 DDoS 或一次发布就能让全站无鉴权运行
策略全量实时推送、无兜底拉取 通知丢一条,某节点永久用旧策略,越权窗口无限长
用昵称 / 邮箱做数据范围比较 改名或改绑后权限错乱,且这类 bug 只在少数用户身上出现

自检

Q1 权限矩阵只有"主体 × 资源 × 操作"三维时,哪类事故必然发生?举一个能落到代码行的例子。

▸ 答案:数据权限缺失导致的行级越权。例:GET /orders?dept=d-west 的 handler 只校验了 hasPerm(user, "order:read"),没有校验 order.dept == user.dept 也没有从 token 里取 dept 强制覆盖参数,于是"有查看订单权限"的东区经理改一个 query 参数就能看西区订单。功能权限的判定是对的,数据权限完全没做——矩阵上一切正常。

Q2 为什么 scope 要放在 role_permission 而不是 permission 上?

▸ 答案:permission 描述"动作语义"(order:read 是什么),scope 描述"这个角色能作用到多大范围"。同一个 order:read,员工是"本人"、经理是"本部门及下级"、财务是"整租户"。放在 permission 上等于规定"读订单永远只能读本人",与业务不符;放在 user_role 上则每个用户都要单独配范围,没法批量治理。放在中间层 (role, permission) 上,才能既复用又可控。

Q3 内嵌 PDP(5µs)与独立授权服务(2ms)差 400 倍,为什么还有人选独立服务?

▸ 答案:因为治理成本不是延迟能换算的。独立服务的收益是:① 多语言统一(Java/Go/Python 不用各写一套判定);② 策略集中评审与审计(一份策略表而不是 N 个配置文件);③ 变更实时生效、可灰度;④ 判定日志天然集中。代价用本地缓存摊掉:策略快照 + 版本号,命中时延迟回到微秒级,只有版本变更时才走一次 RTT。真正不能接受的是"每请求都远程判定且无缓存",不是独立服务本身。

Q4 shadow 模式(只判定不拦截)为什么是权限中心迁移的必要步骤?它的验收指标是什么?

▸ 答案:因为新旧判定必然有差异,直接切换等于把所有历史差异一次性暴露成线上事故。shadow 让新 PDP 在真实流量上跑,产出"新旧结论不一致"的明细。验收指标是差异率(不一致请求 / 总请求)与差异可解释率:差异率 < 0.1% 且每一条差异都能归类为"旧实现漏判"或"新策略有意收紧",才能切读接口。差异率不降反升,说明新模型对业务的理解有偏差,此时切流量就是把 bug 上线。

Q5 为什么"用角色名判定"比"用权限码判定"更容易腐化?

▸ 答案:角色是人的集合,权限是动作的集合,两者是多对多。用角色判定把多对多硬编码成一对一:if role == "manager" { refund() } 意味着"经理 = 能退款",于是当产品要求"经理不能退大额"或"财务也能退款"时,代码只能继续加 if。用权限码判定后,判定点是稳定的(can(user, "order:refund", order)),变化全部落在策略数据里——这也是为什么权限码要用字符串而不是自增 ID:它要出现在日志、告警、评审文档和代码常量里。

关联