做后端时间长了,每个人迟早都会碰到权限这摊事。一开始可能只是在接口里加个if user == "admin",后来用户多了、角色多了、资源也多了,代码里就到处是权限判断,每次加个角色都要改接口逻辑,还容易漏改出漏洞。我接触Casbin比较晚,第一次看完它的设计就有点拍大腿的感觉,这套东西把“授权”从业务代码里彻底抽出来了:你是谁、你想访问什么、你能干什么,全部用模型和策略文件描述,业务层只管调用一个Enforce方法。这篇文章就围绕我实际用Casbin做RBAC、ABAC的经历展开,讲清楚核心概念、完整上手步骤和踩坑经验。不管你是第一次听说Casbin,还是已经写了一段时间但总在配置上报错,都值得花几分钟看一下。
1. 都说权限难写,难在哪
1.1 先想清楚你写的是“认证”还是“授权”
很多新手在权限这里绕弯,就是因为把认证和授权搅在一起了。认证解决的是“你是谁”的问题,常见手段是登录、JWT、Session;授权解决的是“你能干什么”的问题,常见手段是角色、权限点、资源控制。Casbin只关心后者,它不帮你做登录,也不管密码怎么存,你只需要把已经确认好的用户身份、资源路径、操作方法传给它,它根据规则告诉你通不通过。
可以类比公司门禁:门禁卡验证你能不能进公司大门,这是认证;进了大门之后,你进得去财务室还是只能待在工位区,这才是授权。把这两个逻辑混在一起写,最典型的后果就是:登录态校验和权限校验塞在同一个中间件里,想要给某个接口单独调整访问控制,不可避免地影响登录逻辑,改起来特别容易出问题。
我刚学Casbin的时候犯过一个毛病,总想把它当成一个“登录+权限”全套方案,后来看文档才明白,它连用户表都不管。你在业务里维护好用户和角色,Casbin只管帮你在调用接口前做一道判断题。这个边界一旦清晰了,后面的模型设计就顺了。
1.2 权限模型不是只有一张表的事
在你决定用什么框架之前,先要想清楚权限模型。最常见的三种是ACL、RBAC和ABAC。
ACL(访问控制列表)最简单,直接在用户和权限之间建立对应关系,比如用户A可以访问接口B。小项目这么做很直观,但用户量一大就很痛苦,因为你要给每个用户单独维护权限列表,新增一百个用户就得多几百行关系。RBAC(基于角色的访问控制)是现在后台系统最主流的选择,思路是用户挂角色、角色挂权限,中间多了一层“角色”。你只需要定义好“管理员”“运营”“普通员工”这几个角色,再把用户往角色里一挂,权限全部通过角色来管理,清晰也方便批量维护。ABAC(基于属性的访问控制)更灵活,它不限定于角色,而是基于用户的属性、环境属性、资源属性来动态判断,比如“年龄大于18岁才能看”“只有本人才能修改自己的订单”。
用表格一对比,差别就出来了:
| 模型 | 核心关系 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| ACL | 用户直接绑定权限 | 简单直接 | 用户多时维护成本爆炸 | 极小的内部工具 |
| RBAC | 用户绑定角色,角色绑定权限 | 可维护性好,符合直觉 | 规则多时比较粗粒度 | 管理后台、企业系统 |
| ABAC | 基于属性动态判断 | 灵活,精细 | 设计复杂,难排查 | 开放平台、复杂业务规则 |
我个人的感觉是,大部分业务RBAC都够用了,个别地方再叠加一点属性判断就够了,不要一上来就整个复杂的ABAC,否则规则一多,排查起来会怀疑人生。
1.3 为什么我最终选了Casbin
我也考虑过自己写权限逻辑,毕竟看起来不就是几张表加几个if嘛。但真正做了才发现,权限系统隐藏的复杂点太多了:角色继承怎么处理、通配符怎么匹配、策略怎么热更新、多个应用之间怎么复用同一套权限模型。这些如果全部自己造轮子,花费的时间绝对不少,而且边界情况特别容易漏。
Casbin把核心逻辑都封装好了,我只需要做三件事:写模型文件描述规则、写策略文件描述授权数据、在代码里调用Enforce。它有几个点特别对我胃口。第一是跨语言,模型文件和策略文件的语法基本是通用的,从Go切到Java再到Python,理念一致,团队里不同语言的项目可以共用同一套权限设计文档。第二是模型和策略分离,模型是“规则长什么样”,策略是“具体给谁授权”,改权限通常只需要动策略,不一定动代码。第三是内置函数丰富,像keyMatch、globMatch这些,允许我用通配符和后缀匹配来做资源路径匹配,不用自己写正则。
后面我会用实际案例展示这个决策有多省心。
2. 拆开Casbin:模型、策略、效果器三层结构
2.1 一眼看懂 model.conf
Casbin整个系统的核心是模型文件,一般叫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看着是不是很像一个配置文件?但这里面每一段都有明确的职责。request_definition定义了调用Enforce时传入的参数格式,这里的r = sub, obj, act表示每次判断必须给三个东西:主体(sub)、资源(obj)、操作(act)。policy_definition则定义了策略文件中的字段格式,p = sub, obj, act表示策略里的每条规则也是主体、资源、操作三个字段。
role_definition定义了角色关系函数,g = _, _是最常用的结构,表示g(u, r)意思是用户u属于角色r,两个下划线是占位符。如果你用多租户环境,可以写成g = _, _, _,第三个位置放租户ID,我后面会讲。policy_effect是整个模型里很容易被忽略但又很重要的部分,它决定了多条策略同时命中后怎么汇总结果。e = some(where (p.eft == allow))的意思是“有任意一条策略允许就通过”,这是大多数后台系统想要的效果:只要有一条规则说你可以,那就放行。
最后是matchers,这里是真正的判断逻辑,把请求参数、策略参数关联起来。上面这个模型里,g(r.sub, p.sub)表示请求的主体是否拥有策略里要求的主体角色,r.obj == p.obj和r.act == p.act就是资源、操作完全匹配。这部分写得好不好,直接决定权限设计的灵活程度。
2.2 policy.csv 里的数据长什么样
有了模型文件之后,还需要具体的数据,也就是给谁授权。最常规的方式是用CSV文件保存策略数据,每一行就是一条授权规则。比如:
p, role:admin, /api/user, GET p, role:admin, /api/user, POST p, role:admin, /api/user, DELETE p, role:employee, /api/user, GET g, alice, role:admin g, bob, role:employee这里前缀p表示“策略”,前缀g表示“角色关系”。第一行读作:给角色role:admin授予对/api/user资源执行GET操作的权限。最后的g, alice, role:admin表示用户alice属于role:admin角色。如果你习惯用数据库存权限,也可以把策略表导出来,或者用Casbin的Adapter从数据库加载,原理一样。
你可能好奇,为什么p和g要单独写?因为它们是两类完全不同的数据:p定义的是“某个角色能做什么”,g定义的是“谁属于哪个角色”,两者需要通过模型里的matchers结合。理解了这一点,很多配置上的困惑就解开了。
2.3 授权决策的完整链路
Casbin执行一次判断的过程,其实就是把模型和策略串起来跑一遍。假设现在用户alice请求GET /api/user,整个链路是这样的:
第一步,根据request_definition把请求参数包装成r = alice, /api/user, GET。第二步,遍历所有策略规则,这里会先看g关系,确定alice拥有哪些角色,得到她有role:admin。第三步,对于每条p规则,依次执行matcher,检查g(r.sub, p.sub)是否成立、资源是否匹配、操作是否匹配。命中之后,该规则的结果是allow。第四步,交给policy_effect汇总,只要有一条allow,最终结果就返回true;如果全部都不匹配,就返回false。
这个过程可以用一个生活例子来记:你去某个机构办事,前台有一份访客名单,名单上有“哪个部门的人能进哪个办公室”的安排。你要做的事就是:“你是谁?你要去哪个办公室?你要在里面干什么?”然后接待人员一条一条核对,只要有某一条名单允许你进去,你就通过了。
所以我后来看别人的权限问题,第一件事不是看代码,而是看他model.conf和policy.csv是怎么写的,因为九成的问题都在配置上而不在代码上。
3. 动手撸一个RBAC:代码一步步来
3.1 建模型:给员工和部门配上角色
理论说了一堆,不如直接动手。我以最常见的员工管理系统为例,设定这样一个业务场景:系统里有用户管理和订单管理两块资源,角色分成role:admin、role:manager、role:employee三级。admin能操作所有接口,manager能查看和修改订单接口,employee只能查自己的信息。
那么我的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) && (globMatch(r.obj, p.obj) || r.obj == p.obj) && (globMatch(r.act, p.act) || r.act == p.act)这里我用globMatch来做资源路径匹配,它可以支持/api/user/list这样的精确匹配,也可以支持/api/user/*这样的通配匹配,一举两得。你可能会问,既然globMatch能处理精确匹配,为什么还写r.obj == p.obj?其实globMatch内部对非通配字符串就是做精确比较,所以这个双保险是可有可无的,写出来是为了可读性。实际项目里我更习惯只用globMatch,看起来简洁。
3.2 初始化 Enforcer:写 Go 代码
我拿Go举例,因为这是Casbin最流行的使用场景,其他语言比如Java、Python、Node.js的API设计高度相似,看一遍就能迁移。
先创建两个文件,一个叫model.conf存上面那段模型,一个叫policy.csv存策略:
p, role:admin, /api/*, * p, role:manager, /api/order/*, GET p, role:manager, /api/order/*, PUT p, role:employee, /api/user/self, GET g, alice, role:admin g, bob, role:manager g, carol, role:employee注意这里role:admin用/api/*和*配合globMatch,相当于通吃了所有资源和所有操作。然后写一个简单的校验程序:
package main import ( "fmt" "log" "github.com/casbin/casbin/v2" ) func main() { e, err := casbin.NewEnforcer("./model.conf", "./policy.csv") if err != nil { log.Fatal(err) } tests := [][]string{ {"alice", "/api/user/list", "GET"}, {"alice", "/api/order/123", "DELETE"}, {"bob", "/api/order/123", "GET"}, {"bob", "/api/user/list", "GET"}, {"carol", "/api/user/self", "GET"}, {"carol", "/api/order/123", "GET"}, } for _, t := range tests { ok, err := e.Enforce(t[0], t[1], t[2]) if err != nil { log.Printf("enforce error: %v", err) continue } fmt.Printf("%s | %s | %s => %v\n", t[0], t[1], t[2], ok) } }运行结果应该是:
alice | /api/user/list | GET => true alice | /api/order/123 | DELETE => true bob | /api/order/123 | GET => true bob | /api/user/list | GET => false carol | /api/user/self | GET => true carol | /api/order/123 | GET => false几个关键点我想多说一句。e.Enforce(t[0], t[1], t[2])这里传参的顺序和你model.conf里request_definition定义的字段顺序严格一致,r = sub, obj, act所以依次传主体、资源、操作。如果你模型里定义的是r = sub, act, obj,那调用的时候顺序也变了,这个最容易踩坑。
3.3 角色继承和超级管理员怎么配置
角色继承是RBAC里很常见的设计。比如公司里管理员天然拥有经理的权限,经理天然拥有员工的权限,那我不用给管理员重复配置所有经理的策略,只要在policy.csv里多加几行g关系:
g, role:admin, role:manager g, role:manager, role:employee这时候alice如果是role:admin,那么她在判断时会被认为同时拥有role:manager和role:employee的角色,所以admin会不知不觉地继承下面两层角色绑定的所有权限。这个机制特别实用,它避免了很多重复的授权数据。
超级管理员一般有一个惯用配置:不给它列一堆细权限,而是直接用通配符匹配所有资源和操作。因为模型里用了globMatch,策略可以这样写:
p, role:super_admin, *, **就代表任意资源和任意操作。这里有一个容易误会的点:*不是简单的字符串,而是globMatch里的通配符模式,它会匹配任何内容。所以super_admin这个角色天然就拥有整个系统的最高权限,不用再手动添加任何接口级别的授权。
我个人在实际使用中有一个体会:不要把所有角色都设计成通配,尽量只把“超级管理员”这种角色做成通配,其他角色还是明确列出能访问的资源和操作。因为通配符一旦和角色继承叠加,排查权限问题的时候会变得很累,你根本不知道某个常规角色到底能访问什么。
4. 进阶玩法:ABAC、多租户和中间件集成
4.1 用 ABAC 按属性授权,告别粗暴的“角色即权限”
有些业务里,RBAC会显得太粗。比如“员工能查看自己的工单”这种需求,用RBAC表达很别扭,你不能给每个员工都建一个角色。这时候ABAC就派上用场了。Casbin支持在request_definition中直接传入一个结构体对象,然后在matchers里引用对象的属性。
拿员工管理来举例,我希望员工的年龄大于等于18岁才能访问某类资源,模型可以这样写:
[request_definition] r = sub, obj, act [policy_definition] p = obj, act [policy_effect] e = some(where (p.eft == allow)) [matchers] m = r.sub.Age >= 18 && globMatch(r.obj, p.obj) && globMatch(r.act, p.act)注意,这里policy_definition里没有sub字段了,因为主体的判断不再依赖角色,而是依赖属性。在Go代码里,请求对象可以这么定义:
type User struct { Name string Age int } func main() { e, _ := casbin.NewEnforcer("./abac_model.conf", "./abac_policy.csv") ok, _ := e.Enforce(User{Name: "tom", Age: 17}, "/api/adult", "GET") fmt.Println(ok) // false }这样就能做到“按属性动态授权”,写起来很直观。不过要提醒一下,ABAC的规则一旦多了,整个matchers会变得非常难读和难调试,我建议只在个别需要属性判断的模块局部使用ABAC,整体还是以RBAC为主,这样既灵活又不失控。
4.2 多租户数据隔离怎么写
多租户系统里,同一个用户在不同租户下可能有不同的角色。比如alice在tenant1是管理员,在tenant2可能只是普通员工。这种场景使用带租户域的RBAC模型,模型文件改动很小:
[request_definition] r = sub, dom, obj, act [role_definition] g = _, _, _ [policy_effect] e = some(where (p.eft == allow)) [matchers] m = g(r.sub, p.sub, r.dom) && globMatch(r.obj, p.obj) && globMatch(r.act, p.act)这里的request_definition里就多了一个dom字段,role_definition变成了g = _, _, _,第三个位置就是租户域。策略数据相应多一列租户:
p, role:admin, /api/user, GET p, role:employee, /api/user, GET g, alice, role:admin, tenant1 g, alice, role:employee, tenant2调用Enforce时也要把租户传进去:
ok, _ := e.Enforce("alice", "tenant1", "/api/user", "GET") // true ok2, _ := e.Enforce("alice", "tenant2", "/api/user", "POST") // false这么做的好处很明显,同一套用户账号体系,在不同的业务空间里可以做到完全隔离的权限控制。实际落地时需要注意,策略数据里一定要确保每个g关系都带上了租户字段,不然很容易出现跨租户越权访问,这是多租户权限里面最严重的坑。
4.3 对接 Gin 中间件的实战
知道原理之后,接入Web框架就顺理成章了。我最常用的是Gin,所以拿Gin举例。整体思路是:先有全局唯一的Enforcer实例,然后在请求进入处理函数之前,通过中间件把请求里的用户信息、请求路径、请求方法传给Enforcer,判断不通过就直接拒绝。
一个最简单的Gin中间件长这样:
package main import ( "net/http" "github.com/casbin/casbin/v2" "github.com/gin-gonic/gin" ) var enforcer *casbin.Enforcer func Auth() gin.HandlerFunc { return func(c *gin.Context) { user := c.GetString("user") path := c.Request.URL.Path method := c.Request.Method ok, err := enforcer.Enforce(user, path, method) if err != nil { c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "权限判断异常"}) return } if !ok { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "没有访问权限"}) return } c.Next() } } func main() { var err error enforcer, err = casbin.NewEnforcer("./model.conf", "./policy.csv") if err != nil { panic(err) } r := gin.Default() // 登录后把用户信息放进 context r.Use(func(c *gin.Context) { c.Set("user", "alice") c.Next() }) r.Use(Auth()) r.GET("/api/user/list", func(c *gin.Context) { c.JSON(200, gin.H{"data": "user list"}) }) _ = r.Run(":8080") }实际项目里,c.GetString("user")不会写死,而是从JWT或者Session里解析出来的。我只做了最简单的示例,核心想表达的是:Casbin和中间件的结合点只是“读取身份、构造请求、调用Enforce、处理结果”这四步,剩下的事情交给框架就行。
另外注意Enforcer实例的创建一定要是全局的,千万不要在每个请求里执行NewEnforcer。因为加载模型和策略涉及文件读取和内存构建,这个开销不算小,如果高并发下每来一个请求就初始化一个实例,性能会非常难看,而且容易把连接池打爆。
5. 常见问题排查与性能优化实录
5.1 权限不生效?这几个坑最常见
用Casbin时间久了,我发现大多数排查问题并不复杂,基本集中在“模型写错”“策略缺失”“匹配规则不对”这三大类。我把几个最典型的坑整理成了一张表:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 永远返回false | 模型里的matchers条件太严格,或者request字段没对应上 | 先打印Enforce入参,再逐步简化matcher测试 |
| 角色继承没生效 | 策略里缺少g关系,或者role_definition里忘记声明 | 用GetRolesForUser看某个用户到底有哪些角色 |
通配符*不生效 | matcher用的是直接等于==,不是通配匹配函数 | 改成globMatch或keyMatch2 |
| 新增策略后还是不生效 | Enforcer有内置缓存,旧策略还在内存里 | 调用LoadPolicy()重新加载,或者重启进程 |
| 自定义函数报undefined | matcher里用了函数但没有在代码中注册 | 用AddFunction注册自定义函数 |
我印象最深的是有一次,权限判断在测试环境怎么都不通过,查了一下午,最后发现是policy.csv文件里多了一个空格,导致globMatch匹配不上。这种问题非常隐蔽,建议所有策略文件都经过严格的格式检查,或者干脆在管理后台用API去添加策略,尽量别直接改CSV。
5.2 匹配顺序和性能问题
性能是我一开始比较担心的问题,因为权限判断是在每个请求上执行的,如果本身很慢,整个服务的响应时间都会受影响。实测下来Casbin本身非常轻量,在策略数量几千条的规模下,单次Enforce基本是微秒级别,远不需要做缓存层。
但有一个性能杀手:每次请求都新建Enforcer实例。前面说过,NewEnforcer要读模型、读策略、构建匹配器,这个开销比单次Enforce大一两个数量级。所以无论你用什么框架,一定要把Enforcer设计成单例或复用对象。
另一个问题是策略数量膨胀。虽然Enforce性能好,但如果策略表无限制增长,尤其是角色关系爆炸式增加,每次匹配要遍历的策略条数也会增加。这时候可以通过两种手段优化:一是利用Casbin内置的角色继承,把同类的权限合并到角色上,而不是给每个用户单独建规则;二是根据实际业务把不需要全局生效的策略拆开,比如多租户场景下可以按租户加载策略,而不是把所有租户的策略一次性灌进去。
5.3 排查工具与方法
排查权限问题最好的工具就是Casbin自带的管理API和日志。把日志打开能非常清楚地看到每一条规则匹配的过程,推荐在开发环境直接开启:
e.EnableLog(true)这样Enforce执行的时候,控制台会打印出Request定义、策略规则、匹配结果,几乎不用再去靠猜。如果日志还解决不了,那就用API把当前的用户权限拉出来看:
roles, _ := e.GetRolesForUser("alice") perms, _ := e.GetPermissionsForUser("alice") fmt.Println(roles) fmt.Println(perms)这两个方法会直接告诉你“用户有哪些角色”“角色有哪些权限”,很快就能定位是角色没绑上,还是权限没配好。我遇到很多初学者的排查思路是改代码、加日志、重启进程,绕了一大圈,其实只需要先看一眼GetRolesForUser的输出,问题就已经非常清楚了。
另外,Casbin支持动态修改策略,我比较推荐把权限管理做成后台功能,需要加权限的时候通过API添加策略,不要直接改文件。这样不仅减少手动配置出错的可能,也方便在操作层面留审计日志。权限是安全底线,任何变更都最好有记录。
写在最后的一个实际建议
如果你刚开始在项目里引入Casbin,我建议不要急着堆复杂的模型和花哨的ABAC功能。先把RBAC跑通,把“模型+策略+Enforcer”这条链路理顺,等业务上确实碰到了粗粒度角色解决不了的问题,再想着加属性判断、加租户域。这样整个系统的复杂度是慢慢涨上来的,排查问题的时候心理压力也小很多。还有一个小技巧:模型文件和策略文件一定要纳入版本管理。权限模型会随着业务演进,需要经常回顾和审查,有个历史版本方便追溯“上次改了哪里导致权限不对”,真的能节省大量时间。这套流程我在好几个项目里反复用过,算是经过验证的稳妥路线。