尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

RBAC 只能管按钮,真正难的是数据权限:从 ShiyuAdmin 拆解 Data Scope 设计

发布时间:2026/9/8 7:16:45

资讯中心
01
ARTICLE

RBAC 只能管按钮,真正难的是数据权限:从 ShiyuAdmin 拆解 Data Scope 设计

RBAC 只能管按钮,真正难的是数据权限:从 ShiyuAdmin 拆解 Data Scope 设计
很多后台管理系统做到 RBAC就觉得权限系统结束了。比如给用户分配一个角色张三 ↓ 部门管理员 ↓ system:user:list于是张三可以进入系统管理 → 用户管理接口也能正常访问GET /api/v1/system/users看起来权限没问题。但真正麻烦的问题来了张三到底应该看到哪些用户是全公司的 10000 个用户还是自己部门的 30 个人还是自己部门以及下面所有子部门甚至只能看到自己这时候普通 RBAC 就不够用了。因为 RBAC 解决的是你能不能调用这个接口而数据权限解决的是调用接口以后你到底能看到哪些数据这两个问题完全不是一回事。我最近在整理 ShiyuAdminhttps://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全部数据例如超级管理员 老板 系统管理员可以看到所有用户。逻辑ifdataScopeall{returnUserDataScope{All:true,}}最终 SQL 基本没有额外限制SELECT*FROMsys_users;2. dept当前部门比如张三属于技术部那么DataScope dept最终查询SELECT*FROMsys_usersWHEREdept_codeTECH;只返回技术部直属人员。3. deptAndChild部门及子部门这个就有意思了。例如组织结构技术部 TECH ├── Java组 JAVA │ ├── Java一组 JAVA_01 │ └── Java二组 JAVA_02 │ └── Go组 GO张三属于TECH如果权限是deptAndChild那么最终应该允许TECH JAVA JAVA_01 JAVA_02 GOSQLSELECT*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{ifdeptnil{continue}childrenByParent[dept.ParentCode]append(childrenByParent[dept.ParentCode],dept,)}假设数据TECH JAVA parentTECH GO parentTECH JAVA_01 parentJAVA JAVA_02 parentJAVA最终 Map 大概是TECH ├── JAVA └── GO JAVA ├── JAVA_01 └── JAVA_02然后递归遍历。varwalkfunc(parentCodestring)walkfunc(parentCodestring){children:childrenByParent[parentCode]for_,child:rangechildren{ifchildnil{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{resultappend(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{ifdeptnil{continue}deptCodes[dept.DeptCode]struct{}{}}这样后台管理员可以自己勾选☑ 北京 ☑ 天津 ☑ 河北 ☐ 上海 ☐ 深圳形成自定义数据权限。七、第五种self 仅本人这是最严格的一种。例如普通员工只能看到自己的数据最终查询SELECT*FROMsys_usersWHEREuser_codeUSER001;在代码里可以表示成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{ifrolenil||role.Status!1{continue}switchrole.DataScope{caseall:returnUserDataScope{All:true,},nilcasedept:deptCodes[user.DeptCode]struct{}{}casedeptAndChild:addDeptAndChildren(ctx,deptCodes,user.DeptCode,)casecustom:addRoleDepts(ctx,deptCodes,role.RoleCode,)default:includeSelftrue}}注意这里一个非常关键的地方caseall: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这是整套设计里我很喜欢的一个细节。假设由于某个 BugUserCode DeptCodes []很多程序员可能会这么写returnquery结果是什么意思意味着SELECT*FROMsys_users;也就是权限计算异常反而看到了全部数据。这在权限系统里非常危险。正确思路应该是我不知道你有什么权限 ↓ 默认不给权限所以WHERE10最终SELECT*FROMsys_usersWHERE10;结果0 条数据这就是安全领域一个非常重要的原则Fail Closed出现异常时默认拒绝而不是默认放行十二、Fail Open 和 Fail Closed两者区别非常简单。Fail Open权限服务异常权限判断失败 ↓ 算了让他进去这是Fail Open优点业务可用性高问题可能产生安全事故Fail Closed权限判断失败权限无法确定 ↓ 拒绝访问这是Fail Closed权限系统一般更应该选择Fail Closed尤其后台管理 支付 财务 用户数据 订单数据 敏感操作宁可少返回一些数据也不要意外泄露全部数据。十三、Controller 层不要自己拼部门 SQL还有一个很重要的代码设计。很多项目最后会写成funcListUser(c*gin.Context){ifroleadmin{}elseifroledept_admin{}elseifroleuser{}}然后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_closureWHEREancestorTECH;不用递归。这种方案非常适合层级复杂 查询特别频繁 组织架构非常大的系统。十六、也可以使用路径字段还有一种常见方案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 ABACABACAttribute-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这些才是真正值得练的后端基本功。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。