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:它要出现在日志、告警、评审文档和代码常量里。