PERM 模型与 Casbin:把云端授权从代码里抠出去
Posted on 二 02 6月 2026 in Tech
| Abstract | PERM 模型与 Casbin:把云端授权从代码里抠出去 |
|---|---|
| Authors | Walter Fan |
| Category | Cloud / Security |
| Status | v1.5 |
| Updated | 2026-06-02 |
| License | CC-BY-NC-ND 4.0 |
从一段熟悉的代码说起
写过后端的朋友,多半见过类似这样的权限判断:
if user.Role == "admin" ||
(user.Role == "owner" && user.TenantID == log.TenantID) ||
(user.Role == "auditor" && action == "read") {
// ...
}
一开始角色少,写在 handler 里没啥不舒服。可一旦角色从两个涨到十个,租户隔离、按属性放行、按时段限制这些规则陆续加进来,权限判断就会从一两个 if 长成散落各处的千层饼。加一个角色,意味着十几处代码要重审,测试用例翻倍,code review 至少两轮。
于是问题就来了:能不能把"谁能干什么"这件事,从业务代码里彻底抠出去?
这个问题学术界早就有人琢磨,工业界也有现成的轮子。理论叫 PERM,工具叫 Casbin。Luo Yang 等人在 2025 年发了篇 IEEE TIFS 的论文 《PERM: Streamlining Cloud Authorization With Flexible and Scalable Policy Enforcement》,算是把 Casbin 的设计哲学讲明白了。
咱们今天就来拆一拆这个 PERM。
一、PERM 是什么:把授权拆成四块拼图
PERM 是四个英文单词的首字母:Policy、Effect、Request、Matchers。一句话总结就是:
PERM 是一个授权元模型,它把"判断一次访问是否被允许"这件事,拆成四个正交的部分,每一部分都用配置文件描述。
为什么叫"元模型"?因为它本身不是 RBAC、不是 ABAC,而是一套能生成各种授权模型的语法。就像 BNF 是描述语言的语言,PERM 是描述授权模型的模型。
四块拼图各管一摊:
| 部件 | 管什么 | 一句话解释 |
|---|---|---|
| Request | 一次访问长啥样 | "谁,对什么,做什么",标准是三元组 (sub, obj, act) |
| Policy | 规则长啥样 | 策略的字段结构,比如 (sub, obj, act) 或带 effect 的 (sub, obj, act, eft) |
| Matchers | 怎么算"匹配上了" | 一个布尔表达式,决定 request 命中哪条 policy |
| Effect | 多条命中时怎么裁决 | 比如 "只要有一条 allow 就放行"、"有 deny 就否决" |
写成 .conf 文件,一个最简单的 ACL(访问控制列表)模型长这样:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = r.sub == p.sub && r.obj == p.obj && r.act == p.act
配套的策略文件 policy.csv:
p, alice, data1, read
p, bob, data2, write
意思很直白:alice 能读 data1,bob 能写 data2。Enforce 来个 ("alice", "data1", "read"),逐条对照 policy,matcher 全部 true,effect 说"有一条 allow 就放行",结果就是 true。
到这里你可能觉得:这不就是把 if-else 写成了配置文件吗?
固然如此,可是别急,PERM 的杀招在后面。
二、PERM 真正的杀招:换模型不换引擎
刚才那个 ACL 模型,咱们一行代码不动,只改 model.conf,就能升级成 RBAC:
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
多了一个 [role_definition] 段 g = _, _,表示"用户—角色"是个二元关系。matcher 里的 r.sub == p.sub 也换成了 g(r.sub, p.sub),意思是"request 的 sub 是否拥有 policy 里的 sub 这个角色"。
policy.csv 变成:
p, admin, data1, read
p, admin, data1, write
g, alice, admin
alice 是 admin,admin 能读写 data1,所以 alice 也能读写 data1。
想要 ABAC?把 matcher 改成 r.sub.Age >= 18 && r.obj.Owner == r.sub.Name 即可。想要带租户隔离的 RBAC?加一个 domain 字段,matcher 里多一个 &&。
核心思想就这一条:业务代码永远只调一行 enforcer.Enforce(sub, obj, act),授权语义全部在配置文件里演化。
这就是为什么论文里把 PERM 叫做 "streamlining cloud authorization"——云端服务多租户、多角色、多场景,需求一天一个变,把变化收敛到配置层,代码层稳如老狗,运维和安全团队也能直接改策略,不用每次都拉开发陪跑。
三、一个真实场景:给租户里的角色授权某个资源的某个操作
光说 data1、data2 太抽象,咱换个云上密钥管理的真实例子。资源路径长这样:
secrets/prod/app/cluster/region
需求是这样的:
在
tenant_acme这个租户里,secret-reader这个角色,能对secrets/prod/payment/*下面的所有密钥执行read,但不能write;而且tenant_acme看不到tenant_globex的任何东西。
这一句话里藏了三层诉求:租户隔离、角色授权、资源按路径分层匹配。一条条来。
1. 加上租户:domain 字段
多租户隔离,Casbin 的标准做法是在 request 和 policy 里加一个 dom(domain,也就是租户)字段。模型 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) && keyMatch2(r.obj, p.obj) && r.act == p.act
三处变化,对应三层诉求:
r = sub, dom, obj, act:每次请求都带上"在哪个租户里"。g = _, _, _:角色绑定从二元变三元,意思是"某人在某租户里拥有某角色"。同一个人在 acme 是 admin,在 globex 可能啥都不是。keyMatch2(r.obj, p.obj):把资源匹配从字符串全等,换成 RESTful 路径匹配,这样secrets/prod/payment/*才能一把罩住底下所有密钥。
2. 资源分层匹配:keyMatch 系列函数
secrets/prod/app/cluster/region 这种带层级的资源,最忌讳一条条枚举。Casbin 内置了几个 keyMatch 函数专门干这事,按需挑一个:
| 函数 | 通配语法 | 适合的路径风格 |
|---|---|---|
keyMatch |
* 匹配尾段 |
/secrets/prod/* |
keyMatch2 |
:param + * |
/secrets/:env/*,类 RESTful |
keyMatch3 |
{param} |
/secrets/{env}/payment |
keyMatch4 |
{param} 且同名要相等 |
同一段多次出现要一致 |
我习惯用 keyMatch2:策略里写 secrets/prod/payment/*,请求 secrets/prod/payment/db/us-west 就能命中,新增一个 region 不用改任何策略。
3. 配套的策略文件
policy.csv(注意 g 那行多了第三列租户):
p, secret-reader, tenant_acme, secrets/prod/payment/*, read
p, secret-admin, tenant_acme, secrets/prod/payment/*, read
p, secret-admin, tenant_acme, secrets/prod/payment/*, write
g, alice, secret-reader, tenant_acme
g, bob, secret-admin, tenant_acme
g, alice, secret-admin, tenant_globex
读出来就是:alice 在 acme 里只能读 payment 密钥,在 globex 里却是管理员;bob 在 acme 里能读能写。
4. 业务代码:还是一行
package main
import (
"fmt"
"log"
"github.com/casbin/casbin/v2"
)
func main() {
e, err := casbin.NewEnforcer("model.conf", "policy.csv")
if err != nil {
log.Fatalf("load enforcer failed: %v", err)
}
cases := []struct {
sub, dom, obj, act string
}{
// alice 在 acme 是 reader:能读 payment 下任意密钥
{"alice", "tenant_acme", "secrets/prod/payment/db/us-west", "read"},
// alice 在 acme 不能写
{"alice", "tenant_acme", "secrets/prod/payment/db/us-west", "write"},
// alice 在 globex 是 admin:能写
{"alice", "tenant_globex", "secrets/prod/payment/db/us-west", "write"},
// bob 在 acme 是 admin:能写
{"bob", "tenant_acme", "secrets/prod/payment/cache/eu", "write"},
// alice 摸不到 globex 的别的租户资源?这里用 acme 身份访问 globex 路径会被拒
{"alice", "tenant_globex", "secrets/prod/billing/db/us-west", "read"},
}
for _, c := range cases {
ok, err := e.Enforce(c.sub, c.dom, c.obj, c.act)
if err != nil {
log.Printf("enforce error: %v", err)
continue
}
fmt.Printf("%-6s %-14s %-34s %-6s => %v\n", c.sub, c.dom, c.obj, c.act, ok)
}
}
跑出来:
alice tenant_acme secrets/prod/payment/db/us-west read => true
alice tenant_acme secrets/prod/payment/db/us-west write => false
alice tenant_globex secrets/prod/payment/db/us-west write => true
bob tenant_acme secrets/prod/payment/cache/eu write => true
alice tenant_globex secrets/prod/billing/db/us-west read => false
注意第三行和最后一行:同一个 alice,换了租户身份,权限完全不同——这就是 domain 隔离的价值。她在 globex 是 admin 能写 payment,但 globex 里没人给她授过 billing,照样吃闭门羹。
5. 还是动态改:给租户里的角色加权限
线上要给 acme 的 reader 再开一个 metrics 资源的读权限,依旧不用重启:
// 在 tenant_acme 里,给 secret-reader 角色加 metrics 读权限
_, _ = e.AddPolicy("secret-reader", "tenant_acme", "secrets/prod/metrics/*", "read")
// 把 carol 拉进 tenant_acme 的 secret-reader 角色
_, _ = e.AddGroupingPolicy("carol", "secret-reader", "tenant_acme")
_ = e.SavePolicy()
到这儿,开头那句"给某租户某角色授权某资源某操作"的需求,就被拆成了"加一行 policy + 加一行 grouping"两件小事,跟改代码彻底没关系了。
四、上手 Casbin:Go 版三步走
理论讲完,咱们撸代码。Go 版 Casbin 叫 casbin/casbin,安装:
go get github.com/casbin/casbin/v2
第一步:写两个文件
model.conf(RBAC 模型):
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act
[role_definition]
g = _, _
[policy_effect]
e = some(where (p.eft == allow))
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
policy.csv:
p, admin, /logs, read
p, admin, /logs, write
p, auditor, /logs, read
g, alice, admin
g, bob, auditor
第二步:写业务代码
package main
import (
"fmt"
"log"
"github.com/casbin/casbin/v2"
)
func main() {
e, err := casbin.NewEnforcer("model.conf", "policy.csv")
if err != nil {
log.Fatalf("load enforcer failed: %v", err)
}
cases := []struct {
sub, obj, act string
}{
{"alice", "/logs", "read"}, // admin → 应放行
{"alice", "/logs", "write"}, // admin → 应放行
{"bob", "/logs", "read"}, // auditor → 应放行
{"bob", "/logs", "write"}, // auditor 无写权限 → 应拒绝
{"carol", "/logs", "read"}, // 无角色 → 应拒绝
}
for _, c := range cases {
ok, err := e.Enforce(c.sub, c.obj, c.act)
if err != nil {
log.Printf("enforce error: %v", err)
continue
}
fmt.Printf("%-6s %-8s %-6s => %v\n", c.sub, c.obj, c.act, ok)
}
}
跑起来:
alice /logs read => true
alice /logs write => true
bob /logs read => true
bob /logs write => false
carol /logs read => false
第三步:动态改策略
线上加一个 auditor 不需要重启服务,调 API 即可:
// 加策略
_, _ = e.AddPolicy("auditor", "/metrics", "read")
// 把 carol 加入 auditor 角色
_, _ = e.AddGroupingPolicy("carol", "auditor")
// 持久化到 storage(前提是用了 Adapter,比如 GORM)
_ = e.SavePolicy()
生产环境一般会把 policy 存到 MySQL/PostgreSQL,用 casbin/gorm-adapter 之类的 Adapter;多实例之间用 Watcher 同步(Redis、etcd 都有现成的)。这套基础设施成熟得很,开箱即用。
五、Casbin vs OPA vs OpenFGA:三种授权哲学
讲 Casbin 不提 OPA(Open Policy Agent) 和 OpenFGA 是不厚道的。这三家是当下开源授权领域的代表,背后是三种不同的设计哲学:
- Casbin:基于 PERM 元模型,面向"模型 + 策略"——你先描述一种授权范式(ACL/RBAC/ABAC),再喂规则进去。
- OPA:通用策略引擎,面向"策略即代码"——你用 Rego 语言写任意决策逻辑,引擎不预设授权模型。Rego 类似 Datalog,图灵完备。CNCF 毕业项目,K8s 准入控制、Envoy 鉴权、Terraform 守门都靠它。
- OpenFGA:实现 Google Zanzibar 论文,面向"关系图"——所有权限表达成
(user, relation, object)三元组,引擎在关系图上做可达性推理。CNCF Sandbox 项目,由 Auth0 / Okta 主导。
设计模型对比
| 维度 | Casbin | OPA | OpenFGA |
|---|---|---|---|
| 核心范式 | PERM 元模型 | 通用策略引擎 | ReBAC(关系基) |
| 理论源头 | 自研 PERM 论文(2025) | Datalog / Rego | Google Zanzibar (2019) |
| 策略语言 | .conf + .csv |
Rego(类 Datalog) | DSL + JSON Schema |
| 数据模型 | 模型与策略分离 | 任意 JSON 输入 + 规则 | 关系三元组(tuple) |
| 学习曲线 | 半天上手 | 一两周磨 Rego | 几天学 ReBAC 思维 |
| 典型 API | Enforce(sub, obj, act) |
data.policy.allow |
Check(user, relation, object) |
| 存储 | 自定义 Adapter(DB/文件) | bundle(远端拉取) | 内置(PG/MySQL/Memory) |
| 部署形态 | 进程内库 | sidecar / 独立进程 / WASM | 独立服务(gRPC + HTTP) |
| 决策延迟 | 微秒级(in-process) | 毫秒级(IPC/HTTP) | 毫秒级(网络 RPC) |
| 典型场景 | 应用内 RBAC / ABAC | K8s 准入、CI/CD 卡点、Envoy | 文档协作、社交、SaaS 共享 |
同一个例子,三种写法
举一个简单需求:"alice 是不是 roadmap 这份文档的 viewer?"
Casbin(RBAC 模型):
# model.conf
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
# policy.csv
p, viewer, roadmap, read
g, alice, viewer
ok, _ := e.Enforce("alice", "roadmap", "read") // true
OPA(Rego):
package authz
default allow = false
allow {
input.user == "alice"
input.action == "read"
input.resource == "roadmap"
}
opa eval -d policy.rego -i input.json "data.authz.allow"
# input.json: {"user":"alice","action":"read","resource":"roadmap"}
OpenFGA(DSL):
model
schema 1.1
type user
type document
relations
define viewer: [user]
# 写一个关系 tuple
fga tuple write user:alice viewer document:roadmap
# 查询
fga query check user:alice viewer document:roadmap
# => {"allowed": true}
三种写法背后是三种思维:Casbin 把权限看成"匹配规则",OPA 看成"任意决策函数",OpenFGA 看成"图上的关系遍历"。
三家擅长什么、不擅长什么
Casbin 擅长:单服务内的多角色权限、需要快速热加载策略、模型可能从 RBAC 演化到 ABAC。
Casbin 不擅长:跨服务策略统一、需要表达"用户能访问其上级文件夹下的所有文档"这种关系递归。
OPA 擅长:跨语言、跨平台的策略统一(K8s + 微服务 + CI 一套 Rego 通吃)、复杂逻辑(图灵完备)、合规规则审计。
OPA 不擅长:高频细粒度查询(每次决策都要传完整 input)、用户/资源数量级在百万以上的关系建模。
OpenFGA 擅长:细粒度授权(fine-grained)、嵌套层级(文件夹—子文件夹—文档)、共享与继承(团队成员自动获得团队下所有项目的访问权)、大规模关系图(数十亿 tuple 仍能毫秒级响应)。
OpenFGA 不擅长:临时的属性判断(虽然有 Conditions 和 Contextual Tuples 补救,但不如 OPA 灵活)、纯 RBAC 场景(杀鸡用了牛刀)。
一张选型决策表
| 你的场景 | 推荐 |
|---|---|
| 单服务,2-20 个角色,跟业务紧耦合 | Casbin |
| 多服务统一策略,K8s/Envoy/CI 都要管 | OPA |
| 类 Google Drive / GitHub / Notion 的细粒度共享 | OpenFGA |
| 简单 RBAC + 一点 ABAC 即可 | Casbin |
| 合规审计、SoD(职责分离)等复杂逻辑 | OPA |
| 用户/资源百万级,权限需要继承和反查 | OpenFGA |
三者不是替代关系,而是不同层级、不同形态的工具。在比较成熟的架构里,常见的组合是 OPA 在外层守门(API Gateway、K8s 准入),OpenFGA 或 Casbin 在应用层做细粒度判断,互相配合,各管各的边界。
六、几个坑,请提前避开
读 Casbin 文档和 issue 时,有几个细节容易被忽略,提前提一句:
坑 1:matcher 表达式的顺序影响性能
官方文档明确提到过这事。matcher 是按从左到右短路求值的,把贵的条件(比如 g(r.sub, p.sub) 这种角色查找)放在便宜的字符串比较后面,能差出几个数量级的延迟。Casbin 维护者跑过一个有 2500 个项目、每个项目 4 个角色的压测,matcher 顺序写错,单次 enforce 慢到 6 秒;调整顺序后回到几毫秒。
写法是这样:
# 慢
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
# 快
m = r.obj == p.obj && r.act == p.act && g(r.sub, p.sub)
坑 2:policy 字段都是字符串
policy.csv 里的所有字段,进了 Casbin 都当字符串处理。想塞个 age >= 18 进去?得自己写 helper 函数,或者用 ABAC 把对象传进 request,让 matcher 里去比。别指望 p, alice, data1, 100 里那个 100 是 int。
坑 3:策略变更要广播
单实例没问题,多实例部署时,A 实例改了 policy,B 实例还用着内存里的老版本。必到用 Watcher(Redis Pub/Sub 是最常见的方案),或者所有写操作走中心化的 Admin Portal。
坑 4:别把策略写成代码的镜像
见过有人把每个 API 都写一条 policy:p, alice, /api/v1/users/:id/profile, GET。这种粒度的策略,本质还是把 if-else 搬进了 CSV,反而失去了抽象的意义。策略要按业务概念组织,不是按 URL。
七、什么时候用 Casbin:5 条 CheckList
最后给一个判断清单,符合 3 条以上就值得引入:
- [ ] 系统有 2 个以上角色,且未来还会增加
- [ ] 权限规则需要非开发人员(产品、安全、运维)也能改
- [ ] 同一份代码要支持多种部署形态(单租户、多租户、私有化)
- [ ] 审计要求能追溯每一次访问决策的依据
- [ ] 权限模型可能演化(从 RBAC 升级到 ABAC、加上 domain、加上属性等)
反过来,如果你的系统只有"登录用户"和"管理员"两种人,权限规则 5 条以内一辈子不变,那一段 if-else 比啥都强,别为了用而用。
收束:把变化关进配置文件
回到开头那段散落各处的 if。引入 PERM 这套思路之后,业务代码里剩下的只是一行:
ok, _ := enforcer.Enforce(user.Name, resource, action)
至于"谁能干什么",全部沉到 model.conf 和 policy.csv。新增角色、调整规则,不再需要重新审业务代码,只是改配置、走审批、热加载。
PERM 的精髓,我觉得就一句话:
代码负责"如何执行",配置负责"是否允许"。两件事分开,世界清净。
授权这个领域,最怕的不是规则复杂,是规则变。PERM 的贡献,就是把变化挪到了一个可控、可审计、可热更新的地方。这个思路本身比 Casbin 这个具体实现要值钱得多——哪怕你用 OPA、用 Cedar、自己撸一个,把"模型—策略—执行"三者解耦的方向都是对的。
总结脑图
@startmindmap perm_casbin_mindmap
<style>
node {
BackgroundColor White
}
rootNode {
BackgroundColor #ffe0b2
LineColor #f57c00
LineThickness 4
}
</style>
* PERM 与 Casbin
** PERM 四件套
*** Request — 一次访问的快照
*** Policy — 规则的字段结构
*** Matchers — 布尔表达式 / 顺序影响性能
*** Effect — allow / deny override
** 多租户授权
*** domain 字段隔离租户
*** 三元角色绑定 (人, 角色, 租户)
*** keyMatch2 路径分层匹配
*** 授权 = 加一行 policy + grouping
** 三家选型
*** Casbin — 元模型 + 配置 / 进程内 / 单服务首选
*** OPA — 通用策略 + Rego / sidecar / 跨平台守门
*** OpenFGA — Zanzibar 关系图 / 独立服务 / 细粒度共享
** 落地建议
*** 单服务 RBAC 选 Casbin
*** 跨平台统一选 OPA
*** 细粒度共享选 OpenFGA
*** 策略按业务概念组织 别按 URL 切片
** 避坑清单
*** matcher 顺序
*** 字段都是字符串
*** 多实例用 Watcher
@endmindmap

参考资料
- 论文:PERM: Streamlining Cloud Authorization With Flexible and Scalable Policy Enforcement
- 论文:Zanzibar: Google's Consistent, Global Authorization System
- Casbin 官方文档:https://casbin.apache.org/docs/tutorials/
- Casbin 在线编辑器:https://editor.casbin.org/
- Open Policy Agent:https://www.openpolicyagent.org/
- OpenFGA 官方文档:https://openfga.dev/docs/concepts
- OpenFGA Playground:https://play.fga.dev/