数据权限是什么,为什么麻烦
很多人混淆"权限"的两个层面:
- 功能权限(菜单/按钮/接口能不能访问)——一般用 RBAC 解决,前端隐藏菜单 + 后端拦截器校验角色
- 数据权限(同一个接口看到的数据范围不同)——更隐蔽、更难做对、出问题更严重
举个最常见的例子:
销售经理 A 调 /orders 接口,应该只看到自己团队的订单;销售经理 B 调同一个接口,应该只看到自己团队的;总监能看到全部。
接口完全相同,入参完全相同,但每个人看到的数据不一样。这就是数据权限。
数据权限做错了通常造成两类问题:
- 越权(漏数据):用户看到了不该看到的(合规事故、信息泄漏)
- 过滤(少数据):用户该看到却看不到(业务投诉)
下面把工程里常见的几种方案过一遍,从最朴素到最优雅。
方案一:在业务方法里硬写过滤条件
最朴素的写法——在每个查询接口里手动加 where:
1
2
3
4
5
6
7
8
9
10
11
12
13
| public List<Order> queryOrders(OrderQuery q) {
Long currentUserId = UserContext.current().getUserId();
String role = UserContext.current().getRole();
if ("ADMIN".equals(role)) {
// 看全部
} else if ("MANAGER".equals(role)) {
q.setTeamId(getMyTeamId(currentUserId));
} else {
q.setOwnerUserId(currentUserId);
}
return orderMapper.selectByQuery(q);
}
|
它的问题:
- 重复劳动——每个查询接口都要写一遍这套判断
- 极易漏写——新增接口忘了加权限,瞬间漏数据
- 业务和权限耦合——业务代码看不清,“业务逻辑 + 权限逻辑"混在一起
- 角色变更代价大——新加一种角色要改几十处接口
生产环境永远不应该这样写。 但它是所有数据权限故事的起点——理解了它的痛,才能理解后面方案的价值。
方案二:注解 + AOP 拦截
把"这个接口受数据权限控制"变成一个声明:
1
2
3
4
| @DataScope(scopeType = "ORDER", deptColumn = "dept_id", userColumn = "owner_id")
public List<Order> queryOrders(OrderQuery q) {
return orderMapper.selectByQuery(q);
}
|
切面在方法执行前,根据当前用户的角色和数据权限规则,把对应的过滤条件塞到查询参数里:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @Around("@annotation(scope)")
public Object inject(ProceedingJoinPoint pjp, DataScope scope) throws Throwable {
Object[] args = pjp.getArgs();
for (Object arg : args) {
if (arg instanceof BaseQuery q) {
String filter = buildFilter(scope);
q.setDataScopeSql(filter); // 塞条件
}
}
return pjp.proceed();
}
private String buildFilter(DataScope scope) {
User u = UserContext.current();
if (u.hasRole("ADMIN")) return "";
if (u.hasRole("MANAGER")) {
return scope.deptColumn() + " IN (" + getDeptIds(u) + ")";
}
return scope.userColumn() + " = " + u.getUserId();
}
|
XML 里把条件塞进 SQL:
1
2
3
4
5
6
7
8
9
| <select id="selectByQuery" parameterType="OrderQuery" resultType="Order">
SELECT * FROM orders
<where>
<if test="status != null">AND status = #{status}</if>
<if test="dataScopeSql != null and dataScopeSql != ''">
AND ${dataScopeSql}
</if>
</where>
</select>
|
这是 若依(RuoYi)等开源后台管理系统的经典做法。
它的优劣
优点:
- 业务代码干净——只多一个注解
- 权限规则集中——所有角色判断都在切面里
- 接入新接口成本低
缺点:
${...} 拼接字符串——SQL 注入风险,必须严格清理输入- 强依赖业务参数对象的字段——
setDataScopeSql 要在每个 Query 上都有 - AOP 时机偏晚——不是真正的"对所有 SQL 通用”,跨表 join、子查询场景容易漏
方案三:MyBatis 拦截器改写 SQL
更通用的姿势——直接在 SQL 执行前改写它。MyBatis 的 Interceptor 可以拦截 Executor.query,拿到原始 SQL 后用 SQL 解析器(Druid / JSqlParser)改写:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| @Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class DataScopeInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler sh = (StatementHandler) invocation.getTarget();
BoundSql boundSql = sh.getBoundSql();
String origin = boundSql.getSql();
String rewritten = rewrite(origin);
ReflectUtil.setField(boundSql, "sql", rewritten);
return invocation.proceed();
}
private String rewrite(String sql) {
// 用 JSqlParser 解析,识别要保护的表,加上 where
Statement stmt = CCJSqlParserUtil.parse(sql);
// 遍历所有 SELECT,检测到包含 orders 表时附加 dept_id IN (...)
// ...
return modified;
}
}
|
MyBatis-Plus 的多租户、数据权限插件都是这个思路:
1
2
3
4
5
6
| public class TenantSqlParser implements ISqlParser {
@Override
public SqlInfo processParser(Statement statement, ...) {
// 自动给 SELECT/UPDATE/DELETE 加上 tenant_id = ?
}
}
|
它的优劣
优点:
- 覆盖所有 SQL——不管 Mapper 怎么写,最终改写的是真正执行的 SQL
- 业务零侵入——不需要加注解、不需要改 Mapper
- 能处理 join、子查询——SQL 解析器懂语法树
缺点:
- 实现复杂——JSqlParser 要熟,否则改写错了就 SQL 报错
- 性能开销——每条 SQL 都过一遍解析器(不过相对 DB 耗时可忽略)
- 白名单/黑名单维护——不是所有表都受数据权限限制(系统配置表、字典表),要有规则识别
方案四:多租户场景的"自动 tenant_id"
如果你的数据权限本质就是"按租户隔离",可以更简单——所有表都加一个 tenant_id 字段,所有 SQL 都自动带上 WHERE tenant_id = ?。
MyBatis-Plus 的实现方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
| @Bean
public MybatisPlusInterceptor mpInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override public Expression getTenantId() {
return new LongValue(UserContext.current().getTenantId());
}
@Override public boolean ignoreTable(String tableName) {
return Arrays.asList("sys_dict", "sys_config").contains(tableName);
}
}));
return interceptor;
}
|
之后所有查询自动带 tenant_id,包括子查询和 join 内部。
这是 SaaS 系统数据隔离的事实标准。
方案五:基于行级安全(RLS)的数据库原生方案
Postgres / Oracle 都支持 Row-Level Security,可以让数据库根据"当前 session 的标识"自动过滤行:
1
2
3
4
5
6
7
8
| -- Postgres
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id')::bigint);
-- 应用每次连接后设置 session 变量
SET app.tenant_id = 123;
|
优势:从数据库层杜绝越权,业务代码完全无感,连忘记加权限的可能都没有。
劣势:
- MySQL 不原生支持(要靠 view + trigger 模拟,复杂)
- 需要在每次拿连接后设置 session(连接池场景要小心)
- 数据库故障时业务排查多一层
- 跨 DB 厂商兼容性差
金融、医疗等强合规场景值得用,普通业务大可不必。
设计层面:数据权限模型怎么搞
不论用哪种实现方案,数据权限的核心是『谁、对什么数据、有什么操作权』。常见模型:
1. 基于部门/组织树
1
2
3
4
5
| 公司
├─ 销售一部
│ ├─ A 组
│ └─ B 组
└─ 销售二部
|
权限范围一般有 4 种:
- 本人:
owner_id = currentUserId - 本部门:
dept_id = currentDeptId - 本部门及子部门:
dept_id IN (currentDept + 所有子部门) - 全部
实现关键是部门树要有层级关系——通常每个 dept 表里有 parent_id 加 path(如 /1/3/8/),子部门用 LIKE 查就行:
1
| WHERE dept.path LIKE '/1/3/%'
|
2. 基于自定义数据范围
部门模型不够灵活时,加一张"用户数据范围表":
| user_id | data_scope_type | data_scope_value |
|---|
| 100 | DEPT | 1,2,3 |
| 100 | OWNER | self |
| 200 | ALL | - |
权限灵活但配置复杂度上升。
3. 基于策略(Policy)
更进阶——每条策略描述"什么角色能看什么条件下的什么数据":
1
2
3
4
5
6
7
| policies:
- role: SALES_MANAGER
resource: order
scope: "dept_id IN ${myDept + childDept}"
- role: FINANCE
resource: order
scope: "amount > 0 AND status != 'DRAFT'"
|
这是大型系统、SaaS 平台的做法,但对中小项目过度设计。
工程上的实战建议
flowchart TD
Start([需要做数据权限?])
Start --> Type{什么类型?}
Type -->|租户隔离| MT[MP TenantLine 插件
所有 SQL 自动加 tenant_id]
Type -->|按部门看数据| Mode{规则简单 or 复杂?}
Mode -->|简单 4 种范围| AOP[注解 + AOP + Query 字段]
Mode -->|复杂自由组合| Interceptor[MyBatis 拦截器 + JSqlParser 改写]
Type -->|强合规要求| RLS[数据库 RLS]具体几条建议:
- 先理清模型再写代码——画清楚"角色 → 范围 → 表"的对应表
- 优先选侵入小的方案——MyBatis 拦截器 / MP 多租户插件好过 AOP,AOP 好过硬编码
- 白名单和黑名单要明确——字典表、系统配置表不应该被权限过滤;防止"管理员配置了字典却看不到"
- 失败要"看不到"而不是"报错"——越权应该是"你不该看到的就直接没返回",不是抛异常告诉攻击者"你越权了"
- 测试用例必须覆盖——每种角色都要专门测试:A 角色看到的、不该看到的、边界数据
- 审计日志必须打——重要查询的"用户身份 + 看到的数据范围"要记录,事后追溯
- 写操作也要校验——别只做查询权限;UPDATE/DELETE 一样要校验"这条数据是不是这个用户能改的"
小结
数据权限做对的关键不在框架选型,在模型抽象和工程纪律。
把全文压一句:
数据权限是『隐式过滤』——业务代码不该感知它,但它必须无处不在。
不同规模的项目对应方案:
- 小项目、固定几种角色 → 注解 + AOP,把规则集中起来
- 多租户 SaaS → MP TenantLine 插件,零侵入自动隔离
- 复杂角色 × 部门 × 自定义范围 → MyBatis 拦截器 + JSqlParser 自动改写
- 强合规 → 数据库 RLS
不管哪一种,让"忘记加权限"在架构上不可能发生——这才是真正的数据权限设计。