做中后台框架的人都知道,权限这东西表面看是一张表、几个接口的事,可真要到企业级落地,数据权限体系往往是最容易让人挠头的地方。我一直在迭代自己的一个全栈中后台框架,代号“风行”,定位就是给企业级内部系统提供开箱即用的底座。在我把它陆续接入真实业务验证的过程中,最让我出乎意料的需求不是多租户、不是工作流,而是数据权限体系的设计与实现。这套东西做得好不好,直接决定了框架能不能在企业内部真正站住脚。这篇文章不打算抛开细节谈概念,我会把设计思路、表结构、SQL注入方案、缓存策略、常见坑全捋一遍,给正在做同类中后台系统的人一份能直接落地的参考。
1. 为什么中后台必须有一套独立的数据权限体系
1.1 一次让我半夜爬起来改代码的权限事故
先讲一个真实到不能再真实的场景。某公司内部有个人事系统,销售部门可以查看自己团队的客户订单,原本以为加个“销售角色能访问订单模块”的功能权限就完事了。结果有一天,一个普通销售直接在接口里改了一下订单编号,发现能把别的部门同事的订单明细拉出来。问题根源不在于功能权限没做,而在于列表查询接口压根没有做行级的数据过滤。换句话说,系统只校验了“你能不能进这个模块”,没有校验“你能看到这个模块里的哪些数据”。
这个事故让我意识到一件事:企业级中后台,光有功能权限远远不够。功能权限回答的是“能不能访问”,数据权限体系回答的则是“能访问到哪一行、哪一列”。后者往往才是企业内部真正敏感的边界。于是我在“风行”框架里从一开始就把数据权限体系作为一等公民来设计,而不是等出了问题再打补丁。
1.2 功能权限和数据权限之间隔着一条河
很多人会把这两个概念混在一起,其实它们之间的差别非常明确,我通常用一个类比来解释:功能权限相当于门禁卡,决定你能进公司的哪几间办公室;数据权限相当于办公室里的保险柜权限,决定你能打开哪些抽屉、能看到哪些文件。门禁卡发得再正确,如果保险柜的钥匙随便锁,照样会出事。
功能权限一般落在URL、按钮、菜单层面,控制的是“入口”;数据权限则落在数据库查询的行级别,控制的是“可见范围”。举个例子,同样的一个“订单列表”接口,财务角色看到的是全部订单,部门主管看到的是自己部门下的订单,业务员只能看到自己创建的订单。接口地址一样、返回结构一样,唯一不同的就是SQL语句后面追加的WHERE条件。数据权限体系要做的,就是把这个追加条件的过程变得自动、可控、可审计。
| 对比项 | 功能权限 | 数据权限 |
|---|---|---|
| 控制粒度 | 页面、按钮、接口 | 行级、部分场景列级 |
| 实现位置 | 路由守卫、接口拦截 | SQL条件、查询执行器 |
| 典型问题 | 越权访问模块 | 越权访问数据 |
| 失效后果 | 功能不可用 | 数据泄露 |
2. 数据权限体系的设计基座
2.1 选型:在RBAC之上再叠一层数据范围
权限模型的选择有很多种,ACL、RBAC、ABAC各有生存空间。我在“风行”里选择的是RBAC作为底座,因为中后台系统的角色模型足够清晰,而且运维同学对角色授权这件事已经很熟悉了。但RBAC天然解决不了数据范围的问题,角色只能告诉你“谁能访问订单模块”,不能告诉你“他能看哪几个订单”。
所以我在RBAC之上又加了一层数据范围规则,每个角色除了关联菜单权限、按钮权限之外,还关联一条或者多条数据规则。数据规则定义了该角色在访问某类业务数据时的可见范围。这么设计的好处是:不推翻原有的角色模型,又能把范围控制独立出来,灵活性高很多。权限判断的过程变成了两层:先用RBAC判断有没有访问权,再用数据规则判断能看到哪些数据。
2.2 五种范围规则:从全部到仅本人
我把实际需求整理了一遍,发现企业里的数据范围不管怎么变,基本上跑不出下面这五种模式。这个分类不是我闭门造车想出来的,而是从十几个真实业务场景里抽出来的共性。
| 范围类型 | 说明 | 典型场景 |
|---|---|---|
| 全部数据 | 不限制范围,可见所有行 | 企业高管、系统管理员 |
| 本部门及以下 | 可见本部门和所有下级部门的数据 | 部门主管查看团队数据 |
| 仅本部门 | 只可见当前部门的数据 | 部门统计员 |
| 仅本人 | 只可见自己创建或分配的数据 | 一线业务员 |
| 按组织/用户自选 | 规则允许指定若干部门或用户 | 跨部门项目协作 |
这五种范围基本覆盖了90%以上中后台需求。需要注意的是,第五种“自选”最灵活也最容易失控,所以我在实现时做了一个限制:自选的范围值必须落在当前用户可见的组织树范围之内,不允许把一个高层部门的名单塞给一个普通员工,否则这条规则本身就成了越权漏洞。
2.3 组织架构是数据权限的天然边界
数据范围通常和组织架构强相关。中后台系统里几乎都有部门、岗位、人员的结构,部门之间天然形成了数据的边界。比如“本部门及以下”这条规则,关键点在于如何获取当前用户的所有子部门ID。很多人第一个想到的是递归查询,部门少的时候无所谓,但组织树一旦超过几千个节点,每来一次请求就递归一次,数据库会很容易被打爆。
我在“风行”里没有用实时递归,而是在组织表上维护了一条path字段。每个部门创建时都会生成类似1.12.34.的路径,查询子部门时直接通过path LIKE '1.12.%'拿到全部下级部门ID,一条SQL搞定,毫秒级返回。这样的设计虽然多占了一点存储空间,但在数据权限这种高频率的过滤场景里,收益远大于成本。
3. 数据权限体系在“风行”中的具体实现
3.1 核心表结构设计
数据权限体系的落地离不开几张核心表。很多人一上来就想着“在用户表加一个数据范围字段”,这种方案短期能用,中期就会变成灾难:多条规则时不知道取哪条,角色一变化历史范围全部错乱。我的做法是让范围规则独立成表,与角色关联。
CREATE TABLE `auth_data_rule` ( `id` bigint NOT NULL AUTO_INCREMENT, `role_id` bigint NOT NULL COMMENT '关联的角色ID', `target_module` varchar(64) NOT NULL COMMENT '业务模块标识,如order', `scope_type` tinyint NOT NULL COMMENT '1全部 2本部门及以下 3仅本部门 4仅本人 5自选', `scope_values` text NULL COMMENT 'scope_type=5时存部门ID/用户ID列表', `status` tinyint NOT NULL DEFAULT '1', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_role_module` (`role_id`, `target_module`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据权限规则表';部门表那边我额外维护了一条组织路径字段:
CREATE TABLE `sys_org` ( `id` bigint NOT NULL AUTO_INCREMENT, `parent_id` bigint NOT NULL DEFAULT '0', `org_name` varchar(64) NOT NULL, `path` varchar(255) NOT NULL COMMENT '组织路径,如 1.12.34.', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='组织架构表';这两张表是数据权限体系的基石,业务表不需要为了权限去改结构。订单表、报销表、项目表依然是自己原来的样子,权限规则只是给它们增加了一道动态过滤。
3.2 请求级上下文:先搞清楚“我是谁”
要让权限规则在查询时生效,首先要有一个贯穿整个请求周期的上下文对象。用户登录之后,JWT里只存了用户ID,但数据权限判断需要知道更多信息:角色列表、所属部门ID、是不是超级管理员、组织路径。这些信息不可能每次都查数据库,所以我在登录成功后的拦截器里提前把用户上下文加载好。
type UserContext struct { UID int64 Username string DeptID int64 DeptPath string RoleIDs []int64 IsSuper bool }这个上下文对象会通过请求级的中间件注入到当前协程的上下文中,后续任何服务层代码都能拿到。查询数据权限规则时,就可以基于这个上下文去匹配角色对应的规则,而不是临时去数据库里翻用户信息。这么做既保证了数据一致性,又把权限判断的开销降到了一个很小的范围。
3.3 SQL条件自动拼接:不侵入业务SQL的做法
数据权限体系最核心的环节就是把范围规则翻译成SQL的WHERE条件。最开始我也考虑过在业务代码里写一个工具方法,每个查询逻辑手动调用。但很快发现,一旦业务多了,必然出现三种问题:一是有人忘记调用,查出越权数据;二是调用顺序不一致,导致结果不稳定;三是规则变更时要去改每一处调用点,维护成本直线上升。
“风行”里的做法是做一个自治的数据权限组件,业务查询只需要在Mapper层声明自己属于哪个业务模块,组件会自动把过滤条件拼接到SQL上。以订单查询为例,业务的原始SQL是这样的:
SELECT * FROM biz_order WHERE order_status = ?组件在检测到当前用户不是超级管理员之后,会自动追加一段条件:
SELECT * FROM biz_order WHERE order_status = ? AND biz_order.create_by IN ( SELECT id FROM sys_user WHERE dept_id IN ( SELECT id FROM sys_org WHERE path LIKE '1.12.%' ) );这个拼接动作发生在查询执行之前,对上层业务完全透明。业务开发只需要知道自己的模块标识,不需要关心当前用户到底是“全部范围”还是“仅本人”。这就是数据权限体系能做到开箱即用的关键。
提示:动态拼接SQL时一定要使用预编译参数或者严谨的框架方法,严防把scope_values直接拼接进SQL字符串。数据权限体系本来是防越权的,如果自己先漏了SQL注入的口子,那就闹大笑话了。
3.4 规则组合策略:一个用户有多个角色怎么办
企业系统里,一个用户往往同时挂在多个角色下,比如既是销售主管,又是项目成员。这种情况下如果两个角色对同一个模块有不同的数据范围规则,怎么取?我见过简单粗暴的方案是“取并集”,就是把两条规则对应的数据全都查出来。这种方案看似合理,其实容易造成数据面过大;更危险的是“取交集”,一旦某个角色没有配置规则,直接查不到数据。
“风行”里的策略是优先级覆盖:把规则按范围大小排一个序,全部数据 > 本部门及以下 > 本部门 > 仅本人 > 自选,命中更高优先级的规则就忽略当前模块的其他规则。这样可以保证一个用户即使在多个角色里,也总能有一套确定的可见范围,不会出现“时而有权限时而没有”的幽灵问题。
4. 从零接入数据权限体系:完整实操过程
4.1 第一步:初始化组织架构和权限规则
要把数据权限体系真正用起来,第一步不是写代码,而是把组织树和规则数据初始化好。组织树我建议在导入旧系统数据时就同步生成每一条path,不要一个一个手动补齐。初始化时需要注意:父部门的path变更会波及所有子部门,所以我在框架里提供了一个重建组织的接口,修改完部门层级之后,直接调用一次就能把所有path重新刷一遍。
规则初始化相对简单,可以写一个能力中心接口,让运维在后台界面对指定角色、指定模块设置范围类型。不过这里有一点容易踩坑:模块标识的命名必须和业务代码里一致。我在“风行”里规定模块标识统一用“领域.实体”格式,例如sale.order、hr.employee、finance.receipt,避免出现“订单模块”和“订单管理”这种同一模块两种叫法的混乱。
4.2 第二步:改造业务接口,让规则生效
假设现在要改造“查询订单列表”的接口,让它受数据权限体系的保护。在“风行”的Mapper层,只需要在查询方法上声明模块标识:
type OrderMapper struct { *DataPermissionInjector } func (m *OrderMapper) SelectList(cond map[string]interface{}) ([]Order, error) { sql := "SELECT * FROM biz_order WHERE order_status = ?" // DataPermissionInjector 会在执行前自动合并当前用户的权限条件 sql, params := m.Inject("sale.order", sql, params) return runQuery(sql, params...) }这一步看似简单,但有一个非常隐蔽的坑:如果业务SQL本身已经包含WHERE子句,且这个WHERE里已经用到了子查询或者OR条件,直接追加权限条件会破坏原有SQL的逻辑。我踩过一次:原SQL是WHERE order_status = 1 OR order_status = 2,直接追加条件后变成WHERE order_status = 1 OR order_status = 2 AND create_by = 我的ID,结果被外层的OR一短路,权限条件完全没起到作用。从那以后,我在Inject方法里强制要求业务SQL必须以括号包裹原始条件,再另起一行追加权限条件,从语法层面杜绝这种遮蔽问题。
4.3 第三步:前端菜单和按钮的联动
数据权限体系虽然主要在后端实现,但前端也不能完全不管。如果后端已经过滤了数据,但前端还显示着“查看其他部门数据”的按钮,体验上非常奇怪。我的做法是登录后不仅返回菜单权限列表,还顺带返回当前用户在各个模块的数据范围类型,前端根据范围类型禁用掉某些危险的批量操作按钮。比如范围是“仅本人”的时候,批量导出全部数据的按钮直接置灰,避免用户点下去之后导出一个空文件或者被后端无情拒绝。
前端的联动只需要做减法,不需要做加法。把不该显示的入口藏起来即可,真正的安全边界必须且只能在后端维护。前端不管做得多华丽,都是锦上添花,不是安全屏障。
5. 常见问题与排查技巧实录
5.1 数据权限不生效,问题出在哪
这是我在实际接入时遇到最多的问题。用户明明配置了“仅本人”规则,查询接口却把别人的数据也带出来了。排查路径我一般按下述三步来走:第一步看当前用户上下文里的角色列表是否真的包含目标角色,很多“不生效”其实是用户没挂上角色,JWT里的角色信息和数据库不一致;第二步看模块标识是否匹配,M层声明的模块标识和规则表里的target_module有没有对齐,多一个空格都会导致匹配失败;第三步看是不是用户命中了超级管理员分支,超级管理员在很多系统里会绕过所有过滤条件,这属于正常设计,不是bug。
还有一个经常被忽略的原因:查询接口可能走的是通用查询引擎,没有经过Mapper层的权限注入器。比如有些框架会把复杂查询交给一个通用的QueryBuilder,此时原始SQL根本没有经历权限组件的处理,等于赤裸裸地绕过了数据权限体系。遇到这种场景,千万不要在QueryBuilder里再加一次过滤,而是应该强制所有查询走统一入口。
5.2 规则叠加之后出现数据重叠或数据空洞
多个角色各有数据规则时,最容易出现的是重复数据和漏数据问题。虽然我在设计里用了优先级覆盖策略,但有一种情况依然会引起空洞:某角色配置了“本部门及以下”,而另一个角色配置了“自选”但没有配置具体部门。看起来优先级高的赢了,可如果高优先级规则取不到可用范围值,最终查询条件就变成一个空集合,直接返回空列表。
我给“风行”加了一个规则完整性校验:保存规则之前,自动根据规则类型检查必填项。假如是自选类型但scope_values为空,则不允许保存;假如是本部门及以下但用户所属组织已经是根节点,则给出明确提示。配好规则之后,框架还会在后台定期跑一遍规则自检脚本,把有问题规则列出来,而不是让用户等到查询异常的时候才察觉。
5.3 性能掉坑:权限子查询拖垮列表接口
数据权限的过滤条件一旦做成子查询,就会给列表接口带来额外的执行开销。尤其是“本部门及以下”这种涉及组织子查询的范围,如果前端分页要查第500页,子查询可能要执行很多次JOIN。我在一次压测中发现,订单列表在未加权限过滤前响应时间500毫秒,加了数据权限之后直接干到4秒,检查之后发现是子查询没有命中索引。
解决办法有两个。第一个是在sys_org.path字段上加前缀索引,让path LIKE '1.12.%'走索引;第二个是把常见的范围条件映射到一张冗余的“用户可见组织快照表”,每次用户登录时动态刷新,查询时直接拿这张快照表JOIN,性能能提升很多。快照表带来的额外代价是组织变动后需要定时重建快照,我在框架里实现了一个基于版本号的重建机制,每次组织变化全局递增一个版本,用户在下一次请求时如果有新版本就会触发更新。
5.4 超级管理员被误伤
超级管理员和数据权限体系的关系非常微妙。“风行”在实现时让超级管理员无条件绕过所有数据过滤规则,因为超管本身已经拥有全部权限,再叠加数据规则不仅没有意义,反而可能导致超管看不到部分数据,排查问题的时候自己先被挡在门外。但这个绕过逻辑必须放在最顶层判断,不要放到底层SQL拼接里。一旦放到底层,后续如果有人想扩展“准超管”或“审计员”之类的半权限角色,就会非常别扭,动不动就要改核心逻辑。
还有一种情况是部分用户虽然不叫超管,但业务上确实需要查看全量数据,比如财务总监。这个时候不要给他配“全部数据”规则然后让他绕过一切,而是就走正常的规则体系,配一条范围类型为“全部数据”的规则即可。这样审计的时候能查清“谁在什么时候给谁开了全量权限”,避免权限被静默突破。
6. 落地复盘:用真金白银换来的几个心得
6.1 规则配置别硬编码在注解里,要让业务方能改
我最初实现时为了方便,把数据范围规则写在了Java式的注解上,比如@DataPermission(type=DataScope.SELF)。开发的时候确实很快,但到了实际运营阶段就出问题了:规则一改,代码就要重新编译发布。企业里的权限策略调整频率远比想象中高,业务方今天说销售要看全部门,明天又说只看本人,总不能每一次都发版吧。所以后来我把注解和数据库配置做了一个映射:注解负责标记“这个模块需要被数据权限保护”,但具体的范围规则全部放数据库。注解管有没有,规则管怎么过滤,两者彻底解耦。
如果你所在项目也打算做数据权限体系,我建议一开始就记住这个原则:权限规则的最终控制面一定在配置中心或者数据库里,代码里只保留业务模块的标记。
6.2 权限缓存要按“版本”刷新,而不是按“时间”刷新
数据权限规则本身读多写少,非常适合做缓存。但如果只做简单的过期缓存,业务方改完规则后,要等最长过期时间才能生效,很容易被质疑“系统怎么没反应”。我给“风行”设计了一个权限版本号机制:查询之前先从缓存里拿一个全局版本号,规则缓存带上这个版本号作为key的一部分;规则一旦修改,全局版本号加一,下次请求时缓存key自动失效。这样既保留了缓存带来的性能优势,又让规则变更秒级生效。
这套机制同时也能解决组织架构变更导致的范围缓存失效问题。部门树变了,所有依赖部门路径的数据规则都受影响,直接通过版本号一并刷新,比逐个清理缓存键可靠得多。
6.3 审计日志是数据权限体系的保命符
最后必须要说的是审计日志。数据权限体系做得再严密,也怕内鬼。谁在什么时间点、因为哪条角色规则、查到了哪个范围内的数据,这些信息必须全部留下来。“风行”里的审计模块会把权限实际生效后的SQL条件和用户上下文一起记录,平时不求快,但出事时能一条条回溯。我见过太多内部数据泄露事件,最后都因为缺乏权限审计而只能吃哑巴亏。
具体做法上不需要记录每条查询的完整结果集,那样日志量会爆炸。只需要记录查询对应的模块、规则类型、权限条件、返回行数这几个关键字段,足以支撑绝大多数安全溯源场景。
数据权限体系这东西,初看是个技术问题,实际上是把组织规则、数据边界和安全边界揉在一起的系统工程。我最大的感受是:不要等出了越权事故再回头补,一定要在框架层就把行级过滤做成标准能力。上面这些设计思路和踩坑记录,“风行”项目里全都验证过,照着做至少能让你少走大半年的弯路。如果大家在接入过程中有更刁钻的场景,欢迎一起交流。