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"——云端服务多租户、多角色、多场景,需求一天一个变,把变化收敛到配置层,代码层稳如老狗,运维和安全团队也能直接改策略,不用每次都拉开发陪跑。

三、一个真实场景:给租户里的角色授权某个资源的某个操作

光说 data1data2 太抽象,咱换个云上密钥管理的真实例子。资源路径长这样:

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.confpolicy.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 Casbin Mindmap

参考资料