跳转到内容
123xiao | 无名键客

《从零搭建并二次开发开源权限管理系统:基于 Casbin 的中级实战指南》

字数: 0 阅读时长: 1 分钟

从零搭建并二次开发开源权限管理系统:基于 Casbin 的中级实战指南

很多团队第一次做权限系统时,想法都很朴素:先搞个“用户-角色-菜单”表,接口上再加几段 if 判断,能跑就行。可一旦系统进入多租户、多项目、多环境、细粒度数据权限这些阶段,权限模型会迅速失控:

  • 接口权限、菜单权限、按钮权限混在一起
  • 不同业务线有不同授权规则
  • 超级管理员逻辑写死在代码里
  • 临时加白名单、黑名单、数据范围后,判断逻辑越来越绕
  • 权限变更后缓存不一致,线上偶发“明明有权限却访问失败”

我见过不少项目,最后不是“没有权限系统”,而是“到处都是权限逻辑”。这才是最难维护的状态。

这篇文章不讲 Casbin 的基础概念堆砌,而是从架构设计和二次开发视角出发,带你搭一套可运行、可扩展、适合中级开发者落地的权限管理系统。重点放在:

  • 如何从零设计系统边界
  • Casbin 的模型到底该怎么选
  • 如何把 RBAC 扩展成更贴近业务的权限平台
  • 怎么排查常见问题
  • 怎么兼顾安全与性能

背景与问题

在企业系统里,权限管理通常至少包含 4 个层面:

  1. 身份认证:你是谁
  2. 功能权限:你能访问哪些页面/接口/按钮
  3. 数据权限:你能看到哪些数据
  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多租户/多项目兼顾结构与隔离模型稍复杂

对于大多数开源管理后台的二次开发,我更建议这样分阶段:

  1. 第一阶段:RBAC with Domain
  2. 第二阶段:加入资源属性判断
  3. 第三阶段:把数据权限放到业务查询层

原因很现实:
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, GET

    • ptype = p
    • v0 = admin
    • v1 = tenant1
    • v2 = /api/users
    • v3 = GET
  • g, alice, admin, tenant1

    • ptype = g
    • v0 = alice
    • v1 = admin
    • v2 = tenant1

二次开发建议

不要让业务代码直接操作 casbin_rule 表。
更好的方式是封装一层服务:

  • CreateRole
  • BindPolicyToRole
  • AssignUserRole
  • RemoveUserRole
  • ReloadPolicy

这样你未来要做审计日志、灰度生效、批量导入时,不会推翻整个实现。


二次开发二:菜单权限与接口权限分离

这是我非常建议做的一步。很多开源后台把菜单和接口都当成“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)

我以前就踩过一个坑:把 objdom 顺序传反了,日志看起来像“偶发失败”,其实是模型参数错位。


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 句话:

  1. 先用 RBAC with Domain 跑通主链路,别一开始就追求全能模型。
  2. 菜单权限和接口权限分离,后端安全边界要独立。
  3. 数据权限留在业务层做过滤,不要把所有复杂性压进 Casbin matcher。
  4. 策略变更后的刷新与一致性,要在第一版就考虑。
  5. 超级管理员也要建模,不要写死绕过逻辑

如果你的系统规模是中小到中型后台,Casbin 已经足够强大;但边界也要清楚:当授权规则开始强依赖复杂业务属性、实时上下文、审批状态时,单纯依赖 Casbin 就不够了,应该把它作为授权引擎的一部分,而不是全部。

换句话说,Casbin 很适合做权限系统的“核心判定层”,但不是整个权限治理体系的全部。把这个边界想清楚,你的二次开发就会稳很多。


分享到:

上一篇
《中级实战:用 RAG 构建企业知识库问答系统的架构设计与性能优化》
下一篇
《Java Web 开发中基于 Spring Boot + Redis 实现接口幂等性的实战方案》