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 最有价值的地方:

权限从代码逻辑,变成了可以动态配置的业务数据。

posted @ 2026-09-01 10:08  JavaPub  阅读(38)  评论(0)    收藏  举报