RBAC 权限系统到底怎么设计?从用户、角色、菜单到接口权限完整拆解

后台管理系统做到后面,基本绕不开一个东西:
权限。
刚开始写项目的时候,我们可能只会做一个非常简单的判断:
if user.ID != 1 {
return errors.New("没有权限")
}
稍微复杂一点:
if user.Role != "admin" {
return errors.New("没有权限")
}
再往后,系统里开始出现:
- 超级管理员
- 系统管理员
- 财务
- 运营
- 客服
- 普通员工
- 部门负责人
然后需求继续增加:
财务可以查看订单,但不能删除订单。
客服可以查看用户,但不能修改用户。
部门负责人只能看到自己部门的数据。
普通员工不能进入系统管理。
管理员可以给角色重新分配菜单和按钮权限。
这个时候,如果继续在代码里写:
if role == "admin" || role == "manager" {
// ...
}
系统很快就会失控。
所以后台管理系统基本都会引入:
RBAC
Role-Based Access Control
基于角色的访问控制
我自己的开源后台项目 ShiyuAdmin,目前也是基于 RBAC 来完成用户、角色、菜单和接口权限控制。
这篇文章就从一个真实后台项目出发,把一套 RBAC 权限系统完整拆开。
一、RBAC 到底解决什么问题?
先不要急着看代码。
我们先看最原始的权限设计。
假设系统有 1000 个用户:
张三
李四
王五
赵六
...
系统有 100 个权限:
查看用户
新增用户
修改用户
删除用户
查看角色
新增角色
修改角色
删除角色
查看订单
修改订单
退款订单
...
最直接的设计是:
用户
↓
权限
比如:
张三
├── 查看用户
├── 修改用户
├── 查看订单
└── 修改订单
李四:
李四
├── 查看用户
├── 查看订单
└── 退款订单
理论上可以。
但是问题很快就来了。
假设公司有:
200 个客服
这 200 个人权限完全一样。
难道你要给每个人分别配置:
查看用户
查看订单
处理工单
显然不合理。
于是角色出现了。
二、RBAC 最核心的一层关系
RBAC 最经典的设计:
User
↓
Role
↓
Permission
即:
用户
↓
角色
↓
权限
比如:
张三
↓
客服
↓
查看用户
查看订单
处理工单
李四也是:
李四
↓
客服
↓
查看用户
查看订单
处理工单
这样,200 个客服只需要:
200 个用户
↓
绑定同一个“客服角色”
以后客服需要增加一个权限:
导出订单
只需要:
客服角色
+
导出订单
200 个用户全部自动获得。
这就是 Role 存在的价值。
三、真实后台系统一般不是三张表,而是五张表
很多 RBAC 教程会画:
用户
角色
权限
但真正落到数据库,至少需要:
sys_users
sys_roles
sys_menus
sys_user_roles
sys_role_menus
关系:
sys_user_roles
User ───────────────────── Role
│
│ sys_role_menus
↓
Menu
│
↓
Permission
为什么需要两张关联表?
因为:
一个用户
可以拥有多个角色
同时:
一个角色
可以拥有多个用户
所以:
User ↔ Role
是多对多。
角色和权限也是:
Role ↔ Permission
多对多。
四、先设计 User
一个后台用户可以设计成:
type User struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
UserCode string `gorm:"size:32;uniqueIndex"`
Username string `gorm:"size:64;uniqueIndex"`
Nickname string `gorm:"size:64"`
Password string `gorm:"size:255" json:"-"`
DeptCode string `gorm:"size:32"`
Status int `gorm:"default:1"`
IsSuperAdmin bool `gorm:"default:false"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
这里有两个字段非常重要。
Status
Status int
例如:
1 = 正常
0 = 禁用
登录的时候:
if user.Status != 1 {
return nil, errors.New("账号已停用")
}
也就是说:
账号禁用和权限不足是两回事。
一个用户即使拥有:
system:user:list
system:user:create
system:user:update
如果:
status = 0
仍然不能登录。
五、为什么要单独设计 IsSuperAdmin?
ShiyuAdmin 里面还有:
IsSuperAdmin bool
很多后台系统都会有一个特殊账号:
超级管理员
如果按照普通 RBAC 思路实现超级管理员,需要给它绑定:
所有角色
或者
所有权限
比如系统有 500 个权限,那么数据库可能需要维护 500 条授权关系。
以后新增:
system:ai:model:delete
还得再给超级管理员补一条。
其实没必要。
超级管理员本质上应该是:
绕过 RBAC 权限计算
所以可以直接:
if claims.IsSuperAdmin {
c.Next()
return
}
这是一种非常实用的设计。
六、角色表应该怎么设计?
Role 可以设计成:
type Role struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
RoleCode string `gorm:"size:32;uniqueIndex"`
RoleName string `gorm:"size:64"`
Sort int
Status int `gorm:"default:1"`
DataScope int `gorm:"default:1"`
Remark string `gorm:"size:255"`
CreatedAt time.Time
UpdatedAt time.Time
}
比如:
RoleCode
admin
RoleName
系统管理员
或者:
RoleCode
finance
RoleName
财务人员
或者:
RoleCode
customer_service
RoleName
客服人员
注意:
最好使用:
RoleCode
而不是数据库自增 ID 作为业务标识。
例如:
ROLE001
ROLE002
或者:
admin
finance
operator
都可以。
七、最重要的问题:Permission 到底存在哪里?
很多系统会专门建一张:
sys_permissions
但后台管理系统还有另外一种很常见的实现:
菜单本身同时承载权限。
也就是说:
Menu
不仅表示菜单
还可以表示按钮或者接口权限
例如菜单表:
type Menu struct {
ID int64 `gorm:"primaryKey"`
MenuCode string `gorm:"size:32;uniqueIndex"`
ParentCode string `gorm:"size:32"`
Name string `gorm:"size:64"`
Path string `gorm:"size:255"`
Component string `gorm:"size:255"`
Icon string `gorm:"size:64"`
MenuType int
Perms string `gorm:"size:128"`
Sort int
Visible int
Status int
CreatedAt time.Time
UpdatedAt time.Time
}
最重要的是:
Perms string
比如:
system:user:list
或者:
system:user:create
或者:
system:user:update
或者:
system:user:delete
这个字符串,就是整个权限系统真正运行时使用的东西。
八、权限标识应该怎么设计?
推荐格式:
模块:资源:动作
比如:
system:user:list
system:user:create
system:user:update
system:user:delete
角色:
system:role:list
system:role:create
system:role:update
system:role:delete
菜单:
system:menu:list
system:menu:create
system:menu:update
system:menu:delete
订单:
order:order:list
order:order:create
order:order:update
order:order:refund
也可以简化:
order:list
order:create
order:update
order:refund
关键不是格式一定要完全一致。
关键是:
整个系统必须统一。
不要一会儿:
system:user:add
一会儿:
user:create
又来一个:
USER_ADD
以后权限维护会非常痛苦。
九、为什么 Menu 可以同时表示菜单、页面和按钮?
假设后台左侧有:
系统管理
用户管理
角色管理
菜单管理
用户管理页面内部还有:
新增
修改
删除
导出
数据库里可以全部看成 Menu。
通过:
MenuType
区分。
例如:
1 = 目录
2 = 菜单
3 = 按钮
于是数据库大概是:
系统管理
type = 1
用户管理
type = 2
新增用户
type = 3
perms = system:user:create
修改用户
type = 3
perms = system:user:update
删除用户
type = 3
perms = system:user:delete
这就非常有意思了。
角色分配的其实不是简单:
页面
而是一棵完整的权限树。
十、User 和 Role 为什么一定要使用关联表?
用户角色表:
type UserRole struct {
ID int64 `gorm:"primaryKey"`
UserCode string `gorm:"size:32;index"`
RoleCode string `gorm:"size:32;index"`
}
数据库:
sys_user_roles
例如:
| user_code | role_code |
|---|---|
| U001 | admin |
| U002 | finance |
| U003 | customer_service |
| U003 | operator |
这里说明:
U003
同时属于:
customer_service
operator
也就是说:
一个用户可以有多个角色。
十一、为什么用户可以拥有多个角色?
假设张三既是:
客服
又负责:
内容运营
那么:
张三
├── customer_service
└── operator
最终权限应该是两个角色权限的:
并集
假设客服:
system:user:list
order:list
ticket:list
运营:
article:list
article:create
article:update
那么张三最终拥有:
system:user:list
order:list
ticket:list
article:list
article:create
article:update
这也是后面 PermissionService 要做的核心事情。
十二、Role 和 Menu 也需要关联表
角色菜单关系:
type RoleMenu struct {
ID int64 `gorm:"primaryKey"`
RoleCode string `gorm:"size:32;index"`
MenuCode string `gorm:"size:32;index"`
}
例如:
finance
↓
MENU_ORDER_LIST
finance
↓
BTN_ORDER_EXPORT
finance
↓
BTN_ORDER_REFUND
客服:
customer_service
↓
MENU_USER_LIST
customer_service
↓
MENU_ORDER_LIST
这样角色权限就完全数据驱动了。
十三、整个 RBAC 的数据库关系终于出来了
最后:
sys_users
↓
sys_user_roles
↓
sys_roles
↓
sys_role_menus
↓
sys_menus
↓
perms
也就是说,要判断:
张三有没有 system:user:update?
逻辑是:
张三
↓
查询张三的角色
↓
查询这些角色拥有的菜单
↓
提取菜单的 perms
↓
有没有 system:user:update
十四、Repository 怎么设计?
先定义:
type UserRoleRepository interface {
GetUserRoles(
ctx context.Context,
userCode string,
) ([]*Role, error)
SetUserRoles(
ctx context.Context,
userCode string,
roleCodes []string,
) error
}
角色菜单:
type RoleMenuRepository interface {
GetRoleMenus(
ctx context.Context,
roleCode string,
) ([]*Menu, error)
SetRoleMenus(
ctx context.Context,
roleCode string,
menuCodes []string,
) error
}
权限 Service 并不直接依赖:
*gorm.DB
而是依赖 Repository。
十五、查询用户角色怎么实现?
GORM 版本可以这样:
func (r *userRoleRepository) GetUserRoles(
ctx context.Context,
userCode string,
) ([]*Role, error) {
var roles []*Role
err := r.db.
WithContext(ctx).
Table("sys_roles r").
Select("r.*").
Joins(`
INNER JOIN sys_user_roles ur
ON ur.role_code = r.role_code
`).
Where(
"ur.user_code = ?",
userCode,
).
Where(
"r.status = ?",
1,
).
Find(&roles).
Error
if err != nil {
return nil, err
}
return roles, nil
}
相当于:
SELECT r.*
FROM sys_roles r
INNER JOIN sys_user_roles ur
ON ur.role_code = r.role_code
WHERE ur.user_code = ?
AND r.status = 1;
十六、查询角色权限怎么实现?
func (r *roleMenuRepository) GetRoleMenus(
ctx context.Context,
roleCode string,
) ([]*Menu, error) {
var menus []*Menu
err := r.db.
WithContext(ctx).
Table("sys_menus m").
Select("m.*").
Joins(`
INNER JOIN sys_role_menus rm
ON rm.menu_code = m.menu_code
`).
Where(
"rm.role_code = ?",
roleCode,
).
Where(
"m.status = ?",
1,
).
Find(&menus).
Error
if err != nil {
return nil, err
}
return menus, nil
}
对应 SQL:
SELECT m.*
FROM sys_menus m
INNER JOIN sys_role_menus rm
ON rm.menu_code = m.menu_code
WHERE rm.role_code = ?
AND m.status = 1;
十七、PermissionService 才是 RBAC 的核心
有了:
UserRoleRepository
和:
RoleMenuRepository
就可以实现:
type PermissionService struct {
userRoleRepo UserRoleRepository
roleMenuRepo RoleMenuRepository
}
创建:
func NewPermissionService(
userRoleRepo UserRoleRepository,
roleMenuRepo RoleMenuRepository,
) *PermissionService {
return &PermissionService{
userRoleRepo: userRoleRepo,
roleMenuRepo: roleMenuRepo,
}
}
十八、获取一个用户的全部权限
完整逻辑:
func (s *PermissionService) GetUserPermissions(
ctx context.Context,
userCode string,
) ([]string, error) {
// 1. 查询用户所有角色
roles, err :=
s.userRoleRepo.GetUserRoles(
ctx,
userCode,
)
if err != nil {
return nil, err
}
// 使用 Map 自动去重
permissionSet :=
make(map[string]struct{})
// 2. 遍历所有角色
for _, role := range roles {
if role == nil {
continue
}
// 被禁用的角色不生效
if role.Status != 1 {
continue
}
// 3. 查询角色拥有的菜单/权限
menus, err :=
s.roleMenuRepo.GetRoleMenus(
ctx,
role.RoleCode,
)
if err != nil {
return nil, err
}
// 4. 提取 perms
for _, menu := range menus {
if menu == nil {
continue
}
if menu.Status != 1 {
continue
}
perm :=
strings.TrimSpace(
menu.Perms,
)
if perm == "" {
continue
}
permissionSet[perm] =
struct{}{}
}
}
// 5. Map 转 []string
permissions :=
make(
[]string,
0,
len(permissionSet),
)
for perm :=
range permissionSet {
permissions =
append(
permissions,
perm,
)
}
return permissions, nil
}
为什么使用:
map[string]struct{}
而不是直接 append?
因为:
角色 A
system:user:list
角色 B
system:user:list
用户同时拥有两个角色。
最终权限应该是:
system:user:list
而不是重复两次。
十九、真正判断权限其实非常简单
有了用户权限集合之后:
func (
s *PermissionService,
) CheckPermission(
ctx context.Context,
userCode string,
required string,
) (bool, error) {
permissions, err :=
s.GetUserPermissions(
ctx,
userCode,
)
if err != nil {
return false, err
}
for _, permission :=
range permissions {
if permission == required {
return true, nil
}
}
return false, nil
}
核心就是一句:
permission == required
RBAC 看起来复杂。
其实真正核心就是:
用户
→ 角色
→ 权限集合
→ 判断权限集合里有没有目标权限
二十、不要在每个 Handler 里自己判断权限
最差的写法:
func DeleteUser(c *gin.Context) {
userCode :=
GetCurrentUserCode(c)
ok, err :=
permissionService.
CheckPermission(
c,
userCode,
"system:user:delete",
)
if err != nil {
// ...
return
}
if !ok {
c.JSON(
403,
gin.H{
"message": "权限不足",
},
)
return
}
// 删除用户
}
如果有:
100 个接口
那权限判断就要复制 100 遍。
所以一定要做成:
Middleware
二十一、Gin 权限 Middleware 怎么写?
func RequirePermission(
permissionSvc PermissionService,
permission string,
) gin.HandlerFunc {
return func(c *gin.Context) {
// 获取 Auth Middleware
// 放入 Context 的 Claims
value, exists :=
c.Get("current_user")
if !exists {
c.AbortWithStatusJSON(
http.StatusUnauthorized,
gin.H{
"message": "未登录",
},
)
return
}
claims, ok :=
value.(*Claims)
if !ok {
c.AbortWithStatusJSON(
http.StatusUnauthorized,
gin.H{
"message": "登录信息无效",
},
)
return
}
// 超管直接放行
if claims.IsSuperAdmin {
c.Next()
return
}
// 只要求登录
if permission == "" {
c.Next()
return
}
allowed, err :=
permissionSvc.
CheckPermission(
c.Request.Context(),
claims.UserCode,
permission,
)
if err != nil {
c.AbortWithStatusJSON(
http.StatusInternalServerError,
gin.H{
"message": "权限检查失败",
},
)
return
}
if !allowed {
c.AbortWithStatusJSON(
http.StatusForbidden,
gin.H{
"message": "权限不足",
},
)
return
}
c.Next()
}
}
以后路由就变成:
router.GET(
"/users",
RequirePermission(
permissionSvc,
"system:user:list",
),
ListUsers,
)
新增:
router.POST(
"/users",
RequirePermission(
permissionSvc,
"system:user:create",
),
CreateUser,
)
修改:
router.PUT(
"/users/:code",
RequirePermission(
permissionSvc,
"system:user:update",
),
UpdateUser,
)
删除:
router.DELETE(
"/users/:code",
RequirePermission(
permissionSvc,
"system:user:delete",
),
DeleteUser,
)
这样 Router 本身就变成了一个权限说明书。
二十二、为什么接口不要判断“角色”?
比如不要这么写:
RequireRole("admin")
然后:
RequireRole("manager")
因为业务最终真正关心的是:
这个人能不能删除用户?
而不是:
这个人是不是管理员?
今天可能:
管理员
可以删除用户。
明天产品经理可能要求:
安全审计员
也能删除用户。
如果代码写:
if role == "admin"
就必须改代码。
如果接口只判断:
system:user:delete
那么只需要后台给:
安全审计员
角色增加:
system:user:delete
代码完全不用改。
所以非常重要的一条原则是:
角色负责组织权限,接口真正验证的是 Permission。
二十三、一个接口需要多个权限怎么办?
真实业务里经常会出现。
例如:
system:user:update
或者:
system:user:admin
任何一个都可以修改用户。
这时候需要:
RequireAnyPermission()
实现:
func RequireAnyPermission(
permissionSvc PermissionService,
required []string,
) gin.HandlerFunc {
return func(c *gin.Context) {
claims :=
GetCurrentClaims(c)
if claims == nil {
c.AbortWithStatus(
http.StatusUnauthorized,
)
return
}
if claims.IsSuperAdmin {
c.Next()
return
}
permissions, err :=
permissionSvc.
GetUserPermissions(
c.Request.Context(),
claims.UserCode,
)
if err != nil {
c.AbortWithStatus(
http.StatusInternalServerError,
)
return
}
permissionMap :=
make(map[string]bool)
for _, permission :=
range permissions {
permissionMap[permission] =
true
}
for _, requiredPermission :=
range required {
if permissionMap[
requiredPermission
] {
c.Next()
return
}
}
c.AbortWithStatusJSON(
http.StatusForbidden,
gin.H{
"message": "权限不足",
},
)
}
}
使用:
router.PUT(
"/users/:code",
RequireAnyPermission(
permissionSvc,
[]string{
"system:user:update",
"system:user:admin",
},
),
UpdateUser,
)
意思是:
拥有其中任何一个权限即可。
二十四、还有一种:必须同时拥有全部权限
例如某些危险操作:
finance:refund
risk:approve
必须两个权限都具备。
可以设计:
RequireAllPermissions()
实现:
func RequireAllPermissions(
permissionSvc PermissionService,
required []string,
) gin.HandlerFunc {
return func(c *gin.Context) {
claims :=
GetCurrentClaims(c)
if claims == nil {
c.AbortWithStatus(
http.StatusUnauthorized,
)
return
}
if claims.IsSuperAdmin {
c.Next()
return
}
permissions, err :=
permissionSvc.
GetUserPermissions(
c.Request.Context(),
claims.UserCode,
)
if err != nil {
c.AbortWithStatus(
http.StatusInternalServerError,
)
return
}
permissionMap :=
make(map[string]bool)
for _, permission :=
range permissions {
permissionMap[permission] =
true
}
for _, requiredPermission :=
range required {
if !permissionMap[
requiredPermission
] {
c.AbortWithStatusJSON(
http.StatusForbidden,
gin.H{
"message":
"权限不足",
},
)
return
}
}
c.Next()
}
}
二十五、JWT 和 RBAC 到底是什么关系?
很多人会把:
JWT
和:
RBAC
混在一起。
其实是两件完全不同的事情。
JWT 解决的是:
你是谁?
RBAC 解决的是:
你能干什么?
请求:
POST /api/v1/system/users
首先:
Authorization: Bearer xxx
AuthMiddleware 验证 JWT。
得到:
type Claims struct {
UserCode string
Username string
IsSuperAdmin bool
jwt.RegisteredClaims
}
然后:
JWT 验证成功
↓
确定用户身份
↓
放入 Gin Context
↓
Permission Middleware
↓
根据 UserCode 查询权限
↓
判断接口权限
完整链路:
HTTP Request
↓
JWT Authentication
↓
Claims
↓
Permission Middleware
↓
RBAC
↓
Handler
↓
Service
二十六、超级管理员为什么适合写进 JWT?
登录成功的时候:
token, err :=
GenerateToken(
user.UserCode,
user.Username,
user.IsSuperAdmin,
)
Claims:
type Claims struct {
UserCode string `json:"user_code"`
Username string `json:"username"`
IsSuperAdmin bool `json:"is_super_admin"`
jwt.RegisteredClaims
}
以后每次请求:
if claims.IsSuperAdmin {
c.Next()
return
}
就不需要:
每个接口
↓
查询数据库
↓
是不是超级管理员
不过这里有一个问题。
如果管理员在数据库里把:
is_super_admin
从 true 修改为 false,
但旧 JWT 还没过期:
旧 JWT 里的 true
仍然有效。
因此高安全系统需要:
Token Version
Redis Session
短 Token 生命周期
强制注销机制
进一步控制。
二十七、菜单权限和接口权限不要混淆
这是 RBAC 里最容易出现安全漏洞的地方。
假设前端判断:
if (
permissions.includes(
'system:user:delete'
)
) {
return <Button>删除用户</Button>;
}
没有权限的人看不到:
删除
按钮。
很多人以为:
安全了。
其实完全没有。
用户可以自己调用:
curl -X DELETE \
http://localhost:8080/api/v1/system/users/U001
如果后端没有校验:
system:user:delete
一样可以删除。
所以:
前端权限
=
用户体验
而:
后端权限
=
真正的安全边界
这两者一定要分清。
二十八、前端按钮权限怎么做?
登录之后,后端返回:
{
"permissions": [
"system:user:list",
"system:user:create",
"system:user:update"
]
}
前端保存:
const permissions = [
'system:user:list',
'system:user:create',
'system:user:update',
];
权限函数:
export function hasPermission(
permissions: string[],
permission: string,
) {
return permissions.includes(
permission,
);
}
React:
{
hasPermission(
permissions,
'system:user:create',
) && (
<Button type="primary">
新增用户
</Button>
)
}
删除:
{
hasPermission(
permissions,
'system:user:delete',
) && (
<Button danger>
删除
</Button>
)
}
如果用户没有:
system:user:delete
前端按钮不显示。
后端接口同样:
RequirePermission(
permissionSvc,
"system:user:delete",
)
这样就形成:
前端控制显示
+
后端控制安全
二十九、动态菜单又是怎么来的?
登录之后:
用户
↓
角色
↓
角色拥有菜单
↓
过滤菜单
↓
生成菜单树
↓
返回前端
比如完整菜单:
首页
系统管理
├── 用户管理
├── 角色管理
├── 菜单管理
└── 部门管理
系统监控
├── Redis
└── 服务监控
普通用户可能只有:
首页
管理员有:
首页
系统管理
├── 用户管理
├── 角色管理
├── 菜单管理
└── 部门管理
系统监控
├── Redis
└── 服务监控
这不是前端写死:
if (role === 'admin')
而是后端直接返回允许访问的菜单树。
三十、菜单树怎么构建?
假设菜单:
type MenuVO struct {
MenuCode string
ParentCode string
Name string
Path string
Children []*MenuVO
}
可以先建立 Map:
menuMap :=
make(
map[string]*MenuVO,
)
for _, menu :=
range menus {
menuMap[menu.MenuCode] =
&MenuVO{
MenuCode:
menu.MenuCode,
ParentCode:
menu.ParentCode,
Name:
menu.Name,
Path:
menu.Path,
Children:
[]*MenuVO{},
}
}
再组装:
roots := []*MenuVO{}
for _, menu :=
range menuMap {
if menu.ParentCode == "" {
roots =
append(
roots,
menu,
)
continue
}
parent :=
menuMap[
menu.ParentCode
]
if parent != nil {
parent.Children =
append(
parent.Children,
menu,
)
}
}
最后:
return roots
得到:
[
{
"name": "系统管理",
"children": [
{
"name": "用户管理"
},
{
"name": "角色管理"
}
]
}
]
三十一、为什么过滤菜单时还要补父节点?
假设用户拥有:
用户管理
但没有显式绑定:
系统管理
如果直接过滤,就可能得到:
用户管理
但是它的父级:
系统管理
消失了。
前端菜单树就断了。
所以:
用户有某个子菜单
↓
必须自动补齐祖先节点
比如:
系统管理
↓
用户管理
↓
用户列表
用户有:
用户列表
那么最终应该返回:
系统管理
└── 用户管理
└── 用户列表
而不是只返回:
用户列表
这是动态菜单实现中特别容易忽略的一点。
三十二、角色分配菜单怎么实现?
后台一般会有一个树:
☑ 系统管理
☑ 用户管理
☑ 查询
☑ 新增
☑ 修改
☐ 删除
☑ 角色管理
☑ 查询
☐ 新增
前端提交:
{
"role_code": "operator",
"menu_codes": [
"SYSTEM",
"SYSTEM_USER",
"SYSTEM_USER_LIST",
"SYSTEM_USER_CREATE",
"SYSTEM_USER_UPDATE"
]
}
Service:
func (
s *RoleMenuService,
) SetRoleMenus(
ctx context.Context,
roleCode string,
menuCodes []string,
) error {
return s.repo.SetRoleMenus(
ctx,
roleCode,
menuCodes,
)
}
三十三、SetRoleMenus 一定要用事务
因为这个操作一般是:
删除旧权限
+
插入新权限
如果:
删除成功
但是:
插入失败
角色就一个权限都没了。
所以:
func (
r *roleMenuRepository,
) SetRoleMenus(
ctx context.Context,
roleCode string,
menuCodes []string,
) error {
return r.db.
WithContext(ctx).
Transaction(
func(
tx *gorm.DB,
) error {
// 删除旧关系
if err :=
tx.
Where(
"role_code = ?",
roleCode,
).
Delete(
&RoleMenu{},
).
Error;
err != nil {
return err
}
if len(menuCodes) == 0 {
return nil
}
relations :=
make(
[]RoleMenu,
0,
len(menuCodes),
)
for _, menuCode :=
range menuCodes {
relations =
append(
relations,
RoleMenu{
RoleCode:
roleCode,
MenuCode:
menuCode,
},
)
}
return tx.
Create(
&relations,
).
Error
},
)
}
这就是事务存在的意义。
三十四、角色禁用以后权限应该立即失效
PermissionService 查询角色时:
if role.Status != 1 {
continue
}
意味着:
user
↓
role
↓
status = 0
那么这个角色提供的所有权限都失效。
这个设计很重要。
因为有时候我们不是删除一个角色,而是:
临时停用角色
例如:
外包人员
项目结束后:
status = 0
即可让权限整体失效。
三十五、菜单禁用同样应该失效
权限计算:
if menu.Status != 1 {
continue
}
如果管理员禁用:
订单退款
即使某角色还有:
order:refund
关联关系,
最终也不应该生效。
这样我们就拥有两层控制:
Role Status
+
Menu Status
三十六、RBAC 最大的问题:权限查询可能很慢
现在每个请求都:
用户
↓
查角色
↓
遍历角色
↓
查菜单
↓
提取权限
假设:
1000 QPS
每秒可能产生大量 SQL。
所以生产环境通常会加入:
Redis
或者本地缓存。
三十七、权限缓存怎么设计?
Redis Key:
rbac:user:U001:permissions
Value:
[
"system:user:list",
"system:user:create",
"system:role:list"
]
代码:
func (
s *CachedPermissionService,
) GetUserPermissions(
ctx context.Context,
userCode string,
) ([]string, error) {
key :=
"rbac:user:" +
userCode +
":permissions"
// 1. 读缓存
cached, err :=
s.redis.Get(
ctx,
key,
).Result()
if err == nil {
var permissions []string
if json.Unmarshal(
[]byte(cached),
&permissions,
) == nil {
return permissions, nil
}
}
// 2. Cache Miss
permissions, err :=
s.inner.
GetUserPermissions(
ctx,
userCode,
)
if err != nil {
return nil, err
}
data, _ :=
json.Marshal(
permissions,
)
// 3. 写缓存
_ = s.redis.Set(
ctx,
key,
data,
10*time.Minute,
).Err()
return permissions, nil
}
这样:
第一次请求
查数据库
后续请求
直接 Redis
性能差别会非常明显。
三十八、但是 RBAC 缓存最大的坑是“失效”
假设:
张三
拥有 admin 角色
管理员把张三:
admin
角色删除。
但是 Redis 还有:
system:user:delete
如果不清理缓存,
张三可能继续拥有权限 10 分钟。
这是不能接受的。
所以:
修改用户角色
↓
删除用户 Permission Cache
角色权限变化:
修改 RoleMenu
↓
找到这个 Role 的所有用户
↓
删除这些用户的 Permission Cache
这才是完整闭环。
三十九、RBAC 还解决不了“数据权限”
这是非常重要的一点。
假设:
张三
system:user:list
李四也有:
system:user:list
是不是意味着两个人都能看到:
全部用户?
不一定。
张三可能是:
超级管理员
应该看到:
所有用户
李四是:
技术部经理
只能看到:
技术部
这就是:
Data Scope
数据权限
四十、RBAC 和 Data Scope 是两个概念
RBAC 回答:
你能不能打开用户管理?
Data Scope 回答:
打开用户管理以后
你能看到哪些用户?
例如:
RBAC
system:user:list = true
但:
DataScope
SELF
查询必须是:
SELECT *
FROM sys_users
WHERE user_code = ?;
如果:
DEPT
可能:
SELECT *
FROM sys_users
WHERE dept_code = ?;
如果:
ALL
才是:
SELECT *
FROM sys_users;
所以成熟权限系统其实是:
RBAC
+
Data Scope
而不是只有 RBAC。
四十一、角色可以携带 DataScope
例如:
type Role struct {
RoleCode string
RoleName string
DataScope int
}
定义:
const (
DataScopeAll = 1
DataScopeCustom = 2
DataScopeDept = 3
DataScopeDeptAndChildren = 4
DataScopeSelf = 5
)
分别表示:
1 全部数据
2 自定义部门
3 本部门
4 本部门及子部门
5 仅本人
这就是很多企业后台常见的数据权限模型。
四十二、权限系统最终应该形成四层
完整后台权限,我一般会拆成:
第一层
Authentication
身份认证
第二层
RBAC
功能权限
第三层
Menu / Button Permission
前端展示权限
第四层
Data Scope
数据权限
请求:
GET /api/v1/system/users
经历:
JWT
↓
你是谁?
RBAC
↓
有没有 system:user:list?
Data Scope
↓
能看到哪些用户?
Repository
↓
拼装 WHERE 条件
Database
↓
最终数据
这才是一套真正完整的后台权限链路。
四十三、为什么不要把所有权限直接放进 JWT?
有些项目登录的时候:
查询全部 Permission
↓
全部塞进 JWT
例如:
{
"permissions": [
"system:user:list",
"system:user:create",
"system:user:update",
"system:user:delete",
"..."
]
}
看起来可以减少数据库查询。
但有一个严重问题:
权限修改不能立即生效。
假设管理员撤销:
system:user:delete
但是用户旧 Token 还有效两个小时。
那么这两个小时:
system:user:delete
依然有效。
所以通常 JWT 只保存:
UserCode
Username
IsSuperAdmin
而真正权限:
数据库 / Redis 动态获取
这样更加灵活。
四十四、为什么不能只依赖前端路由权限?
再强调一次。
前端:
不显示菜单
不显示按钮
只是:
UI 权限
攻击者完全可以:
打开 Postman
或者:
curl
自己请求 API。
所以真正安全边界一定是:
RequirePermission(
permissionSvc,
"system:user:delete",
)
如果一个系统:
前端按钮权限做得特别漂亮
但是:
后端 API 没有权限校验
那么这个权限系统基本等于没有。
四十五、RBAC 推荐的目录结构
一个 Go 项目可以拆成:
internal
│
├── middleware
│ ├── auth.go
│ └── permission.go
│
├── model
│ └── entity
│ ├── user.go
│ ├── role.go
│ ├── menu.go
│ ├── user_role.go
│ └── role_menu.go
│
├── repository
│ ├── user_role_repository.go
│ ├── role_menu_repository.go
│ └── role_repository.go
│
├── service
│ ├── permission
│ ├── role
│ ├── role_menu
│ ├── user_role
│ └── data_scope
│
└── api
└── v1
└── system
├── users.go
├── roles.go
└── menus.go
职责非常清晰。
四十六、一次完整权限请求是什么样?
最后我们完整走一遍。
用户登录:
POST /auth/login
用户名:
zhangsan
Service 查询:
UserCode
U001
同时:
IsSuperAdmin
false
生成 JWT:
{
"user_code": "U001",
"username": "zhangsan",
"is_super_admin": false
}
用户访问:
DELETE /api/v1/system/users/U009
路由:
router.DELETE(
"/users/:code",
RequirePermission(
permissionSvc,
"system:user:delete",
),
DeleteUser,
)
Middleware:
解析 JWT
↓
UserCode = U001
↓
IsSuperAdmin = false
↓
进入 RBAC
PermissionService:
U001
↓
GetUserRoles()
得到:
system_admin
operator
查询:
system_admin
↓
Menus
得到:
system:user:list
system:user:create
system:user:update
system:user:delete
最终:
CheckPermission(
"U001",
"system:user:delete",
)
返回:
true
于是:
c.Next()
请求进入:
DeleteUser Handler
如果没有:
system:user:delete
直接:
403 Forbidden
Handler 根本不会执行。
这就是一条完整的 RBAC 请求链路。
四十七、一套成熟 RBAC 最终应该具备什么?
如果让我现在设计一个后台权限系统,我至少会要求下面这些能力:
User
用户
Role
角色
Menu
菜单
Permission
权限标识
UserRole
用户角色关联
RoleMenu
角色菜单关联
JWT
身份认证
Permission Middleware
接口权限
Dynamic Menu
动态菜单
Button Permission
按钮权限
Super Admin
超级管理员
Role Status
角色启停
Menu Status
菜单启停
Data Scope
数据权限
Permission Cache
权限缓存
Cache Invalidation
缓存失效
这些东西组合起来,才算是一套比较完整的企业后台权限系统。
总结
RBAC 看起来很复杂。
但把它拆开之后,其实核心模型非常简单:
User
↓
UserRole
↓
Role
↓
RoleMenu
↓
Menu
↓
Perms
运行时:
用户
↓
找到角色
↓
找到角色权限
↓
合并权限
↓
判断 Permission
接口永远不要问:
“你是不是管理员?”
而应该问:
“你有没有 system:user:delete?”
角色的意义,只是帮助我们:
组织权限
批量分配权限
真正执行权限校验的对象,应该始终是:
Permission
同时还需要记住三个非常重要的边界:
JWT
解决你是谁
RBAC
解决你能干什么
Data Scope
解决你能操作哪些数据
最后,整个后台权限链路应该是:
HTTP Request
↓
JWT Authentication
↓
Current User
↓
Super Admin Check
↓
RBAC Permission
↓
Data Scope
↓
Handler
↓
Service
↓
Repository
↓
Database
如果把这条链路真正设计清楚,后面无论系统增加:
用户管理
订单管理
会员管理
财务管理
内容管理
AI 模型管理
工单系统
都只需要不断增加:
角色
+
权限
+
数据范围
而不需要在业务代码里到处写:
if role == "admin" {
}
这也是 RBAC 最有价值的地方:
权限从代码逻辑,变成了可以动态配置的业务数据。

浙公网安备 33010602011771号