元数据过滤与权限控制实战指南
这活儿说白了就是——让不同的人进到同一个系统里,看到不同的数据。不是把菜单藏起来那种“假装没权限”,而是真正落到字段级别的拦截。比如销售总监能看到全国订单,华北区的销售就只能看华北的,你俩查的是同一张表,返回来的是不同的结果。
① 核心概念解析:元数据如何驱动权限决策
很多新手容易把权限控制理解成“if (用户.角色 == 'admin')”,这只能应付最简单的场景。真实业务里权限是动态的、带上下文的。
这里说的元数据,指的是附着在数据行上的描述性标签,比如这条订单属于哪个区域、密级是几颗星、创建人归属哪个部门。权限决策的流程就是:系统拿到当前用户的身份信息(部门、职级、区域),再去跟数据行上的元数据标签做匹配,匹配上了就放行,匹配不上就过滤掉。
打个比方——数据行是商品,元数据是商品上的价签,用户手里攥着一把购物券,价签跟购物券对上了才能拿。
② 环境搭建与基础依赖安装步骤
咱们用Java生态里最成熟的方案组合:Spring Boot + MyBatis-Plus + H2(测试用)或MySQL。
新建一个Spring Boot项目,依赖选这些:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.20</version>
</dependency>
AOP是必须的,后面拦截SQL注入元数据过滤条件全靠它。数据库随便选一个,配置好application.yml里的连接信息就能跑起来。
③ 元数据结构设计与标签体系定义
别急着写代码,先把元数据的“词典”定下来。 你得搞清楚业务里有哪些维度需要做隔离。
常见的企业级元数据维度就这几类:
- 组织维度:区域(华北/华东/华南)、部门(研发/销售/售后)
- 安全维度:密级(公开/内部/机密/绝密)
- 业务维度:产品线(A线/B线/C线)、项目代号
设计上,我建议直接用两张表解决问题:
一张是数据行本身的元数据字段,也就是在业务表里直接加上tenant_id、region_code、security_level这样的列。这样做查询效率最高,维护也直观。
另一张是用户元数据权限表,存每个用户拥有哪些标签的访问权:
| user_id | dimension | tag_value |
|---|---|---|
| 1001 | region | north |
| 1001 | region | east |
| 1002 | region | south |
这样设计的好处是扩展性极强——新增一个用户或者调整权限,只是在这张表里插删记录,不用改代码。
④ 基于属性的动态过滤规则配置
规则怎么定?把条件抽象成“维度 + 运算符 + 值”的三元组。
比如:
region IN ('north','east')这条规则意思是只允许看华北和华东的数据security_level <= 2这条规则意思是密级不超过2级的都能看creator_id = ${currentUserId}这条规则意思是只能看自己创建的数据
这些规则存成JSON或者配置表里都行,但千万别写死在代码里。我见过把权限条件硬编码在mapper.xml里的项目,后来业务一变改到吐血。
正确的做法是定义一个规则引擎接口:
public interface AccessRule {
String toSqlCondition(Map<String, Object> userContext);
// 返回类似 "region IN ('north','east')" 这样的片段
}
然后把规则实现类注册成Spring Bean,用策略模式根据维度名称动态获取。
⑤ 集成身份认证与上下文注入方法
这一块最容易出问题。权限过滤的前提是系统知道“谁在查数据”。
Spring Security是首选,但如果你项目已经跑起来了不想大动干戈,用拦截器+ThreadLocal也能达到同样效果。
核心就一步:在请求入口(过滤器或拦截器)把当前用户的信息提取出来,塞进一个全局可访问的上下文中。
public class UserContextHolder {
private static final ThreadLocal<UserInfo> CONTEXT = new ThreadLocal<>();
public static void set(UserInfo user) { CONTEXT.set(user); }
public static UserInfo get() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); }
}
登录的时候把用户拥有的所有标签权限查出来,装进UserInfo对象。千万记住在请求结束的finally块里调用clear(),不然线程池复用的时候会串用户数据,酿成严重的安全事故。
⑥ 完整代码实现:从请求拦截到数据脱敏
重头戏来了。我们要在MyBatis执行SQL之前,拦截并修改它。
实现方式:写一个MyBatis的拦截器,拦截Executor的query方法。
@Intercepts({@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
)})
@Component
public class DataPermissionInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 获取当前用户
UserInfo user = UserContextHolder.get();
if (user == null || user.isAdmin()) {
return invocation.proceed(); // admin不过滤
}
// 2. 获取MappedStatement和原始SQL
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
BoundSql boundSql = ms.getBoundSql(invocation.getArgs()[1]);
String originalSql = boundSql.getSql();
// 3. 根据当前用户的权限标签,拼接WHERE条件
String condition = buildPermissionCondition(user);
String newSql = wrapSqlWithCondition(originalSql, condition);
// 4. 利用反射把BoundSql里的sql替换掉(这块代码比较啰嗦,但网上一搜一把)
// ...
return invocation.proceed();
}
}
buildPermissionCondition是关键方法:去查用户的权限标签表,拼出(region = 'north' OR region = 'east') AND security_level <= 2这样的串,用括号包好直接插到原SQL的WHERE后面。
数据脱敏可以放在同一层做:如果用户权限里没有FULL_VIEW这个标签,就把手机号中间4位换成****,身份证号只留后四位。脱敏逻辑建议用注解驱动,在实体类的字段上标@Desensitize(type = SensitiveType.PHONE),拦截器拿到结果集后遍历处理。
⑦ 典型场景演练:多租户数据隔离方案
SaaS系统里,租户A的用户绝对看不到租户B的数据,这是底线。
做法其实最简单——在每张业务表上强行加一个tenant_id字段,然后在拦截器里无条件拼接tenant_id = ?,问号的值从当前用户的上下文里取。
要注意的是,租户ID必须在用户登录的时候就确定下来,并且整个会话周期不能变更。如果用户在前端切换租户,必须重新登录拿到新的token。
还有一个容易被忽视的点:关联查询的时候,子查询和JOIN的表也要带上租户过滤。比如查订单的时候JOIN了客户表,两个表都得有tenant_id过滤条件,否则可能从客户表把别的租户的数据带出来。
⑧ 常见报错排查与权限失效诊断
现象一:SQL语法报错,提示WHERE附近有问题
大概率是拼接条件的时候少了空格,或者WHERE关键字重复了。解决方案是在拦截器里写一个工具方法,正规化SQL——先去掉末尾分号,再检查有没有WHERE,有就加AND,没有就插WHERE。
现象二:明明配了权限,但数据一条都没出来
查两件事:第一看UserContextHolder.get()是不是null,很可能拦截器没生效或者上下文没注入成功;第二看生成的最终SQL长什么样,把日志里的SQL复制到数据库客户端里跑一遍,看是不是逻辑写反了(比如配了region IN ('north'),但数据里存的却是northeast)。
现象三:性能突然暴跌
八成是拦截器里每次请求都去查数据库取用户权限标签了。压测的时候几百个请求同时进来,数据库连接池直接爆掉。
⑨ 性能优化策略与缓存机制应用
用户权限在短时间内不会变,所以必须用缓存。
推荐用Caffeine做本地缓存,过期时间设5分钟:
@Bean
public Cache<String, List<PermissionTag>> permissionCache() {
return Caffeine.newBuilder()
.expireAfterWrite(5, TimeUnit.MINUTES)
.maximumSize(10000)
.build();
}
用户登录的时候把权限标签塞进缓存,key就用userId。每次拦截器需要权限数据的时候,先查缓存,查不到再去数据库捞。
还有一个更大的优化点:如果业务表数据量上百万,每次都在SQL上动态拼条件,数据库的执行计划可能会乱走。这时候可以考虑把元数据过滤条件下沉到数据库视图层——针对每个角色创建不同的视图,用户访问视图而不是原始表。代价是视图数量会膨胀,管理起来麻烦,适合角色种类固定的场景。
⑩ 安全加固建议与审计日志规范
最后说几个容易被忽视的安全细节:
第一,拦截器要防绕过。 如果某个Mapper方法不想被拦截(比如查询权限配置表本身),加一个自定义注解@IgnorePermission,在拦截器里检测到该注解就放行。否则会陷入“查权限需要权限”的死循环。
第二,不要信任前端传过来的任何过滤条件。 如果用户提交的请求里带了?region='north',你千万不能直接把它拼到SQL里。正确的做法是:前端传的条件作为“二次过滤”放在业务层处理,底层的数据隔离仍然由拦截器保证。
第三,审计日志规范。 谁、在什么时间、查了什么数据、过滤条件是什么,这些都要记下来。最简单的方案是在拦截器里拿到最终生成的SQL和参数,异步写入日志表。注意是异步,别影响主业务的响应时间。
@Async
public void logQuery(String userId, String sql, String params) {
// 写入审计表
}
WEB项目地址:演示地址 安卓APP下载地址:演示地址 最后给一句实在话:权限控制这件事,宁可一开始多花时间把元数据结构梳理清楚,也千万别想着“先跑起来后面再补”。 因为权限是穿插在所有业务逻辑里的,后面补的成本比重新写还高。把这套拦截器框架搭好,后面无论业务怎么变,都只是配置层面的调整了。