RBAC 与 Casbin
先把一对概念分开:认证 vs 授权
访问控制里最容易混的一对概念先分开:
- 认证(Authentication):管「你是谁」——登录、验证签名、校验 token,产出的是一个「身份」。
- 授权(Authorization / Access Control):管「你能干什么」——拿到身份之后,判断这个身份能不能做这件事。
RBAC 属于后者。本仓库前面讲过的「Ethereum 验证签名」「JWT 校验」都属于认证;这一章讲的 RBAC 是它们之后的那一步。
RBAC 是什么
RBAC = Role-Based Access Control,基于角色的访问控制。
核心思想一句话:不直接给人发权限,而是把权限打包成「角色」,再把角色发给用户。
为什么要在中间隔一层「角色」
如果直接给用户发权限(alice: [read, write]、bob: [read]),会立刻遇到几个问题:
- 组合爆炸:1000 个用户 × 50 项权限,映射关系是 N×M;每次调整都要逐人改。
- 无法批量:新来 10 个运营,要手工给 10 个人勾同样的权限。
- 调岗即重配:人从「内容编辑」转「审核」,权限要一条条减一条条加。
- 审计困难:想知道「有没有人越权」,得逐人核对长列表。
- 语义丢失:权限列表里看不到「这个人是什么岗位」,只有一堆零散动作。
引入角色后,N×M 的映射被拆成两段:用户 → 角色(N×1)和 角色 → 权限(1×M)。
没有角色: alice ─────────┬──► read
├──► write
bob ───────────┼──► read
└──► ... (关系随人增多而膨胀)
有角色: alice ──► reader ──► read
bob ──► writer ──► write (改权限只动角色这一层)
好处是:给用户加权限 = 加一个角色;改一批人的权限 = 改角色对应的权限;审计时看「谁是什么角色」就够了。
RBAC 的三个要素(外加一层继承)
| 要素 | 英文 | 含义 |
|---|---|---|
| 用户 | User / Subject | 谁在发起访问 |
| 角色 | Role | 权限的集合,也是权限的分配单位 |
| 权限 | Permission | 对某个对象做某个操作,如 data → read |
| 角色继承 | Role Hierarchy | 角色可以「包含」角色(RBAC1),如 admin 继承 editor |
注意:Casbin 里
g规则负责的正是角色分配与继承,所以「用户 → 角色」和「角色 → 角色」用的是同一套语法。
Casbin:把 RBAC 落成配置
Casbin 是一个「访问控制模型框架」,它把 RBAC 拆成三样东西:
| 概念 | 作用 | 对应截图区域 |
|---|---|---|
| Model(模型) | 描述「怎么判定」,与业务无关 | 中间「模型」面板 |
| Policy(策略) | 描述「谁拥有什么」,随业务变化 | 右上「策略」面板 |
| Request(请求) | 一次具体的「想干什么」 | 左下「请求」面板 |
关键在于模型与策略分离:判定逻辑写在 model 里(一般很少动),业务数据写在 policy 里(可以放数据库、随时改、热更新)。同一套 model,换一份 policy 就是另一套权限体系。
下面是 Casbin 官方在线编辑器里的一个完整 RBAC 例子(编辑器界面):

(在线编辑器地址:https://casbin.org/editor/。截图里右下角显示 Node-Casbin 5.37.0——各语言绑定语法一致,Go 版就是 github.com/casbin/casbin/v2。)
模型面板逐行拆解
[request_definition]
r = sub, obj, act # 请求三元组:主体(谁) / 对象(操作什么) / 动作(怎么操作)
[policy_definition]
p = sub, obj, act # 策略三元组,与请求同构,便于逐字段比较
[role_definition]
g = _, _ # 角色定义函数 g:前两个下划线是「用户, 角色」两个参数
[policy_effect]
e = some(where (p.eft == allow)) # 只要有一条策略命中且为 allow 就放行
[matchers]
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act
三个字段的含义:
g = _, _声明了一个叫g的角色判定函数。g(r.sub, p.sub)问的是:「r.sub是否拥有p.sub这个角色(含继承)」。这正是 RBAC 的落点——请求里的主体和策略里的主体不需要相等,只要存在角色关系即可。r.obj == p.obj && r.act == p.act:对象和动作要精确相等。想让它们也变模糊(比如/api/user/123匹配/api/user/*),就换成一个自定义函数。e = some(where (p.eft == allow))是「命即放行」的语义。注意它的默认行为是只认 allow:没有任何策略命中时结果是false(拒绝);要支持「显式 deny 优先」,得改成!some(where (p.eft == deny))这类写法。
截图左上角还有个「自定义函数」面板,定义了
my_func(arg1, arg2) = arg1.endsWith(arg2)。它就是给matchers准备的:把r.obj == p.obj换成my_func(r.obj, p.obj),就能用「后缀匹配」做对象判定(例如按资源前缀分组授权)。模型不够用时,靠自定义函数扩展,而不是去改 Casbin 源码。
策略面板
p, reader, data, read
p, writer, data, write
p, admin, data, delete
g, alice, reader
g, bob, writer
g, bob, reader
g, cathy, admin
p行是「角色 → 权限」:p, reader, data, read表示「reader 角色可以对 data 执行 read」。g行是「用户 → 角色」:g, bob, writer表示 bob 拥有 writer 角色。- bob 有两条
g行(writer和reader),这就是「一个用户多角色」——RBAC 允许,且 Casbin 天然支持,多条g是「或」的关系。
角色继承图(截图左下)
alice ──► reader ──► data:read
bob ──► writer ──► data:write
reader ──► data:read
cathy ──► admin ──► data:delete
这张图就是上面 g 行与 p 行的可视化:用户连到角色,角色连到权限。alice 只挂了 reader,bob 同时挂了 writer 和 reader,cathy 挂了 admin。
请求与结果
请求面板列出的四行,按上面的模型逐条判定:
| 请求 (sub, obj, act) | 结果 | 命中原因 |
|---|---|---|
alice, data, read | ✅ true | g(alice, reader) 成立,命中 p, reader, data, read |
alice, data, write | ❌ false | alice 不是 writer;g(alice, reader) 虽成立,但 act 是 write≠read,不匹配 |
alice, data, delete | ❌ false | alice 不是 admin |
bob, data, write | ✅ true | g(bob, writer) 成立,命中 p, writer, data, write |
如果把 bob, data, read 也拿去问,结果同样是 true——因为 bob 还有 g, bob, reader。一个用户多角色,任一角色命中即放行。
一个小知识点:截图「执行结果」面板里出现了
{"reader","data","read"}这样的请求——角色名本身也能当 subject 传入,因为g是自反的:g(reader, reader)成立(一个角色当然拥有它自己)。这提醒我们:Enforce 的sub传角色名不会报错,但也意味着别把用户可控的输入直接当 sub 传。
用 Go 跑起来
模型存成 model.conf:
[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, reader, data, read
p, writer, data, write
p, admin, data, delete
g, alice, reader
g, bob, writer
g, bob, reader
g, cathy, admin
这个
model.conf不是启动时必须的——它只是「Model 的来源」之一。Casbin 启动只需要一个model.Model对象:你可以从文件读(上面这种),也可以用 Go 代码在内存里构造出完全等价的模型,二者二选一。
模型也能直接用 Go 代码构造(无需 model.conf 文件)
如果不想维护 .conf 文件,可以用 model.NewModel() + AddDef 把同样的模型在代码里搭出来:
import "github.com/casbin/casbin/v2/model"
func CasbinModel() model.Model {
m := model.NewModel()
m.AddDef("r", "r", "sub, obj, act")
m.AddDef("p", "p", "sub, obj, act")
m.AddDef("g", "g", "_, _")
m.AddDef("e", "e", "some(where (p.eft == allow))")
m.AddDef("m", "m", "g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act || r.sub == \"root\"")
return m
}
AddDef(section, key, value) 的每个调用,都精确对应 model.conf 里的一行:
| 代码 | 等价的 model.conf |
|---|---|
m.AddDef("r", "r", ...) | [request_definition] r = ... |
m.AddDef("p", "p", ...) | [policy_definition] p = ... |
m.AddDef("g", "g", ...) | [role_definition] g = ... |
m.AddDef("e", "e", ...) | [policy_effect] e = ... |
m.AddDef("m", "m", ...) | [matchers] m = ... |
注意这里 matchers 比前面的 model.conf 多了一段 || r.sub == "root":
g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act || r.sub == "root"
这是一句「超级管理员兜底」——只要 sub 是 "root",无论角色、对象、动作是什么,一律放行。它逻辑上等价于「root 是上帝角色」,但写在 matchers 里而非 policy 里,所以不占 policy 表、也绕过了角色体系。使用时务必小心:任何把 sub 设成 "root" 的请求都会通过授权,因此必须保证 sub 来自可信的认证结果、"root" 不会被普通用户伪造。
把代码模型接到 Enforcer(策略来源换成任意 adapter,不一定要文件):
import (
"github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v2/model"
fileadapter "github.com/casbin/casbin/v2/file-adapter"
)
func main() {
a := fileadapter.NewAdapter("policy.csv") // 生产可换成 gormadapter / redisadapter 等
e, err := casbin.NewEnforcer(CasbinModel(), a)
if err != nil {
panic(err)
}
// 之后用法与文件版完全一致:e.Enforce("alice", "data", "read")
}
NewEnforcer 的第一个参数是「模型(怎么判定)」,第二个参数就是「策略存储适配器(policy 存在哪)」——也就是你自定义的 NewAdapter() 返回的那个 persist.BatchAdapter。它的职责只有一件:读写 policy 表(即 p 行和 g 行那批规则)。具体发生两件事:
- 初始化时:
NewEnforcer会调用adapter.LoadPolicy(),把数据库里的所有p/g规则拉进 Enforcer 的内存策略表(所以你不必再手动LoadPolicy)。之后Enforce判定只读内存、不每次查库,因此很快。 - 运行时:
e.AddPolicy(...)/e.AddRoleForUser(...)/e.RemovePolicy(...)这类写操作,会经BatchAdapter批量落回数据库;e.LoadPolicy()则可手动触发一次全量刷新(常用于「管理员在后台改了权限、想立刻生效」,或配合 watcher 自动刷新)。
对照你贴的 adapter 实现:timeout 是每次 MongoDB 读写的 context 超时(防止慢库把鉴权链路拖死),filtered: false 表示它加载全部策略而非按租户过滤的子集(多租户需要 LoadFilteredPolicy 时才把 filtered 置为 true)。
模型与适配器分工一句话:模型决定「怎么判」,适配器决定「规则从哪来、改了存到哪」;二者都齐了,Enforcer 才完整。
两种方式怎么选?模型(怎么判定)几乎不变,写在
model.conf文件里最省事、也方便非 Go 同学(运维/安全)核对;策略(谁有什么)经常变,应放进数据库由 adapter 加载。代码构造模型适合「模型需要随配置动态拼装」的场景。无论哪种,最终喂给NewEnforcer的都是「一个 Model + 一个 Adapter」。
Go 侧只需要一个 Enforcer:
package main
import (
"fmt"
"github.com/casbin/casbin/v2"
)
func main() {
e, err := casbin.NewEnforcer("model.conf", "policy.csv")
if err != nil {
panic(err)
}
// 通常策略来自数据库而非文件;这里演示运行时增删
e.AddPolicy("reader", "data", "read")
e.AddRoleForUser("alice", "reader") // 用户 → 角色
e.AddRoleForUser("bob", "writer") // 一个用户可以有多个角色
e.AddRoleForUser("bob", "reader")
requests := [][]any{
{"alice", "data", "read"},
{"alice", "data", "write"},
{"alice", "data", "delete"},
{"bob", "data", "write"},
{"bob", "data", "read"},
}
for _, r := range requests {
ok, err := e.Enforce(r...)
if err != nil {
fmt.Printf("%v => error: %v\n", r, err)
continue
}
fmt.Printf("%-6v %-6v %-8v => %v\n", r[0], r[1], r[2], ok)
}
}
输出:
alice data read => true
alice data write => false
alice data delete => false
bob data write => true
bob data read => true
在业务里通常封装成一个中间件,把「已认证的身份」当作 sub 传进来:
func Authorize(e *casbin.Enforcer, obj, act string) fiber.Handler {
return func(c *fiber.Ctx) error {
sub, ok := c.Locals("address").(string) // 认证阶段放进去的身份
if !ok {
return c.SendStatus(fiber.StatusUnauthorized)
}
allowed, err := e.Enforce(sub, obj, act)
if err != nil {
return c.SendStatus(fiber.StatusInternalServerError)
}
if !allowed {
return c.SendStatus(fiber.StatusForbidden) // 403,不是 401
}
return c.Next()
}
}
用的时候:app.Delete("/api/data/:id", Authorize(enforcer, "data", "delete"), handler)。
这里正好把认证与授权串起来:认证中间件产出身份(c.Locals("address")),授权中间件消费这个身份去问 Casbin「能不能删」。403 vs 401 的区别也是这个道理——401 是「没认证/认证失败」,403 是「认证过了但没权限」。
几个要点与坑
g是自反的:g(reader, reader)为真。所以 sub 传角色名也能匹配到策略。别把用户输入直接当sub。Enforce的参数必须和request_definition严格一一对应(个数、顺序、类型)。r = sub, obj, act就必须传三个,漏一个直接报错或结果错乱。policy_effect决定默认行为。some(where (p.eft == allow))是「无命中即拒绝」;如果业务需要「黑名单优先」,得用!some(where (p.eft == deny))的写法。- 角色继承用
g的嵌套即可,如g, admin, editor表示 admin 继承 editor。不必为继承另建模型。 NewEnforcer有加载开销,不要每个请求都新建;生产里应做成单例,策略改动用 watcher /LoadPolicy()热更新,而不是重启服务。- stub:RBAC 不是越细越好。权限粒度太细(细到每个接口一个权限)会让策略表膨胀到没人看得懂;通常按「资源 × 动作」这一层就够,再上面的复杂度应该靠角色继承消化。
- 认证与授权不要混。RBAC 只回答「你能干什么」,它不能证明你是谁——签名校验、token 验签该做还得做,顺序是「先认证拿身份,再授权查权限」。
小结
- 认证管「你是谁」,授权管「你能干什么」,RBAC 是授权的一种模型。
- RBAC 的核心:用户 → 角色 → 权限,中间那层角色让权限可批量、可审计、可复用。
- Casbin 把 RBAC 拆成 model(怎么判定)+ policy(谁有什么)+ request(这次行不行),实现「逻辑与数据分离」。
- 模型的核心就是那行
m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act:主体靠g走角色(含继承),对象与动作精确匹配。