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✅ 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 走角色(含继承),对象与动作精确匹配。