RBAC 还不够:Go + Gin + GORM 实现企业后台数据权限

很多人做后台权限系统,做到 RBAC 就觉得差不多了。
比如:
用户
↓
角色
↓
菜单
↓
接口权限
管理员能访问:
/api/v1/system/users
普通用户不能访问。
看起来权限系统已经完成了。
但真实企业项目里,很快会遇到另一个问题:
两个人都有“用户管理”权限,但是他们能看到的数据应该一样吗?
答案通常是不一样。
比如:
超级管理员
→ 查看全公司用户
华北区负责人
→ 只能查看华北区
北京分公司经理
→ 只能查看北京分公司及下属部门
普通员工
→ 只能查看自己
这就是:
数据权限 Data Scope
今天结合我的开源项目 ShiyuAdmin,从零实现一套:
Go
+
Gin
+
GORM
+
RBAC
+
部门树
+
数据权限
项目地址:
https://github.com/Rodert/ShiyuAdmin
一、RBAC 和数据权限不是一回事
先把两个概念区分清楚。
RBAC 解决的是:
你能不能访问这个功能。
比如:
system:user:list
没有这个权限:
GET /api/v1/system/users
直接:
403 Forbidden
但是数据权限解决的是:
你进入用户列表以后,可以看到哪些用户?
例如张三和李四都有:
system:user:list
但:
张三
角色:公司管理员
查询结果:
1000 个用户
李四:
角色:北京部门经理
查询结果:
86 个用户
王五:
角色:普通员工
查询结果:
1 个用户
所以完整权限系统实际上应该是:
功能权限
+
数据权限
二、先设计 5 种常见数据范围
企业后台比较常见的 Data Scope 可以分成:
const (
DataScopeAll = "all"
DataScopeDept = "dept"
DataScopeDeptAndChild = "deptAndChild"
DataScopeCustom = "custom"
DataScopeSelf = "self"
)
分别表示:
all
全部数据
dept
当前部门
deptAndChild
当前部门 + 所有子部门
custom
角色指定部门
self
仅本人
这 5 种基本已经可以覆盖大量后台系统。
三、角色表增加 DataScope
先看角色:
type Role struct {
ID uint `gorm:"primaryKey"`
RoleCode string `gorm:"size:32;uniqueIndex"`
RoleName string `gorm:"size:64"`
DataScope string `gorm:"size:32"`
Status int
}
数据库:
CREATE TABLE sys_roles (
id BIGSERIAL PRIMARY KEY,
role_code VARCHAR(32)
NOT NULL,
role_name VARCHAR(64)
NOT NULL,
data_scope VARCHAR(32)
NOT NULL DEFAULT 'self',
status INT
NOT NULL DEFAULT 1
);
例如:
INSERT INTO sys_roles
(
role_code,
role_name,
data_scope,
status
)
VALUES
(
'super_admin',
'超级管理员',
'all',
1
);
部门经理:
INSERT INTO sys_roles
(
role_code,
role_name,
data_scope,
status
)
VALUES
(
'dept_manager',
'部门经理',
'deptAndChild',
1
);
普通员工:
INSERT INTO sys_roles
(
role_code,
role_name,
data_scope,
status
)
VALUES
(
'employee',
'普通员工',
'self',
1
);
四、用户必须关联部门
用户表:
type User struct {
ID uint `gorm:"primaryKey"`
UserCode string `gorm:"size:32;uniqueIndex"`
Username string `gorm:"size:64"`
Nickname string `gorm:"size:64"`
DeptCode string `gorm:"size:32;index"`
Status int
}
数据库:
CREATE TABLE sys_users (
id BIGSERIAL PRIMARY KEY,
user_code VARCHAR(32)
NOT NULL UNIQUE,
username VARCHAR(64)
NOT NULL,
nickname VARCHAR(64),
dept_code VARCHAR(32),
status INT
DEFAULT 1
);
比如:
王仕宇
user_code = U10001
dept_code = DEV
意味着:
王仕宇
属于
研发部
五、部门本身是一棵树
企业组织结构通常不是平级的。
比如:
总公司
│
├── 技术中心
│ │
│ ├── 后端研发部
│ │
│ ├── 前端研发部
│ │
│ └── 测试部
│
├── 产品中心
│
└── 市场中心
所以部门实体需要:
type Dept struct {
ID uint `gorm:"primaryKey"`
DeptCode string `gorm:"size:32;uniqueIndex"`
DeptName string `gorm:"size:64"`
ParentCode string `gorm:"size:32;index"`
Status int
}
数据库:
CREATE TABLE sys_depts (
id BIGSERIAL PRIMARY KEY,
dept_code VARCHAR(32)
NOT NULL UNIQUE,
dept_name VARCHAR(64)
NOT NULL,
parent_code VARCHAR(32),
status INT
DEFAULT 1
);
插入一些测试数据:
INSERT INTO sys_depts
(dept_code, dept_name, parent_code)
VALUES
('ROOT', '总公司', '');
INSERT INTO sys_depts
(dept_code, dept_name, parent_code)
VALUES
('TECH', '技术中心', 'ROOT');
INSERT INTO sys_depts
(dept_code, dept_name, parent_code)
VALUES
('BACKEND', '后端研发部', 'TECH');
INSERT INTO sys_depts
(dept_code, dept_name, parent_code)
VALUES
('FRONTEND', '前端研发部', 'TECH');
INSERT INTO sys_depts
(dept_code, dept_name, parent_code)
VALUES
('TEST', '测试部', 'TECH');
六、定义统一的数据范围对象
这里不要把角色逻辑直接塞到 Repository。
应该先把复杂权限计算成一个简单结构。
例如:
type UserDataScope struct {
All bool
UserCode string
DeptCodes []string
}
最终无论权限多复杂,都归一化成:
全部数据
或者:
指定用户
或者:
指定部门集合
或者:
用户 + 部门集合
例如普通员工:
scope := UserDataScope{
UserCode: "U10001",
}
部门经理:
scope := UserDataScope{
DeptCodes: []string{
"TECH",
"BACKEND",
"FRONTEND",
"TEST",
},
}
超级管理员:
scope := UserDataScope{
All: true,
}
这样 Repository 就会简单很多。
七、先处理超级管理员
这是最简单的。
func (
s *DataScopeService,
) ResolveUserScope(
ctx context.Context,
userCode string,
isSuperAdmin bool,
) (*UserDataScope, error) {
if isSuperAdmin {
return &UserDataScope{
All: true,
}, nil
}
// 后续普通用户权限计算
return nil, nil
}
超级管理员直接:
All = true
后面不再继续计算。
八、查询当前用户
下一步:
user, err :=
s.userRepo.GetByCode(
ctx,
userCode,
)
if err != nil {
return nil, err
}
如果用户不存在:
if user == nil {
return &UserDataScope{
UserCode: userCode,
}, nil
}
这里推荐遵循:
默认拒绝
而不是:
查询不到用户就给全部权限。
安全系统里一定要:
Fail Closed
而不是:
Fail Open
九、一个用户可能有多个角色
真实系统通常不是:
用户
→ 一个角色
而是:
用户
→ 多个角色
例如王仕宇同时拥有:
研发部经理
+
安全审计员
所以需要:
roles, err :=
s.userRoleRepo.
GetUserRoles(
ctx,
userCode,
)
if err != nil {
return nil, err
}
如果没有任何角色:
if len(roles) == 0 {
return &UserDataScope{
UserCode: userCode,
}, nil
}
还是:
只能看自己。
十、开始合并多个角色的数据权限
准备:
deptCodes :=
map[string]struct{}{}
includeSelf := false
为什么用:
map[string]struct{}
而不是:
[]string
因为部门可能重复。
例如:
角色 A:
TECH
BACKEND
角色 B:
BACKEND
TEST
最终应该:
TECH
BACKEND
TEST
而不是:
TECH
BACKEND
BACKEND
TEST
Set 写法:
deptCodes :=
map[string]struct{}{}
deptCodes["TECH"] =
struct{}{}
deptCodes["BACKEND"] =
struct{}{}
天然去重。
十一、处理 All
多个角色中,只要有一个:
all
整个用户就可以查看全部数据。
for _, role :=
range roles {
if role == nil {
continue
}
if role.Status != 1 {
continue
}
switch role.DataScope {
case "all":
return &UserDataScope{
All: true,
}, nil
}
}
例如:
角色 A:self
角色 B:dept
角色 C:all
最终:
all
这是典型的:
权限取并集。
十二、处理仅本部门
如果:
DataScope = dept
加入当前用户部门。
case "dept":
if user.DeptCode != "" {
deptCodes[
user.DeptCode
] = struct{}{}
}
例如用户:
DeptCode = BACKEND
最后:
DeptCodes: []string{
"BACKEND",
}
SQL 就可以变成:
WHERE dept_code IN ('BACKEND')
十三、本部门及子部门最麻烦
比如技术中心负责人:
TECH
应该看到:
TECH
BACKEND
FRONTEND
TEST
所以需要遍历部门树。
最简单的方法:
先查询全部部门。
depts, err :=
s.deptRepo.List(ctx)
if err != nil {
return err
}
建立:
ParentCode
→
Children
索引。
childrenByParent :=
make(
map[string][]*Dept,
len(depts),
)
遍历:
for _, dept :=
range depts {
if dept == nil {
continue
}
childrenByParent[
dept.ParentCode
] =
append(
childrenByParent[
dept.ParentCode
],
dept,
)
}
得到:
ROOT
→ TECH
→ PRODUCT
→ MARKET
以及:
TECH
→ BACKEND
→ FRONTEND
→ TEST
十四、递归查询全部子部门
接下来写递归。
func collectChildren(
parentCode string,
childrenByParent map[string][]*Dept,
result map[string]struct{},
) {
children :=
childrenByParent[
parentCode
]
for _, child :=
range children {
if child == nil {
continue
}
if _, exists :=
result[
child.DeptCode
]; exists {
continue
}
result[
child.DeptCode
] =
struct{}{}
collectChildren(
child.DeptCode,
childrenByParent,
result,
)
}
}
调用:
result :=
map[string]struct{}{
"TECH": {},
}
collectChildren(
"TECH",
childrenByParent,
result,
)
结果:
TECH
BACKEND
FRONTEND
TEST
十五、完整的部门 + 子部门函数
可以封装:
func (
s *DataScopeService,
) addDeptAndChildren(
ctx context.Context,
deptCodes map[string]struct{},
deptCode string,
) error {
if deptCode == "" {
return nil
}
deptCodes[
deptCode
] =
struct{}{}
depts, err :=
s.deptRepo.List(ctx)
if err != nil {
return err
}
childrenByParent :=
make(
map[string][]*Dept,
len(depts),
)
for _, dept :=
range depts {
if dept == nil {
continue
}
childrenByParent[
dept.ParentCode
] =
append(
childrenByParent[
dept.ParentCode
],
dept,
)
}
var walk func(
parentCode string,
)
walk =
func(
parentCode string,
) {
children :=
childrenByParent[
parentCode
]
for _, child :=
range children {
if child == nil {
continue
}
if _, exists :=
deptCodes[
child.DeptCode
]; exists {
continue
}
deptCodes[
child.DeptCode
] =
struct{}{}
walk(
child.DeptCode,
)
}
}
walk(deptCode)
return nil
}
十六、为什么要判断 exists?
注意这一段:
if _, exists :=
deptCodes[
child.DeptCode
]; exists {
continue
}
不仅仅是为了去重。
它还能防止异常部门数据形成:
A
↓
B
↓
C
↓
A
如果没有 visited 判断:
递归永远不会结束。
有了 Set:
A 已访问
第二次碰到:
直接跳过。
十七、自定义部门权限怎么做?
有时候角色不是:
当前部门。
而是指定几个部门。
例如:
华北区域负责人
可以看:
北京
天津
河北
这就是:
custom
需要一张:
角色部门关联表
十八、角色部门关联表
type RoleDept struct {
ID uint `gorm:"primaryKey"`
RoleCode string `gorm:"size:32;index"`
DeptCode string `gorm:"size:32;index"`
}
SQL:
CREATE TABLE sys_role_depts (
id BIGSERIAL PRIMARY KEY,
role_code VARCHAR(32)
NOT NULL,
dept_code VARCHAR(32)
NOT NULL
);
数据:
INSERT INTO sys_role_depts
(role_code, dept_code)
VALUES
('north_manager', 'BEIJING');
INSERT INTO sys_role_depts
(role_code, dept_code)
VALUES
('north_manager', 'TIANJIN');
INSERT INTO sys_role_depts
(role_code, dept_code)
VALUES
('north_manager', 'HEBEI');
十九、查询角色自定义部门
Repository:
func (
r *RoleDeptRepository,
) GetRoleDepts(
ctx context.Context,
roleCode string,
) ([]*Dept, error) {
var depts []*Dept
err :=
r.db.
WithContext(ctx).
Table(
"sys_depts AS d",
).
Joins(`
INNER JOIN sys_role_depts rd
ON rd.dept_code = d.dept_code
`).
Where(
"rd.role_code = ?",
roleCode,
).
Find(
&depts,
).
Error
return depts, err
}
Service:
func (
s *DataScopeService,
) addRoleDepts(
ctx context.Context,
result map[string]struct{},
roleCode string,
) error {
depts, err :=
s.roleDeptRepo.
GetRoleDepts(
ctx,
roleCode,
)
if err != nil {
return err
}
for _, dept :=
range depts {
if dept == nil {
continue
}
if dept.DeptCode == "" {
continue
}
result[
dept.DeptCode
] =
struct{}{}
}
return nil
}
二十、处理 self
如果角色是:
self
就标记:
includeSelf = true
例如:
case "self":
includeSelf = true
最后:
if includeSelf {
scope.UserCode =
userCode
}
最终 SQL:
WHERE user_code = 'U10001'
二十一、完整 DataScope 计算逻辑
把所有逻辑组合:
func (
s *DataScopeService,
) ResolveUserScope(
ctx context.Context,
userCode string,
isSuperAdmin bool,
) (*UserDataScope, error) {
if isSuperAdmin {
return &UserDataScope{
All: true,
}, nil
}
user, err :=
s.userRepo.
GetByCode(
ctx,
userCode,
)
if err != nil {
return nil, err
}
if user == nil {
return &UserDataScope{
UserCode: userCode,
}, nil
}
roles, err :=
s.userRoleRepo.
GetUserRoles(
ctx,
userCode,
)
if err != nil {
return nil, err
}
if len(roles) == 0 {
return &UserDataScope{
UserCode: userCode,
}, nil
}
deptCodes :=
map[string]struct{}{}
includeSelf := false
for _, role :=
range roles {
if role == nil {
continue
}
if role.Status != 1 {
continue
}
switch normalizeScope(
role.DataScope,
) {
case "all":
return &UserDataScope{
All: true,
}, nil
case "dept":
if user.DeptCode != "" {
deptCodes[
user.DeptCode
] =
struct{}{}
}
case "deptandchild":
err :=
s.addDeptAndChildren(
ctx,
deptCodes,
user.DeptCode,
)
if err != nil {
return nil, err
}
case "custom":
err :=
s.addRoleDepts(
ctx,
deptCodes,
role.RoleCode,
)
if err != nil {
return nil, err
}
default:
includeSelf = true
}
}
scope :=
&UserDataScope{
DeptCodes:
mapKeys(
deptCodes,
),
}
if includeSelf ||
len(scope.DeptCodes) == 0 {
scope.UserCode =
userCode
}
return scope, nil
}
这个函数完成以后:
复杂的:
用户
角色
部门
子部门
自定义部门
全部被转换成:
UserDataScope
二十二、normalizeScope 也有必要
真实数据库中的值经常不统一。
可能有人写:
deptAndChild
有人写:
dept_and_child
甚至:
dept-and-child
可以统一:
func normalizeScope(
scope string,
) string {
scope =
strings.TrimSpace(
strings.ToLower(
scope,
),
)
scope =
strings.ReplaceAll(
scope,
"_",
"",
)
scope =
strings.ReplaceAll(
scope,
"-",
"",
)
return scope
}
最终都会变:
deptandchild
这属于小细节,但实际项目里非常实用。
二十三、权限计算完了,真正重要的是 SQL
Data Scope 不是算出来就结束了。
必须真正进入:
WHERE
否则毫无意义。
比如定义:
func applyUserScope(
query *gorm.DB,
userCode string,
deptCodes []string,
) *gorm.DB {
switch {
case userCode != "" &&
len(deptCodes) > 0:
return query.Where(
"(user_code = ? OR dept_code IN ?)",
userCode,
deptCodes,
)
case userCode != "":
return query.Where(
"user_code = ?",
userCode,
)
case len(deptCodes) > 0:
return query.Where(
"dept_code IN ?",
deptCodes,
)
default:
return query.Where(
"1 = 0",
)
}
}
这一段非常关键。
二十四、为什么最后必须是 1 = 0?
假设:
UserCode = ""
DeptCodes = []
你千万不能:
return query
因为这意味着:
SELECT *
FROM sys_users;
本来没有任何权限。
结果反而变成:
全部权限。
这是典型严重越权漏洞。
正确做法:
return query.Where(
"1 = 0",
)
最终 SQL:
SELECT *
FROM sys_users
WHERE 1 = 0;
返回:
0 条
这就是安全系统里非常重要的:
默认拒绝
二十五、分页查询加入数据权限
正常用户列表:
func (
r *UserRepository,
) List(
ctx context.Context,
page int,
pageSize int,
) (
[]*User,
int64,
error,
) {
var users []*User
var total int64
query :=
r.db.
WithContext(ctx).
Model(
&User{},
)
err :=
query.
Count(
&total,
).
Error
if err != nil {
return nil, 0, err
}
offset :=
(page - 1) *
pageSize
err =
query.
Order(
"id DESC",
).
Limit(
pageSize,
).
Offset(
offset,
).
Find(
&users,
).
Error
return users,
total,
err
}
带数据权限:
func (
r *UserRepository,
) ListByScope(
ctx context.Context,
page int,
pageSize int,
userCode string,
deptCodes []string,
) (
[]*User,
int64,
error,
) {
var users []*User
var total int64
query :=
r.db.
WithContext(ctx).
Model(
&User{},
)
query =
applyUserScope(
query,
userCode,
deptCodes,
)
if err :=
query.
Count(
&total,
).
Error; err != nil {
return nil, 0, err
}
if total == 0 {
return []*User{},
0,
nil
}
offset :=
(page - 1) *
pageSize
if err :=
query.
Order(
"id DESC",
).
Limit(
pageSize,
).
Offset(
offset,
).
Find(
&users,
).
Error; err != nil {
return nil, 0, err
}
return users,
total,
nil
}
二十六、Service 层决定走哪个 Repository
例如:
func (
s *UserService,
) Page(
ctx context.Context,
currentUserCode string,
isSuperAdmin bool,
page int,
pageSize int,
) (
[]*User,
int64,
error,
) {
scope, err :=
s.dataScopeService.
ResolveUserScope(
ctx,
currentUserCode,
isSuperAdmin,
)
if err != nil {
return nil, 0, err
}
if scope.All {
return s.userRepo.List(
ctx,
page,
pageSize,
)
}
return s.userRepo.
ListByScope(
ctx,
page,
pageSize,
scope.UserCode,
scope.DeptCodes,
)
}
整个逻辑很清晰:
超级管理员
↓
普通 List
其他角色
↓
ListByScope
二十七、Controller 不需要知道部门权限细节
Controller 应该尽量简单。
func (
h *UserHandler,
) ListUsers(
c *gin.Context,
) {
userCode :=
c.GetString(
"user_code",
)
isSuperAdmin :=
c.GetBool(
"is_super_admin",
)
page, _ :=
strconv.Atoi(
c.DefaultQuery(
"page",
"1",
),
)
pageSize, _ :=
strconv.Atoi(
c.DefaultQuery(
"page_size",
"20",
),
)
users,
total,
err :=
h.userService.
Page(
c.Request.Context(),
userCode,
isSuperAdmin,
page,
pageSize,
)
if err != nil {
c.JSON(
500,
gin.H{
"message":
err.Error(),
},
)
return
}
c.JSON(
200,
gin.H{
"total":
total,
"items":
users,
},
)
}
Controller 不需要知道:
dept
deptAndChild
custom
self
这些全部由:
DataScopeService
处理。
二十八、最好抽成 GORM Scope
如果很多 Repository 都需要数据权限,可以进一步封装成:
func UserDataScopeQuery(
scope *UserDataScope,
) func(*gorm.DB) *gorm.DB {
return func(
db *gorm.DB,
) *gorm.DB {
if scope == nil {
return db.Where(
"1 = 0",
)
}
if scope.All {
return db
}
switch {
case scope.UserCode != "" &&
len(scope.DeptCodes) > 0:
return db.Where(
"(user_code = ? OR dept_code IN ?)",
scope.UserCode,
scope.DeptCodes,
)
case scope.UserCode != "":
return db.Where(
"user_code = ?",
scope.UserCode,
)
case len(scope.DeptCodes) > 0:
return db.Where(
"dept_code IN ?",
scope.DeptCodes,
)
default:
return db.Where(
"1 = 0",
)
}
}
}
然后:
query :=
r.db.
WithContext(ctx).
Model(
&User{},
).
Scopes(
UserDataScopeQuery(
scope,
),
)
这样非常适合复用。
二十九、不只是用户表能用
比如订单:
type Order struct {
ID uint
OrderNo string
UserCode string
DeptCode string
Amount float64
}
同样:
db.
Model(
&Order{},
).
Scopes(
UserDataScopeQuery(
scope,
),
).
Find(
&orders,
)
客户:
type Customer struct {
ID uint
Name string
OwnerCode string
DeptCode string
}
也可以做:
func CustomerDataScope(
scope *UserDataScope,
) func(*gorm.DB) *gorm.DB {
return func(
db *gorm.DB,
) *gorm.DB {
if scope.All {
return db
}
if scope.UserCode != "" &&
len(scope.DeptCodes) > 0 {
return db.Where(
"(owner_code = ? OR dept_code IN ?)",
scope.UserCode,
scope.DeptCodes,
)
}
if scope.UserCode != "" {
return db.Where(
"owner_code = ?",
scope.UserCode,
)
}
if len(scope.DeptCodes) > 0 {
return db.Where(
"dept_code IN ?",
scope.DeptCodes,
)
}
return db.Where(
"1 = 0",
)
}
}
这样:
用户
订单
客户
合同
工单
操作日志
全部可以套 Data Scope。
三十、只保护列表接口远远不够
这里是很多系统最容易犯的错误。
例如:
用户列表已经做了:
WHERE dept_code IN (...)
但是详情接口:
GET /users/U90001
却直接:
user, err :=
userRepo.GetByCode(
ctx,
userCode,
)
那么攻击者只要猜到别人的:
user_code
就能直接越权读取。
所以详情也必须带 Scope。
三十一、详情接口防越权
可以写:
func (
r *UserRepository,
) GetByCodeWithScope(
ctx context.Context,
targetUserCode string,
scope *UserDataScope,
) (*User, error) {
var user User
query :=
r.db.
WithContext(ctx).
Model(
&User{},
).
Where(
"user_code = ?",
targetUserCode,
)
query =
query.Scopes(
UserDataScopeQuery(
scope,
),
)
err :=
query.
First(
&user,
).
Error
if errors.Is(
err,
gorm.ErrRecordNotFound,
) {
return nil, nil
}
if err != nil {
return nil, err
}
return &user, nil
}
这样:
即使知道别人的 UserCode:
也查不到。
三十二、修改接口也必须防越权
危险写法:
db.
Model(
&User{},
).
Where(
"user_code = ?",
targetUserCode,
).
Updates(
update,
)
这完全没有数据范围限制。
正确做法:
func (
r *UserRepository,
) UpdateWithScope(
ctx context.Context,
targetUserCode string,
update map[string]any,
scope *UserDataScope,
) error {
query :=
r.db.
WithContext(ctx).
Model(
&User{},
).
Where(
"user_code = ?",
targetUserCode,
).
Scopes(
UserDataScopeQuery(
scope,
),
)
result :=
query.Updates(
update,
)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New(
"数据不存在或无权限操作",
)
}
return nil
}
三十三、删除更必须防越权
func (
r *UserRepository,
) DeleteWithScope(
ctx context.Context,
targetUserCode string,
scope *UserDataScope,
) error {
query :=
r.db.
WithContext(ctx).
Where(
"user_code = ?",
targetUserCode,
)
query =
query.Scopes(
UserDataScopeQuery(
scope,
),
)
result :=
query.Delete(
&User{},
)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New(
"数据不存在或无权限删除",
)
}
return nil
}
所以真正完整的数据权限必须覆盖:
List
Detail
Update
Delete
Export
Statistics
而不是只保护:
List
三十四、导出接口特别容易漏
比如:
GET /users/export
如果导出接口直接:
db.Find(
&allUsers,
)
那么前端用户列表虽然只能看到:
20 人
一点击导出:
全公司 10000 人
全出来了。
所以导出一定也要:
db.
Scopes(
UserDataScopeQuery(
scope,
),
).
Find(
&users,
)
三十五、统计接口同样需要 Scope
比如 Dashboard:
今日订单数
本月销售额
客户数量
部门经理看到的应该是:
自己部门的数据
而不是:
全公司数据
例如:
func CountOrders(
db *gorm.DB,
scope *UserDataScope,
) (int64, error) {
var count int64
err :=
db.
Model(
&Order{},
).
Scopes(
OrderDataScope(
scope,
),
).
Count(
&count,
).
Error
return count, err
}
销售额:
func SumOrderAmount(
db *gorm.DB,
scope *UserDataScope,
) (float64, error) {
var total float64
err :=
db.
Model(
&Order{},
).
Scopes(
OrderDataScope(
scope,
),
).
Select(
"COALESCE(SUM(amount), 0)",
).
Scan(
&total,
).
Error
return total, err
}
三十六、多角色为什么一般取并集?
例如用户同时拥有:
角色 A
→ 本部门
角色 B
→ 自定义查看测试部
那么最终应该:
当前部门
+
测试部
而不是:
两个范围取交集。
否则:
很可能一个用户明明拥有两个角色,权限反而越来越少。
所以常见 RBAC 数据权限模型一般采用:
权限并集
例如:
deptCodes :=
map[string]struct{}{}
不断往里面添加。
如果任意角色:
all
直接:
All = true
三十七、但是高安全系统可以采用交集
有些特殊系统可能需要:
角色权限
AND
安全策略
比如:
角色允许:
北京 + 天津
安全策略:
当前只能访问北京
最终:
北京
可以写一个集合交集:
func intersection(
a []string,
b []string,
) []string {
set :=
make(
map[string]struct{},
len(a),
)
for _, item :=
range a {
set[item] =
struct{}{}
}
result :=
make(
[]string,
0,
)
for _, item :=
range b {
if _, ok :=
set[item]; ok {
result =
append(
result,
item,
)
}
}
return result
}
所以:
数据权限的合并策略本身也是业务规则。
三十八、给 DataScope 写单元测试
权限代码不能只靠:
“我点了几下感觉没问题。”
一定要测试。
例如:
func TestSuperAdminScope(
t *testing.T,
) {
service :=
newTestService()
scope, err :=
service.
ResolveUserScope(
context.Background(),
"U001",
true,
)
require.NoError(
t,
err,
)
require.True(
t,
scope.All,
)
}
三十九、测试普通员工只能看自己
func TestSelfScope(
t *testing.T,
) {
service :=
newTestServiceWithUser(
"U001",
"BACKEND",
[]Role{
{
RoleCode:
"employee",
DataScope:
"self",
Status:
1,
},
},
)
scope, err :=
service.
ResolveUserScope(
context.Background(),
"U001",
false,
)
require.NoError(
t,
err,
)
require.False(
t,
scope.All,
)
require.Equal(
t,
"U001",
scope.UserCode,
)
require.Empty(
t,
scope.DeptCodes,
)
}
四十、测试本部门权限
func TestDeptScope(
t *testing.T,
) {
service :=
newTestServiceWithUser(
"U001",
"BACKEND",
[]Role{
{
RoleCode:
"manager",
DataScope:
"dept",
Status:
1,
},
},
)
scope, err :=
service.
ResolveUserScope(
context.Background(),
"U001",
false,
)
require.NoError(
t,
err,
)
require.Contains(
t,
scope.DeptCodes,
"BACKEND",
)
}
四十一、测试部门及子部门
func TestDeptAndChildScope(
t *testing.T,
) {
service :=
newServiceWithDepts(
[]Dept{
{
DeptCode:
"TECH",
},
{
DeptCode:
"BACKEND",
ParentCode:
"TECH",
},
{
DeptCode:
"FRONTEND",
ParentCode:
"TECH",
},
{
DeptCode:
"TEST",
ParentCode:
"TECH",
},
},
)
scope, err :=
service.
ResolveUserScope(
context.Background(),
"U001",
false,
)
require.NoError(
t,
err,
)
require.ElementsMatch(
t,
[]string{
"TECH",
"BACKEND",
"FRONTEND",
"TEST",
},
scope.DeptCodes,
)
}
四十二、一定要测试“没有权限”
这个测试特别重要。
func TestEmptyScopeMustReturnNoRows(
t *testing.T,
) {
db :=
newTestDB()
scope :=
&UserDataScope{}
var users []User
err :=
db.
Model(
&User{},
).
Scopes(
UserDataScopeQuery(
scope,
),
).
Find(
&users,
).
Error
require.NoError(
t,
err,
)
require.Len(
t,
users,
0,
)
}
要保证:
无权限
永远不能退化成:
全部权限。
四十三、部门很多怎么办?
前面的方案:
查询全部部门
↓
Go 递归
对于:
几十个
几百个
几千个部门
完全够用。
但如果真的有:
几十万个组织节点
就要考虑另外的数据结构。
比如:
Materialized Path
部门表增加:
path
例如:
/ROOT/TECH/BACKEND/
查询技术中心全部子部门:
SELECT *
FROM sys_depts
WHERE path LIKE '/ROOT/TECH/%';
四十四、Closure Table 也是一种方案
单独维护:
祖先
→
后代
关系。
比如:
CREATE TABLE dept_closure (
ancestor_code VARCHAR(32),
descendant_code VARCHAR(32),
depth INT
);
数据:
TECH TECH 0
TECH BACKEND 1
TECH FRONTEND 1
TECH TEST 1
查询:
SELECT descendant_code
FROM dept_closure
WHERE ancestor_code = 'TECH';
大型组织结构下查询会更加稳定。
四十五、Data Scope 最容易出现的 5 个漏洞
做数据权限时,我认为至少要检查下面几个地方。
1. 只限制前端
错误:
前端隐藏数据
API:
仍然返回全部。
完全没用。
2. 只限制 List
列表安全了。
但:
Detail
Update
Delete
Export
没限制。
依然越权。
3. 没有权限时返回全部
比如:
if scope == nil {
return query
}
这是高危错误。
应该:
if scope == nil {
return query.Where(
"1 = 0",
)
}
4. Scope 参数由前端传
千万不要:
GET /users?dept=TECH
然后认为:
TECH 就是当前用户权限范围。
攻击者完全可以:
GET /users?dept=ROOT
Data Scope 必须:
后端根据当前登录用户计算。
5. 超级管理员由前端告诉后端
更不能:
{
"isSuperAdmin": true
}
这个信息必须来自:
JWT
+
后端角色系统
四十六、最终完整调用链
一个真正的数据权限请求,最终应该是:
浏览器
↓
JWT
↓
Auth Middleware
↓
获取当前 UserCode
↓
Permission Middleware
↓
检查 system:user:list
↓
DataScopeService
↓
获取用户
↓
获取角色
↓
计算角色 DataScope
↓
递归部门树
↓
合并部门权限
↓
生成 UserDataScope
↓
GORM Scope
↓
WHERE user_code = ?
OR dept_code IN (...)
↓
数据库
↓
返回允许查看的数据
这时候:
RBAC 和 Data Scope 才真正连接起来。
四十七、这套设计为什么值得单独抽成模块?
因为以后你做:
CRM
ERP
OA
SaaS
工单系统
销售系统
内容后台
几乎都会碰到:
同一个功能,不同人看到不同数据。
例如 CRM:
销售
→ 看自己的客户
销售经理
→ 看团队客户
区域负责人
→ 看整个区域客户
老板
→ 看所有客户
其实还是:
self
dept
deptAndChild
custom
all
所以这一套设计完全可以复用。
最后
很多后台项目做到:
JWT
+
RBAC
就觉得权限系统已经结束了。
其实真正进入企业项目以后你会发现:
能不能访问一个接口,只是权限问题的一半。
另一半是:
访问这个接口以后,你到底能看到多少数据。
ShiyuAdmin 里我把数据范围最终收敛成:
type UserDataScope struct {
All bool
UserCode string
DeptCodes []string
}
复杂的:
角色
部门
子部门
自定义部门
多个角色
最后全部转换成:
WHERE ...
这才是数据权限真正落地的地方。
所以如果你正在学习 Go + Gin + GORM,别只会写:
if role == "admin"
可以尝试真正实现一次:
RBAC
+
Data Scope
+
Department Tree
+
GORM Scope
你会对企业后台的权限设计理解得更深。
因为成熟权限系统最终解决的不是:
“这个按钮给不给你看?”
而是:
“系统里的哪一行数据,才真正属于你的权限范围?”

浙公网安备 33010602011771号