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

你会对企业后台的权限设计理解得更深。

因为成熟权限系统最终解决的不是:

“这个按钮给不给你看?”

而是:

“系统里的哪一行数据,才真正属于你的权限范围?”

posted @ 2026-09-13 19:49  JavaPub  阅读(12)  评论(0)    收藏  举报