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 有两条 gwriterreader),这就是「一个用户多角色」——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✅ trueg(alice, reader) 成立,命中 p, reader, data, read
alice, data, write❌ falsealice 不是 writer;g(alice, reader) 虽成立,但 act 是 write≠read,不匹配
alice, data, delete❌ falsealice 不是 admin
bob, data, write✅ trueg(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 是「认证过了但没权限」。

几个要点与坑

  1. g 是自反的g(reader, reader) 为真。所以 sub 传角色名也能匹配到策略。别把用户输入直接当 sub
  2. Enforce 的参数必须和 request_definition 严格一一对应(个数、顺序、类型)。r = sub, obj, act 就必须传三个,漏一个直接报错或结果错乱。
  3. policy_effect 决定默认行为some(where (p.eft == allow)) 是「无命中即拒绝」;如果业务需要「黑名单优先」,得用 !some(where (p.eft == deny)) 的写法。
  4. 角色继承用 g 的嵌套即可,如 g, admin, editor 表示 admin 继承 editor。不必为继承另建模型。
  5. NewEnforcer 有加载开销,不要每个请求都新建;生产里应做成单例,策略改动用 watcher / LoadPolicy() 热更新,而不是重启服务。
  6. stub:RBAC 不是越细越好。权限粒度太细(细到每个接口一个权限)会让策略表膨胀到没人看得懂;通常按「资源 × 动作」这一层就够,再上面的复杂度应该靠角色继承消化。
  7. 认证与授权不要混。RBAC 只回答「你能干什么」,它不能证明你是谁——签名校验、token 验签该做还得做,顺序是「先认证拿身份,再授权查权限」。

小结

  • 认证管「你是谁」,授权管「你能干什么」,RBAC 是授权的一种模型
  • RBAC 的核心:用户 → 角色 → 权限,中间那层角色让权限可批量、可审计、可复用。
  • Casbin 把 RBAC 拆成 model(怎么判定)+ policy(谁有什么)+ request(这次行不行),实现「逻辑与数据分离」。
  • 模型的核心就是那行 m = g(r.sub, p.sub) && r.obj == p.obj && r.act == p.act主体靠 g 走角色(含继承),对象与动作精确匹配