后台系统最难的不是 CRUD:用 ShiyuAdmin 看懂 RBAC 权限设计

很多人第一次写后台管理系统的时候,会觉得最麻烦的是:
- 用户管理
- 部门管理
- 菜单管理
- 增删改查
- 分页查询
但真正把系统做大之后,你会发现:
CRUD 反而是最简单的。
后台系统真正难处理的是一个问题:
谁,可以在什么情况下,访问什么资源?
比如:
张三可以查看用户列表,但不能删除用户。
李四可以修改订单,但是看不到系统配置。
财务人员能看到财务菜单,但普通员工连这个菜单都不应该出现。
超级管理员则拥有全部权限。
这些需求如果一开始没有设计好,后面业务越多,权限判断就会越来越乱。
最近我在整理自己的开源后台项目 ShiyuAdmin 时,也重新梳理了一遍后台权限系统。
ShiyuAdmin 本身是一个基于:
Go
Gin
Gorm
JWT
Redis
React
Ant Design Pro
实现的前后端分离后台管理系统,并且内置用户、角色、菜单、部门、动态菜单和接口权限等能力。
项目地址:
https://github.com/Rodert/ShiyuAdmin
今天不讲 CRUD。
我们专门聊聊一个后台管理系统里的核心:
RBAC 权限到底应该怎么设计。
一、最简单的权限系统为什么不够用?
假设我们刚开始写一个后台。
最直接的方法是什么?
可能是在代码里面判断:
if user.Role == "admin" {
// 可以操作
}
稍微复杂一点:
if user.Role == "admin" || user.Role == "manager" {
// 可以删除用户
}
项目小的时候完全没问题。
但是业务一多,很快就会变成:
if user.Role == "admin" ||
user.Role == "manager" ||
user.Role == "operator" {
// ...
}
然后另外一个接口又写一遍。
if user.Role != "guest" {
// ...
}
再过几个月,你自己都不知道:
manager
operator
admin
super_admin
到底分别能干什么。
问题的根源在于:
角色和具体操作绑定得太死了。
所以成熟一点的后台系统通常不会直接问:
你是不是管理员?
而是问:
你有没有
system:user:delete这个权限?
这就进入了 RBAC。
二、RBAC 到底是什么?
RBAC:
Role-Based Access Control
中文一般叫:
基于角色的访问控制。
核心关系其实非常简单:
用户
↓
角色
↓
权限
例如:
王仕宇
↓
系统管理员
↓
system:user:list
system:user:create
system:user:update
system:user:delete
另外一个用户:
小明
↓
运营人员
↓
system:user:list
system:user:update
这样删除用户这个能力就不需要写:
role == "admin"
而是统一判断:
HasPermission("system:user:delete")
这一步非常重要。
因为从这里开始:
角色只负责组织权限,权限才真正决定用户能做什么。
三、ShiyuAdmin 是怎么拆这个关系的?
ShiyuAdmin 的权限体系可以简单理解为:
User
↓
UserRole
↓
Role
↓
RoleMenu
↓
Menu
↓
Perms
也就是:
用户
↓
用户角色关系
↓
角色
↓
角色菜单关系
↓
菜单/按钮
↓
权限标识
项目里已经把 user_role、role_menu、role、menu 等能力拆成独立 Repository / Service,而不是把所有权限逻辑都塞进 Controller。
假设菜单表里存在这样一些权限:
system:user:list
system:user:add
system:user:update
system:user:delete
system:role:list
system:role:update
那么一个角色只需要绑定对应菜单和权限。
例如:
普通管理员
system:user:list
system:user:add
system:user:update
超级管理员:
*
或者直接通过超级管理员标记绕过普通权限判断。
四、第一层:JWT 解决“你是谁”
权限判断之前必须先解决一个问题:
当前请求是谁发出来的?
ShiyuAdmin 使用 JWT 做登录认证。
整个请求大概是:
客户端
↓
Authorization: Bearer xxx
↓
Auth Middleware
↓
解析 JWT
↓
得到 UserCode / Role / Session
↓
写入 Gin Context
项目中的 Auth 中间件会从:
Authorization: Bearer TOKEN
读取 Token。
Token 验证通过以后,把解析出的 Claims 放进 Gin Context:
c.Set("currentUser", claims)
后面的业务代码就不需要重新解析 Token 了。与此同时,项目还会根据 Token 计算 sessionID,并通过 SessionValidator 判断当前会话是否已经被注销。
于是请求链变成:
Request
↓
JWT Middleware
↓
currentUser
↓
Permission Middleware
↓
Controller
这就是一个很典型的认证与授权分离。
五、认证和授权千万不要混为一谈
很多刚开始写权限系统的人容易把两个概念混在一起:
Authentication
Authorization
它们实际上完全不同。
Authentication:
你是谁?
Authorization:
你能干什么?
例如:
JWT 验证成功
只能说明:
这个用户登录了
但并不能说明:
这个用户可以删除其他用户
所以请求应该经过两层:
JWT Authentication
↓
Permission Authorization
也就是:
先认证
再鉴权
这是后台权限系统最基本的一条边界。
六、第二层:Permission Middleware 解决“你能干什么”
ShiyuAdmin 把接口权限检查放到了 Gin Middleware。
思路可以简化为:
func RequirePermission(
permissionService PermissionService,
permission string,
) gin.HandlerFunc {
return func(c *gin.Context) {
user := GetCurrentUser(c)
if user.IsSuperAdmin {
c.Next()
return
}
ok, err := permissionService.CheckPermission(
c.Request.Context(),
user.UserCode,
permission,
)
if err != nil {
c.Abort()
return
}
if !ok {
c.Abort()
return
}
c.Next()
}
}
实际项目中的 RequirePermission 会先从 Context 中取得 JWT Claims,然后判断超级管理员,再调用 PermissionService 查询用户是否拥有指定权限。
如果不存在权限,则返回 HTTP:
403 Forbidden
项目还分别实现了:
RequirePermission
RequireAnyPermission
RequireAllPermissions
RequireSuperAdmin
因此可以支持“必须拥有一个权限”“拥有任意一个即可”“必须同时拥有多个权限”等不同场景。
这一点其实非常实用。
七、Any Permission 和 All Permissions 有什么区别?
例如某个接口:
只需要满足:
订单管理员
或者
超级客服
中的任意一个权限。
就可以使用:
RequireAnyPermission(
permissionService,
[]string{
"order:update",
"order:admin",
},
)
逻辑是:
A OR B
而有些敏感接口可能要求:
既拥有审核权限
又拥有财务权限
则可以使用:
RequireAllPermissions(
permissionService,
[]string{
"finance:audit",
"finance:approve",
},
)
逻辑变成:
A AND B
所以一个成熟的权限系统并不只有:
HasPermission()
最好能够表达:
ANY
ALL
SUPER ADMIN
这些不同的授权语义。
八、用户权限最终是怎么查出来的?
ShiyuAdmin 的 Permission Service 逻辑很典型。
第一步:
根据用户获取角色。
类似:
roles, err := userRoleRepo.GetUserRoles(
ctx,
userCode,
)
第二步:
遍历角色。
for _, role := range roles {
}
第三步:
获取角色拥有的菜单。
menus, err := roleMenuRepo.GetRoleMenus(
ctx,
role.RoleCode,
)
第四步:
提取菜单中的权限标识:
menu.Perms
最后去重:
permsMap := make(map[string]bool)
得到:
[
"system:user:list",
"system:user:add",
"system:user:update",
"system:role:list"
]
ShiyuAdmin 当前的 Permission Service 正是按照“用户 → 角色 → 菜单 → Perms”的路径汇总权限,同时过滤被禁用的角色和菜单,再进行去重。
这个结构的好处在于:
权限来源非常清晰。
九、为什么权限最好挂在 Menu 上?
这里还有一个很有意思的设计问题。
很多后台管理系统都会把:
菜单
按钮
权限
放在一套资源模型里面。
例如:
系统管理
└── 用户管理
├── 查看
├── 新增
├── 修改
└── 删除
对应:
system:user:list
system:user:add
system:user:update
system:user:delete
这样前端和后端就可以共享一套权限模型。
前端决定:
按钮显示不显示
后端决定:
接口允许不允许调用
例如前端:
{hasPermission('system:user:delete') && (
<Button danger>
删除
</Button>
)}
后端:
router.DELETE(
"/users/:id",
RequirePermission(
permissionService,
"system:user:delete",
),
userController.Delete,
)
两边使用同一个:
system:user:delete
整个权限系统就统一了。
十、但前端隐藏按钮绝对不等于权限控制
这里一定要特别强调。
很多后台系统有一个非常严重的问题:
前端做了:
if (!hasPermission) {
return null
}
于是开发者就以为安全了。
实际上用户完全可以绕过前端。
直接调用:
curl -X DELETE \
http://localhost:18000/api/v1/system/users/100
或者用:
Postman
Apifox
Burp Suite
浏览器 DevTools
直接请求接口。
所以:
前端权限控制解决的是用户体验,后端权限控制解决的才是安全问题。
正确架构应该是:
权限
│
┌──────┴──────┐
↓ ↓
前端 后端
↓ ↓
控制按钮显示 控制接口访问
二者缺一不可。
十一、权限系统为什么需要 Redis?
如果每一个请求都查询:
用户
↓
用户角色
↓
角色
↓
角色菜单
↓
菜单
↓
权限
数据库压力会非常大。
假设首页同时发送:
20 个 API 请求
每个 API 都重新查询权限。
那么一次打开页面可能会产生几十次甚至上百次数据库访问。
所以 ShiyuAdmin 又在 Permission Service 外增加了一层 Redis 缓存。
缓存 Key 的设计类似:
user:perms:v1:{userCode}
例如:
user:perms:v1:10001
Value:
[
"system:user:list",
"system:user:add",
"system:user:update"
]
查询过程变成:
请求
↓
Redis
↓
命中?
├── YES → 直接返回权限
│
└── NO
↓
查询数据库
↓
生成权限列表
↓
写 Redis
项目中的 CachedService 正是这样包装基础 Permission Service,并使用 TTL 保存用户权限列表。
十二、缓存权限以后,真正困难的问题来了
权限缓存并不是加一个 Redis 就结束了。
真正困难的问题叫:
缓存失效。
假设:
用户 A
↓
管理员角色
↓
拥有删除用户权限
Redis 中已经缓存:
system:user:delete
现在管理员把这个权限取消了。
数据库已经修改。
但是 Redis 里面还存在:
system:user:delete
那么在缓存过期之前:
这个用户仍然可能拥有删除权限。
这就是典型的:
缓存与数据库一致性
问题。
ShiyuAdmin 当前已经提供:
InvalidateUserCache()
用于删除单个用户权限缓存;而角色级缓存失效部分目前仍采用比较简化的方案,源码中的注释也明确提到,可以继续通过维护“角色 → 用户”的映射,或者使用 SCAN 等方式完善角色权限变更后的批量失效。
实际上这正好是一个非常值得继续优化的地方。
十三、生产环境可以怎么进一步优化?
如果让我继续完善这套权限缓存,我会考虑建立:
Role → Users
映射。
例如:
role:users:admin
里面保存:
10001
10002
10003
当:
admin
角色权限发生变化时:
找到这些用户:
10001
10002
10003
然后逐个删除:
user:perms:v1:10001
user:perms:v1:10002
user:perms:v1:10003
这样就可以做到:
修改角色权限
↓
找到角色用户
↓
删除对应权限缓存
↓
下一次请求重新计算
这比单纯等待 TTL 过期更加可靠。
十四、再进一步:给权限增加版本号
还有一种思路。
为用户或者角色维护:
PermissionVersion
例如:
user_perm_version = 12
JWT 或缓存记录:
version = 11
发现:
11 != 12
说明权限发生过变化。
直接重新加载。
于是权限缓存可以从:
user:perms:10001
变成:
user:perms:12:10001
版本号变化后:
旧缓存自动失效。
ShiyuAdmin 当前的 CachedService 已经在 Key 中加入了:
v1
这样的缓存版本字段。
这其实已经为后续做整个权限缓存版本升级留下了空间。
十五、权限命名也非常重要
我比较推荐:
模块:资源:动作
例如:
system:user:list
system:user:create
system:user:update
system:user:delete
再比如订单:
order:order:list
order:order:create
order:order:refund
商品:
product:goods:list
product:goods:create
product:goods:update
product:goods:delete
不要写:
permission1
user1
admin_edit
几个月以后基本没人知道什么意思。
一个好的权限字符串应该做到:
不用看文档也能大概知道它控制什么。
十六、一个完整请求到底经历了什么?
现在把整个流程串起来。
用户访问:
DELETE /api/v1/system/users/100
请求头:
Authorization: Bearer eyJ...
第一步:
Auth Middleware
解析:
JWT
获得:
UserCode = 10001
第二步:
Permission Middleware
要求:
system:user:delete
第三步:
查询:
Redis
得到:
[
"system:user:list",
"system:user:update",
"system:user:delete"
]
第四步:
判断:
system:user:delete
存在。
于是:
PASS
继续执行:
UserController.Delete()
如果不存在:
直接返回:
403 Forbidden
整个 Controller 根本不会执行。
所以一个比较清晰的后台权限架构应该是:
HTTP Request
↓
JWT Authentication
↓
Current User
↓
Permission Middleware
↓
Redis Permission Cache
↓
RBAC Permission Service
↓
Controller
↓
Service
↓
Repository
↓
Database
十七、为什么我更推荐“权限中间件”这种方案?
你当然也可以在 Controller 里面写:
func DeleteUser(c *gin.Context) {
if !HasPermission(...) {
return
}
}
但这样写久了以后:
每个 Controller 都会存在大量重复代码。
例如:
checkPermission()
checkRole()
checkAdmin()
checkLogin()
最后业务代码和权限代码混在一起。
而 Middleware 的好处就是:
Controller
只负责:
业务
MiddleWare:
认证 / 权限 / 日志 / Trace
Service:
业务规则
Repository:
数据库
每层职责非常明确。
这也是为什么在 ShiyuAdmin 的后端结构里,可以看到独立的:
middleware
repository
service
api
model
这些目录,而不是把所有代码全部堆在 Handler 里面。
十八、后台系统真正值得学习的地方
很多人学习后台项目,第一反应是:
登录怎么写?
分页怎么写?
CRUD 怎么写?
这些当然重要。
但当你真正开始设计一个可以长期维护的系统以后,更值得研究的其实是:
认证怎么设计
权限怎么设计
角色怎么设计
数据权限怎么设计
缓存怎么设计
操作日志怎么设计
会话注销怎么设计
接口边界怎么设计
这些东西才决定了:
这个后台到底只是一个 Demo,
还是一个真正能够继续扩展的系统。
ShiyuAdmin 目前除了用户、角色、菜单和接口权限,还包含部门、数据管理、Redis 缓存管理、系统监控、操作日志以及数据仪表盘等模块,并支持 PostgreSQL、MySQL、SQLite 等部署方式。
如果你正在学习:
Go
Gin
Gorm
JWT
Redis
RBAC
React
Ant Design Pro
可以直接把代码拉下来跑一遍。
有时候看十篇 RBAC 教程,
不如真正跟着一个项目,把:
用户
↓
角色
↓
菜单
↓
权限
↓
JWT
↓
Middleware
↓
Redis
完整跑通一次。
你会发现:
后台管理系统真正有意思的地方,才刚刚开始。
项目地址:
https://github.com/Rodert/ShiyuAdmin
作者:王仕宇 / JavaPub

浙公网安备 33010602011771号