Featured image of post 几种数据权限控制方案的探索与最佳实践

几种数据权限控制方案的探索与最佳实践

从硬编码到 SQL 改写、从 AOP 拦截到 MyBatis 拦截器——把数据权限做对的几种姿势

数据权限是什么,为什么麻烦

很多人混淆"权限"的两个层面:

  • 功能权限(菜单/按钮/接口能不能访问)——一般用 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);
}

它的问题:

  1. 重复劳动——每个查询接口都要写一遍这套判断
  2. 极易漏写——新增接口忘了加权限,瞬间漏数据
  3. 业务和权限耦合——业务代码看不清,“业务逻辑 + 权限逻辑"混在一起
  4. 角色变更代价大——新加一种角色要改几十处接口

生产环境永远不应该这样写。 但它是所有数据权限故事的起点——理解了它的痛,才能理解后面方案的价值。


方案二:注解 + 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_idpath(如 /1/3/8/),子部门用 LIKE 查就行:

1
WHERE dept.path LIKE '/1/3/%'

2. 基于自定义数据范围

部门模型不够灵活时,加一张"用户数据范围表":

user_iddata_scope_typedata_scope_value
100DEPT1,2,3
100OWNERself
200ALL-

权限灵活但配置复杂度上升

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 平台的做法,但对中小项目过度设计。


工程上的实战建议

具体几条建议:

  1. 先理清模型再写代码——画清楚"角色 → 范围 → 表"的对应表
  2. 优先选侵入小的方案——MyBatis 拦截器 / MP 多租户插件好过 AOP,AOP 好过硬编码
  3. 白名单和黑名单要明确——字典表、系统配置表不应该被权限过滤;防止"管理员配置了字典却看不到"
  4. 失败要"看不到"而不是"报错"——越权应该是"你不该看到的就直接没返回",不是抛异常告诉攻击者"你越权了"
  5. 测试用例必须覆盖——每种角色都要专门测试:A 角色看到的、不该看到的、边界数据
  6. 审计日志必须打——重要查询的"用户身份 + 看到的数据范围"要记录,事后追溯
  7. 写操作也要校验——别只做查询权限;UPDATE/DELETE 一样要校验"这条数据是不是这个用户能改的"

小结

数据权限做对的关键不在框架选型,在模型抽象和工程纪律

把全文压一句:

数据权限是『隐式过滤』——业务代码不该感知它,但它必须无处不在。

不同规模的项目对应方案:

  • 小项目、固定几种角色 → 注解 + AOP,把规则集中起来
  • 多租户 SaaS → MP TenantLine 插件,零侵入自动隔离
  • 复杂角色 × 部门 × 自定义范围 → MyBatis 拦截器 + JSqlParser 自动改写
  • 强合规 → 数据库 RLS

不管哪一种,让"忘记加权限"在架构上不可能发生——这才是真正的数据权限设计。

使用 Hugo 构建
主题 StackJimmy 设计