很多后台管理系统做到 RBAC,就觉得权限系统结束了。
比如给用户分配一个角色:
张三 ↓ 部门管理员 ↓ system:user:list于是张三可以进入:
系统管理 → 用户管理接口也能正常访问:
GET /api/v1/system/users看起来权限没问题。
但真正麻烦的问题来了:
张三到底应该看到哪些用户?
是全公司的 10000 个用户?
还是自己部门的 30 个人?
还是自己部门以及下面所有子部门?
甚至只能看到自己?
这时候普通 RBAC 就不够用了。
因为 RBAC 解决的是:
你能不能调用这个接口?而数据权限解决的是:
调用接口以后,你到底能看到哪些数据?这两个问题完全不是一回事。
我最近在整理 ShiyuAdmin:
https://github.com/Rodert/ShiyuAdmin里面就专门拆了一层:
Data Scope今天我们从代码层面把这个东西讲透。
一、接口权限和数据权限有什么区别?
先看一个最简单的例子。
假设系统里有三个部门:
总公司 ├── 技术部 │ ├── Java 组 │ └── Go 组 │ └── 市场部系统里有这些用户:
王总 总公司 张三 技术部 李四 Java组 王五 Go组 赵六 市场部现在张三拥有:
system:user:list意味着:
张三可以访问用户列表接口但并不意味着:
张三可以看全公司所有用户真正返回什么数据,还应该继续判断:
张三的数据范围是什么?例如:
dept那么张三只能看:
技术部如果是:
deptAndChild那么可以看:
技术部 Java组 Go组如果是:
self那就只能看:
张三自己所以完整权限模型应该是两层:
第一层:功能权限 system:user:list system:user:create system:user:update system:user:delete ↓ 第二层:数据权限 all dept deptAndChild custom self这才更接近企业后台真实的权限系统。
二、把 DataScope 放到角色上
一种比较常见的设计,是把数据权限范围放到角色上。
例如:
typeRolestruct{IDint64RoleCodestringRoleNamestringRoleKeystringDataScopestringStatusint}那么不同角色可以配置:
超级管理员 DataScope = all部门管理员:
DataScope = deptAndChild普通员工:
DataScope = self自定义区域负责人:
DataScope = custom这样角色不只是决定:
能干什么还决定:
能看到什么三、常见的 5 种数据权限
我比较推荐后台系统至少支持下面几种。
1. all:全部数据
例如:
超级管理员 老板 系统管理员可以看到所有用户。
逻辑:
ifdataScope=="all"{return&UserDataScope{All:true,}}最终 SQL 基本没有额外限制:
SELECT*FROMsys_users;2. dept:当前部门
比如张三属于:
技术部那么:
DataScope = dept最终查询:
SELECT*FROMsys_usersWHEREdept_code='TECH';只返回技术部直属人员。
3. deptAndChild:部门及子部门
这个就有意思了。
例如组织结构:
技术部 TECH ├── Java组 JAVA │ ├── Java一组 JAVA_01 │ └── Java二组 JAVA_02 │ └── Go组 GO张三属于:
TECH如果权限是:
deptAndChild那么最终应该允许:
TECH JAVA JAVA_01 JAVA_02 GOSQL:
SELECT*FROMsys_usersWHEREdept_codeIN('TECH','JAVA','JAVA_01','JAVA_02','GO');问题来了。
怎么找到所有子部门?
四、部门树怎么展开?
ShiyuAdmin 当前的思路比较直观。
先把全部部门查出来:
depts,err:=deptRepo.List(ctx)iferr!=nil{returnerr}然后构造:
ParentCode → Children映射。
代码可以写成:
childrenByParent:=make(map[string][]*Dept)for_,dept:=rangedepts{ifdept==nil{continue}childrenByParent[dept.ParentCode]=append(childrenByParent[dept.ParentCode],dept,)}假设数据:
TECH JAVA parent=TECH GO parent=TECH JAVA_01 parent=JAVA JAVA_02 parent=JAVA最终 Map 大概是:
TECH ├── JAVA └── GO JAVA ├── JAVA_01 └── JAVA_02然后递归遍历。
varwalkfunc(parentCodestring)walk=func(parentCodestring){children:=childrenByParent[parentCode]for_,child:=rangechildren{ifchild==nil{continue}deptCodes[child.DeptCode]=struct{}{}walk(child.DeptCode)}}最后调用:
walk("TECH")就能拿到:
TECH JAVA JAVA_01 JAVA_02 GO五、为什么用 map[string]struct{}?
这里顺带讲一个 Go 里面非常常见的小技巧。
我们需要保存:
部门编码集合同时又要防止重复。
很多人第一反应:
[]string例如:
vardeptCodes[]string但是这样判断重复就比较麻烦。
更适合使用:
map[string]struct{}例如:
deptCodes:=map[string]struct{}{}添加:
deptCodes["TECH"]=struct{}{}deptCodes["JAVA"]=struct{}{}deptCodes["GO"]=struct{}{}判断:
if_,exists:=deptCodes["JAVA"];exists{// 已存在}最后再转:
funcmapKeys(valuesmap[string]struct{},)[]string{result:=make([]string,0,len(values))forkey:=rangevalues{result=append(result,key)}returnresult}这是 Go 中实现:
Set最常用的方式之一。
六、第四种:custom 自定义数据权限
企业后台经常还有一种需求:
华北负责人但是组织架构可能并不是:
华北 ├── 北京 ├── 天津 └── 河北甚至几个部门完全不在同一棵子树下面。
这时候就不能简单使用:
deptAndChild因此可以增加:
custom然后引入:
角色部门关联表例如:
sys_role_depts数据:
role_code dept_code north_admin beijing north_admin tianjin north_admin hebei实体:
typeRoleDeptstruct{IDint64RoleCodestringDeptCodestring}查询:
depts,err:=roleDeptRepo.GetRoleDepts(ctx,role.RoleCode,)然后加入集合:
for_,dept:=rangedepts{ifdept==nil{continue}deptCodes[dept.DeptCode]=struct{}{}}这样后台管理员可以自己勾选:
☑ 北京 ☑ 天津 ☑ 河北 ☐ 上海 ☐ 深圳形成自定义数据权限。
七、第五种:self 仅本人
这是最严格的一种。
例如普通员工只能看到:
自己的数据最终查询:
SELECT*FROMsys_usersWHEREuser_code='USER001';在代码里,可以表示成:
UserDataScope{UserCode:"USER001",}八、真正麻烦的问题:一个用户有多个角色怎么办?
这才是数据权限最值得讨论的地方。
假设张三同时有两个角色:
Role A DataScope = dept以及:
Role B DataScope = customRole A 允许:
技术部Role B 允许:
市场部那么最终张三应该看到什么?
一般有两种模型。
一种是:
交集也就是:
A ∩ B权限越多,数据反而越少。
这种比较少见。
另一种是:
并集也就是:
A ∪ B张三最终能看到:
技术部 + 市场部后台 RBAC 一般更适合使用:
并集模型
也就是:
用户拥有的多个角色权限累加九、ShiyuAdmin 怎么合并多个 DataScope?
可以定义一个统一结构:
typeUserDataScopestruct{AllboolUserCodestringDeptCodes[]string}然后遍历用户的所有角色。
roles,err:=userRoleRepo.GetUserRoles(ctx,userCode,)初始化:
deptCodes:=map[string]struct{}{}includeSelf:=false遍历:
for_,role:=rangeroles{ifrole==nil||role.Status!=1{continue}switchrole.DataScope{case"all":return&UserDataScope{All:true,},nilcase"dept":deptCodes[user.DeptCode]=struct{}{}case"deptAndChild":addDeptAndChildren(ctx,deptCodes,user.DeptCode,)case"custom":addRoleDepts(ctx,deptCodes,role.RoleCode,)default:includeSelf=true}}注意这里一个非常关键的地方:
case"all":return为什么直接返回?
因为:
all ∪ 任意集合 = all既然其中一个角色已经拥有全部数据:
后面其他角色就没有必要继续计算十、最终把权限模型变成 SQL 条件
前面做了一大堆事情,最后目的其实只有一个:
拼出正确的 WHERE 条件
例如最终算出来:
scope:=&UserDataScope{UserCode:"USER001",DeptCodes:[]string{"TECH","JAVA","GO",},}Repository 层再应用它。
例如:
funcapplyUserScope(query*gorm.DB,userCodestring,deptCodes[]string,)*gorm.DB{switch{caseuserCode!=""&&len(deptCodes)>0:returnquery.Where("(user_code = ? OR dept_code IN ?)",userCode,deptCodes,)caseuserCode!="":returnquery.Where("user_code = ?",userCode,)caselen(deptCodes)>0:returnquery.Where("dept_code IN ?",deptCodes,)default:returnquery.Where("1 = 0")}}这里面最值得注意的,其实是最后一句:
returnquery.Where("1 = 0")十一、为什么没有权限时要WHERE 1 = 0?
这是整套设计里我很喜欢的一个细节。
假设由于某个 Bug:
UserCode = "" DeptCodes = []很多程序员可能会这么写:
returnquery结果是什么意思?
意味着:
SELECT*FROMsys_users;也就是:
权限计算异常,反而看到了全部数据。
这在权限系统里非常危险。
正确思路应该是:
我不知道你有什么权限 ↓ 默认不给权限所以:
WHERE1=0最终:
SELECT*FROMsys_usersWHERE1=0;结果:
0 条数据这就是安全领域一个非常重要的原则:
Fail Closed
出现异常时:
默认拒绝而不是:
默认放行十二、Fail Open 和 Fail Closed
两者区别非常简单。
Fail Open
权限服务异常:
权限判断失败 ↓ 算了,让他进去这是:
Fail Open优点:
业务可用性高问题:
可能产生安全事故Fail Closed
权限判断失败:
权限无法确定 ↓ 拒绝访问这是:
Fail Closed权限系统一般更应该选择:
Fail Closed尤其:
后台管理 支付 财务 用户数据 订单数据 敏感操作宁可少返回一些数据,也不要意外泄露全部数据。
十三、Controller 层不要自己拼部门 SQL
还有一个很重要的代码设计。
很多项目最后会写成:
funcListUser(c*gin.Context){ifrole=="admin"{}elseifrole=="dept_admin"{}elseifrole=="user"{}}然后:
db.Where(...)这种代码一多,后面基本没法维护。
更合理的分层应该是:
Controller ↓ DataScopeService ↓ UserDataScope ↓ Service ↓ Repository ↓ SQLController 只负责:
我要查询用户列表DataScopeService 负责:
这个用户能看哪些范围Repository 负责:
把范围变成 SQL职责完全分开。
十四、完整请求链路
最终一次:
GET /api/v1/system/users可以经历:
HTTP Request ↓ JWT Middleware ↓ 获得 UserCode ↓ RBAC Permission Middleware ↓ 检查 system:user:list ↓ DataScopeService ↓ 读取 User ↓ 读取 User Roles ↓ 解析 Role.DataScope ↓ 计算部门集合 ↓ UserDataScope ↓ UserService ↓ UserRepository ↓ WHERE 条件 ↓ Database ↓ 只返回有权限的数据这里可以非常清楚地看出来:
RBAC和:
DataScope其实解决的是完全不同的事情。
十五、如果部门有 10 万个怎么办?
再往生产环境考虑一步。
现在:
deptRepo.List(ctx)把全部部门加载出来,再在内存构造:
Parent → Children对于普通后台:
几十 几百 几千个部门完全够用。
但如果是大型组织:
10 万部门节点每次算数据权限都把整张部门表加载出来,就不合适了。
这时候可以继续优化。
比如:
方案一:Redis 缓存部门树结构:
dept:children:TECH保存:
JAVA GO AI还可以使用:
Closure Table例如专门建:
sys_dept_closure保存:
ancestor descendant depth TECH TECH 0 TECH JAVA 1 TECH JAVA_01 2 TECH JAVA_02 2查询技术部全部子部门:
SELECTdescendantFROMsys_dept_closureWHEREancestor='TECH';不用递归。
这种方案非常适合:
层级复杂 查询特别频繁 组织架构非常大的系统。
十六、也可以使用路径字段
还有一种常见方案:
Materialized Path给部门保存:
path例如:
技术部 /TECH/Java:
/TECH/JAVA/Java 一组:
/TECH/JAVA/JAVA_01/查询所有技术部子部门:
SELECT*FROMsys_deptsWHEREpathLIKE'/TECH/%';实现简单。
但是移动部门节点时:
整个子树的 path 都需要更新所以各种树结构方案都有取舍。
十七、数据权限不仅能控制用户表
DataScope 真正有价值的地方在于:
它应该是一套通用能力。
例如用户:
WHEREdept_codeIN(...)订单:
WHEREcreator_dept_codeIN(...)客户:
WHEREowner_dept_codeIN(...)操作日志:
WHEREoperator_dept_codeIN(...)工单:
WHEREhandler_dept_codeIN(...)最终:
DataScopeService可以成为整个后台系统统一的数据安全边界。
十八、再复杂一点:业务归属不一定等于用户部门
这里还有一个真实项目经常遇到的问题。
例如销售系统。
张三属于:
北京销售部但是他负责的客户可能包括:
北京 天津 河北这时候:
用户 DeptCode已经无法直接决定:
客户数据范围需要进一步抽象:
组织权限 业务权限 资源权限甚至最后会演变成:
RBAC + ABACABAC:
Attribute-Based Access Control也就是:
基于属性的访问控制例如:
用户地区 = 华北 AND 订单地区 = 华北 AND 订单金额 < 100000才能访问。
这也是权限系统从:
简单后台逐渐演化成:
企业权限平台的过程。
最后
很多人学习后台权限,只停留在:
User ↓ Role ↓ Menu但真正到了项目里,RBAC 只是第一步。
完整的问题其实是:
这个人能不能进入页面? ↓ 这个人能不能调用接口? ↓ 这个人能不能点击按钮? ↓ 这个人调用接口以后能看到哪些数据? ↓ 这些数据里哪些又允许修改和删除?所以一个稍微完整一点的后台权限体系,应该至少包括:
JWT + RBAC + Permission + Department + Data Scope + Repository SQL Filter而且一定记住一个原则:
权限算不清楚的时候, 宁可返回 0 条数据, 也不要默认返回全部数据。也就是:
Fail Closed
这类细节,才是真实后台系统和普通 CRUD Demo 之间的区别。
项目代码:
https://github.com/Rodert/ShiyuAdmin如果你正在学 Go + Gin + Gorm,这个数据权限模块其实很适合自己完整实现一遍。
因为你会同时碰到:
RBAC 多角色合并 部门树 递归 Set 去重 Gorm 动态查询 Repository 分层 数据安全 Fail Closed这些才是真正值得练的后端基本功。