元数据过滤与权限控制实战指南

0 阅读9分钟

元数据过滤与权限控制实战指南

这活儿说白了就是——让不同的人进到同一个系统里,看到不同的数据。不是把菜单藏起来那种“假装没权限”,而是真正落到字段级别的拦截。比如销售总监能看到全国订单,华北区的销售就只能看华北的,你俩查的是同一张表,返回来的是不同的结果。

① 核心概念解析:元数据如何驱动权限决策

很多新手容易把权限控制理解成“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_idregion_codesecurity_level这样的列。这样做查询效率最高,维护也直观。

另一张是用户元数据权限表,存每个用户拥有哪些标签的访问权:

user_iddimensiontag_value
1001regionnorth
1001regioneast
1002regionsouth

这样设计的好处是扩展性极强——新增一个用户或者调整权限,只是在这张表里插删记录,不用改代码。

④ 基于属性的动态过滤规则配置

规则怎么定?把条件抽象成“维度 + 运算符 + 值”的三元组。

比如:

  • 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的拦截器,拦截Executorquery方法。

@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下载地址:演示地址 最后给一句实在话:权限控制这件事,宁可一开始多花时间把元数据结构梳理清楚,也千万别想着“先跑起来后面再补”。 因为权限是穿插在所有业务逻辑里的,后面补的成本比重新写还高。把这套拦截器框架搭好,后面无论业务怎么变,都只是配置层面的调整了。