从零搭建并二次开发开源权限管理系统:基于 Casbin 的中级实战指南
很多团队第一次做权限系统时,想法都很朴素:先搞个“用户-角色-菜单”表,接口上再加几段 if 判断,能跑就行。可一旦系统进入多租户、多项目、多环境、细粒度数据权限这些阶段,权限模型会迅速失控:
- 接口权限、菜单权限、按钮权限混在一起
- 不同业务线有不同授权规则
- 超级管理员逻辑写死在代码里
- 临时加白名单、黑名单、数据范围后,判断逻辑越来越绕
- 权限变更后缓存不一致,线上偶发“明明有权限却访问失败”
我见过不少项目,最后不是“没有权限系统”,而是“到处都是权限逻辑”。这才是最难维护的状态。
这篇文章不讲 Casbin 的基础概念堆砌,而是从架构设计和二次开发视角出发,带你搭一套可运行、可扩展、适合中级开发者落地的权限管理系统。重点放在:
- 如何从零设计系统边界
- Casbin 的模型到底该怎么选
- 如何把 RBAC 扩展成更贴近业务的权限平台
- 怎么排查常见问题
- 怎么兼顾安全与性能
背景与问题
在企业系统里,权限管理通常至少包含 4 个层面:
- 身份认证:你是谁
- 功能权限:你能访问哪些页面/接口/按钮
- 数据权限:你能看到哪些数据
- 组织边界:你在哪个租户/部门/项目内生效
传统做法常见有两种:
方案一:纯数据库表驱动
典型就是:
- user
- role
- permission
- user_role
- role_permission
优点是直观,缺点也明显:判断逻辑常常写死在业务代码,无法描述复杂条件。
方案二:代码硬编码权限
例如:
if (user.isAdmin() || user.getDeptId().equals(order.getDeptId())) {
return true;
}
短期快,长期灾难。你会发现权限规则散落在 Controller、Service、SQL、前端菜单里,根本没法统一治理。
为什么选 Casbin
Casbin 的价值不只是“鉴权库”,而是它把权限判断抽象成了三部分:
- 请求定义:谁访问什么资源,做什么动作
- 策略定义:哪些规则允许/拒绝
- 匹配器:如何把请求和策略关联起来
这意味着你可以把很多原本写死在代码里的权限规则,统一沉淀成模型和策略。
方案目标与架构边界
如果我们是从零搭建一个“可二次开发”的开源权限系统,我建议先明确目标,不要一上来就做成“全能权限平台”。
本文的目标范围
我们实现以下能力:
- 基于 Casbin 做接口级 RBAC 鉴权
- 支持用户、角色、策略三类核心实体
- 支持 RESTful 路径匹配
- 支持租户维度隔离
- 预留数据权限扩展点
- 提供一套可运行示例代码
不在本文展开的内容
- OAuth2 / SSO 统一认证
- 前端动态路由完整实现
- 审批流式授权
- 超大规模分布式授权中心
也就是说,这是一套中型业务系统可直接落地的架构骨架,而不是全家桶。
整体架构设计
先看推荐的组件关系。
flowchart LR
A[前端/调用方] --> B[API 网关或应用服务]
B --> C[认证中间件 JWT/Session]
C --> D[权限中间件 Casbin Enforcer]
D --> E[(Policy 存储 DB)]
D --> F[(用户角色关系 DB)]
B --> G[业务服务]
G --> H[(业务数据)]
这里有个关键点:Casbin 不负责认证,只负责授权。
也就是说,JWT、Session、SSO 这些是前置条件,Casbin 接收的是“已识别身份”的请求。
推荐的职责拆分
| 模块 | 职责 |
|---|---|
| Auth 模块 | 登录、发 token、解析用户身份 |
| Permission 模块 | 用户-角色-策略管理 |
| Casbin Enforcer | 运行时鉴权 |
| Admin 后台 | 配置角色、菜单、接口策略 |
| Audit 模块 | 权限变更记录、访问审计 |
为什么这样拆
因为很多项目一开始就把“菜单、接口、组织、字段权限”塞进一个表,最后后台配置页比业务本身还复杂。
更稳妥的做法是:先把运行时鉴权链路打通,再逐步把配置能力收敛到后台。
核心原理
Casbin 的核心不难,但要用好,必须理解几个关键对象。
1. Model:权限模型
权限模型决定了“你用什么语言表达规则”。
一个经典 RBAC 模型长这样:
[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) && r.dom == p.dom && keyMatch2(r.obj, p.obj) && r.act == p.act
这段配置表达的是:
sub:用户或角色主体dom:租户/域obj:资源路径act:动作,如 GET/POST
这里我用了 g = _, _, _,表示带域的 RBAC,适合多租户或多项目隔离。
2. Policy:策略
策略是数据,不是代码。例如:
p, admin, tenant1, /api/users, GET
p, admin, tenant1, /api/users, POST
p, viewer, tenant1, /api/users, GET
g, alice, admin, tenant1
g, bob, viewer, tenant1
含义很直接:
- admin 在 tenant1 下可 GET/POST
/api/users - viewer 在 tenant1 下只能 GET
- alice 属于 tenant1 的 admin
- bob 属于 tenant1 的 viewer
3. Enforcer:执行器
Enforcer 会在运行时做一件事:
给定一个请求
(sub, dom, obj, act),判断是否命中策略。
4. 匹配器决定扩展能力
最容易被低估的其实是 matchers。
你要支持 RESTful 动态路径、部门层级、黑名单优先、临时授权,很多时候都要改匹配器,而不是只改表结构。
权限模型选型与取舍
做中级项目时,不要只知道“RBAC 很常见”,更重要的是知道什么时候不够。
常见模型对比
| 模型 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| ACL | 单资源精确控制 | 直观 | 策略量爆炸 |
| RBAC | 企业后台管理 | 易管理 | 难表达复杂条件 |
| ABAC | 复杂条件授权 | 灵活 | 实现和调试复杂 |
| RBAC with Domain | 多租户/多项目 | 兼顾结构与隔离 | 模型稍复杂 |
对于大多数开源管理后台的二次开发,我更建议这样分阶段:
- 第一阶段:RBAC with Domain
- 第二阶段:加入资源属性判断
- 第三阶段:把数据权限放到业务查询层
原因很现实:
Casbin 能做数据权限判断,但如果你把“部门树、本人数据、部门及子部门数据、项目共享范围”全塞进 matcher,模型会越来越难读,性能也会变差。
更可控的办法是:
- 接口权限:Casbin
- 数据范围过滤:业务层 + SQL/ORM 条件拼装
- 字段脱敏/列权限:响应层处理
核心流程设计
下面看一次典型请求的授权时序。
sequenceDiagram
participant U as 用户
participant A as API服务
participant M as 认证中间件
participant C as Casbin Enforcer
participant D as Policy/Role存储
U->>A: 请求 /api/users GET
A->>M: 解析 JWT
M-->>A: user=alice, tenant=tenant1
A->>C: Enforce(alice, tenant1, /api/users, GET)
C->>D: 读取角色/策略
D-->>C: admin, allow
C-->>A: true
A-->>U: 200 OK
再补一个后台配置视角:
flowchart TD
A[管理员配置角色] --> B[绑定接口策略]
B --> C[绑定用户到角色]
C --> D[写入策略存储]
D --> E[刷新 Enforcer 缓存]
E --> F[新权限生效]
这里的“刷新 Enforcer 缓存”非常关键,我后面会专门讲这个坑。
从零搭建:一个可运行的实战示例
下面我用 Go + Casbin 做一个最小可运行系统。之所以选 Go,是因为 Casbin 在 Go 生态里体验很好,示例也够简洁。
项目结构
casbin-demo/
├── go.mod
├── main.go
├── model.conf
└── policy.csv
安装依赖
mkdir casbin-demo
cd casbin-demo
go mod init casbin-demo
go get github.com/casbin/casbin/v2
go get github.com/gin-gonic/gin
实战代码(可运行)
1. 权限模型: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) && r.dom == p.dom && keyMatch2(r.obj, p.obj) && r.act == p.act
2. 策略文件:policy.csv
p, admin, tenant1, /api/users, GET
p, admin, tenant1, /api/users, POST
p, admin, tenant1, /api/users/:id, GET
p, viewer, tenant1, /api/users, GET
p, viewer, tenant1, /api/users/:id, GET
g, alice, admin, tenant1
g, bob, viewer, tenant1
3. 主程序:main.go
package main
import (
"fmt"
"net/http"
"github.com/casbin/casbin/v2"
"github.com/gin-gonic/gin"
)
type AuthInfo struct {
User string
Tenant string
}
func fakeAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
user := c.GetHeader("X-User")
tenant := c.GetHeader("X-Tenant")
if user == "" || tenant == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing X-User or X-Tenant header",
})
return
}
c.Set("auth", AuthInfo{
User: user,
Tenant: tenant,
})
c.Next()
}
}
func casbinMiddleware(e *casbin.Enforcer) gin.HandlerFunc {
return func(c *gin.Context) {
v, exists := c.Get("auth")
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "auth info not found",
})
return
}
auth := v.(AuthInfo)
obj := c.FullPath()
act := c.Request.Method
ok, err := e.Enforce(auth.User, auth.Tenant, obj, act)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
"error": fmt.Sprintf("enforce error: %v", err),
})
return
}
if !ok {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "permission denied",
"user": auth.User,
"obj": obj,
"act": act,
})
return
}
c.Next()
}
}
func main() {
e, err := casbin.NewEnforcer("model.conf", "policy.csv")
if err != nil {
panic(err)
}
r := gin.Default()
r.Use(fakeAuthMiddleware())
r.Use(casbinMiddleware(e))
r.GET("/api/users", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "list users success",
})
})
r.POST("/api/users", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "create user success",
})
})
r.GET("/api/users/:id", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "get user success",
"id": c.Param("id"),
})
})
r.Run(":8080")
}
4. 启动服务
go run main.go
5. 测试请求
alice 作为 admin,允许 GET
curl -X GET http://localhost:8080/api/users \
-H "X-User: alice" \
-H "X-Tenant: tenant1"
alice 作为 admin,允许 POST
curl -X POST http://localhost:8080/api/users \
-H "X-User: alice" \
-H "X-Tenant: tenant1"
bob 作为 viewer,只允许 GET,不允许 POST
curl -X POST http://localhost:8080/api/users \
-H "X-User: bob" \
-H "X-Tenant: tenant1"
你应该会得到:
{"error":"permission denied","user":"bob","obj":"/api/users","act":"POST"}
二次开发:把“示例”变成“系统”
到这一步只是跑通了 Casbin。接下来才是更接近真实项目的二次开发部分。
二次开发一:从文件策略切到数据库
policy.csv 适合示例,不适合线上。实际项目应使用数据库适配器。
常见表结构可以很简单:
Casbin 策略存储表示例
CREATE TABLE casbin_rule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
ptype VARCHAR(100) NOT NULL,
v0 VARCHAR(100),
v1 VARCHAR(100),
v2 VARCHAR(100),
v3 VARCHAR(100),
v4 VARCHAR(100),
v5 VARCHAR(100)
);
对于本文的模型:
-
p, admin, tenant1, /api/users, GETptype = pv0 = adminv1 = tenant1v2 = /api/usersv3 = GET
-
g, alice, admin, tenant1ptype = gv0 = alicev1 = adminv2 = tenant1
二次开发建议
不要让业务代码直接操作 casbin_rule 表。
更好的方式是封装一层服务:
CreateRoleBindPolicyToRoleAssignUserRoleRemoveUserRoleReloadPolicy
这样你未来要做审计日志、灰度生效、批量导入时,不会推翻整个实现。
二次开发二:菜单权限与接口权限分离
这是我非常建议做的一步。很多开源后台把菜单和接口都当成“permission”,表面统一,实则混乱。
为什么要分离
因为它们的判断维度不同:
- 菜单权限:决定前端显示什么
- 接口权限:决定后端是否放行
菜单权限天然偏 UI 配置;接口权限是安全边界。
前端隐藏按钮不等于后端安全。
推荐做法
- 菜单、按钮树:业务表维护
- 接口鉴权:Casbin
- 角色作为桥梁:同一个角色同时绑定菜单和接口策略
classDiagram
class User {
+id
+name
+tenantId
}
class Role {
+id
+code
+name
}
class Menu {
+id
+path
+type
}
class ApiPolicy {
+sub
+dom
+obj
+act
}
User --> Role : belongs to
Role --> Menu : grants
Role --> ApiPolicy : grants
这样做的好处是:
- 前后端职责清晰
- 菜单结构变化不会影响后端鉴权模型
- 接口权限可以独立审计
二次开发三:数据权限不要硬塞进 matcher
很多人学到 Casbin 后会很兴奋,觉得“那我把部门、项目、创建人、数据状态都丢进 matcher 吧”。
我不建议这么干,至少不要在第一版这么做。
更务实的做法
把数据权限拆成两层:
第一层:Casbin 判断有没有访问这个接口的资格
例如:
alice 是否可以访问 GET /api/orders
第二层:业务层拼数据过滤条件
例如:
- 仅本人数据
- 本部门数据
- 本部门及子部门数据
- 全部数据
示例伪代码:
type DataScope string
const (
Self DataScope = "self"
Dept DataScope = "dept"
DeptAndSub DataScope = "dept_and_sub"
All DataScope = "all"
)
func BuildOrderQuery(userId int64, deptId int64, scope DataScope) string {
switch scope {
case Self:
return fmt.Sprintf("creator_id = %d", userId)
case Dept:
return fmt.Sprintf("dept_id = %d", deptId)
case DeptAndSub:
return fmt.Sprintf("dept_id IN (SELECT id FROM dept_closure WHERE ancestor_id = %d)", deptId)
case All:
return "1=1"
default:
return "1=0"
}
}
为什么这样更合理
因为数据权限通常依赖业务库中的动态数据,而 Casbin 更擅长做规则判断层,不是 SQL 优化器。
常见坑与排查
这一节是我觉得最值钱的部分。很多人不是不会写,而是卡在“明明配置了,为什么不生效”。
1. 请求路径不匹配
现象
策略里写的是:
p, admin, tenant1, /api/users/:id, GET
实际请求却被拒绝。
原因
你拿去匹配的 obj 可能是实际 URL:
/api/users/123
而不是路由模板:
/api/users/:id
排查方法
打印中间件里的 obj:
fmt.Println("obj =", c.FullPath())
在 Gin 里,c.FullPath() 通常比 c.Request.URL.Path 更适合和策略模板匹配。
2. 多租户维度没传对
现象
同一个用户在 A 租户能访问,在 B 租户不能访问,或者反过来串权限。
原因
- token 里 tenant 缺失
- tenant 从请求头传入,但未校验是否合法
- Enforce 时 tenant 参数顺序错了
排查建议
确认 Enforce 的四个参数顺序必须和模型一致:
e.Enforce(sub, dom, obj, act)
我以前就踩过一个坑:把 obj 和 dom 顺序传反了,日志看起来像“偶发失败”,其实是模型参数错位。
3. 修改策略后不生效
现象
后台已经给角色授权,但接口还是 403。
原因
常见是 Enforcer 没有重新加载策略,或者多实例缓存不一致。
排查建议
单机先确认:
err := e.LoadPolicy()
如果是多实例部署,需要考虑:
- 管理后台更新策略后广播通知
- 使用 watcher 同步策略变更
- 本地缓存设置合理过期时间
4. 角色继承链太深
现象
角色 A 继承 B,B 继承 C,C 再继承 D……最后排查权限特别痛苦。
建议
- 控制角色继承层级,最好不超过 2~3 层
- 业务角色与系统角色分开
- 对高风险接口做显式策略,不要全靠继承推导
5. 超级管理员绕过逻辑写死
现象
代码里到处有:
if user == "admin" {
return true
}
风险
- 安全审计不统一
- 权限行为不可追踪
- 后期超管拆分、租户超管、多级管理员时很难演进
更好的做法
把超管也建模为角色或特殊域策略,而不是散落在代码里。
安全最佳实践
权限系统是安全边界,很多问题不在“能不能用”,而在“能不能扛”。
1. 永远以后端鉴权为准
前端菜单、按钮隐藏只是一层体验优化,不是安全措施。
所有关键接口都必须经过后端鉴权。
2. 最小权限原则
默认不给权限,只给必要权限。
例如:
- viewer 只读
- operator 只能执行有限写操作
- admin 也按租户隔离
不要一开始就给“全站读写”。
3. 高风险操作增加二次校验
对于以下接口,不建议只靠 Casbin 一次放行:
- 删除核心数据
- 导出敏感信息
- 修改组织/角色/权限
- 批量操作
可以追加:
- 二次密码确认
- 操作审批
- MFA
- 审计记录
4. 审计日志必须留痕
至少记录:
- 谁改了策略
- 改了什么
- 何时改的
- 变更前后内容
- 从哪个 IP/终端发起
因为权限问题很多不是“出错”,而是“谁改的已经说不清了”。
5. 防止租户越权
如果你的系统是多租户,一定要保证:
- tenant 来源可信,不可被伪造
- 用户所属 tenant 要和 token / session 绑定
- 查询业务数据时再次校验 tenant 条件
不要只在 Casbin 层校验一次,SQL 却没带租户过滤。
性能最佳实践
Casbin 本身不慢,但系统变复杂后,性能瓶颈通常出现在“策略管理方式”上。
1. 控制策略粒度
不要给每个用户都生成大量直接策略:
p, user123, tenant1, /api/a, GET
p, user123, tenant1, /api/b, GET
...
这会让策略数量快速膨胀。
更推荐:
- 用户绑定角色
- 角色绑定策略
也就是优先用 RBAC,少做 user-based ACL。
2. 路径规则要适度抽象
例如:
/api/orders/:id
比给每个 ID 单独配策略更合理。
但也不要过度泛化成:
/api/*
否则策略虽少,风险却高,审计也困难。
3. 预加载与缓存
生产环境建议:
- 服务启动时加载策略
- 策略变更时主动刷新
- 控制刷新频率,避免每次请求读库
如果是高并发场景,可以考虑策略缓存和分布式同步,但前提是一致性方案先设计清楚。
4. 容量估算思路
做架构时,建议粗估三类规模:
- 用户数
- 角色数
- 策略数
例如一个中型后台:
- 用户:1 万
- 角色:100
- 接口策略:2000
- 用户角色关系:2 万
这种规模下,Casbin 完全能胜任。
真正需要重点关注的不是 Enforce 本身,而是:
- 策略加载耗时
- 多实例同步
- 后台配置链路复杂度
5. 把复杂查询留给数据库
如果你的数据权限要判断“部门树 + 项目成员 + 时间区间 + 数据状态”,优先让数据库负责过滤,Casbin 负责“有没有资格访问这个能力”。
一套更稳的落地建议
如果你正准备基于开源项目做二次开发,我建议按下面顺序推进:
第一阶段:先打通最小闭环
- 登录后拿到用户身份
- 所有接口接入 Casbin 中间件
- 跑通用户-角色-策略三元关系
- 支持租户维度
第二阶段:搭权限后台
- 角色管理
- 接口策略管理
- 用户绑定角色
- 策略变更审计
- 一键刷新策略
第三阶段:补业务扩展点
- 菜单权限
- 数据权限
- 批量授权
- 临时授权
- 风险操作保护
第四阶段:做平台化治理
- 权限变更审批
- 多环境配置同步
- 权限巡检
- 冗余权限回收
这个顺序很重要。
不要先做一个“功能丰富的权限后台”,再去想运行时怎么鉴权。应该反过来:先保障核心链路,再建设治理能力。
总结
Casbin 的真正价值,不是帮你少写几个 if,而是把权限逻辑从业务代码中抽离出来,变成可建模、可配置、可审计的系统能力。
如果你要从零搭建并做二次开发,我的建议可以浓缩成 5 句话:
- 先用 RBAC with Domain 跑通主链路,别一开始就追求全能模型。
- 菜单权限和接口权限分离,后端安全边界要独立。
- 数据权限留在业务层做过滤,不要把所有复杂性压进 Casbin matcher。
- 策略变更后的刷新与一致性,要在第一版就考虑。
- 超级管理员也要建模,不要写死绕过逻辑。
如果你的系统规模是中小到中型后台,Casbin 已经足够强大;但边界也要清楚:当授权规则开始强依赖复杂业务属性、实时上下文、审批状态时,单纯依赖 Casbin 就不够了,应该把它作为授权引擎的一部分,而不是全部。
换句话说,Casbin 很适合做权限系统的“核心判定层”,但不是整个权限治理体系的全部。把这个边界想清楚,你的二次开发就会稳很多。