数据权限拦截器
数据权限在角色权限之上进一步约束用户可见数据范围,实现"同一角色、不同数据范围"的精细化控制。本文基于 MyBatis 拦截器 + JSqlParser 实现动态 SQL 权限过滤。
数据权限模型
| 权限类型 | 过滤条件 | 适用场景 |
|---|---|---|
| 全部数据 | 无过滤 | 管理员、CEO |
| 本部门及下级 | dept_id IN (子部门树) | 部门经理 |
| 本部门 | dept_id = 当前部门 | 部门主管 |
| 本人 | create_by = 当前用户 | 普通员工 |
| 自定义 | id IN (授权列表) | 特殊授权场景 |
XML
权限层级模型
┌────────────────────────────┐
│ 全部数据 (ADMIN) │
│ ┌──────────────────────┐ │
│ │ 本部门及下级 (MGR) │ │
│ │ ┌────────────────┐ │ │
│ │ │ 本部门 (LEAD) │ │ │
│ │ │ ┌──────────┐ │ │ │
│ │ │ │ 本人(EMP)│ │ │ │
│ │ │ └──────────┘ │ │ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
└────────────────────────────┘
JSqlParser SQL 解析
为什么使用 JSqlParser
简单字符串替换无法正确处理以下场景:
- 子查询中的 WHERE
- JOIN 关联条件
- 多表别名
- UNION/EXISTS 嵌套
JSqlParser 将 SQL 解析为抽象语法树(AST),可在正确的位置注入条件。
核心依赖
Java
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>4.7</version>
</dependency>
数据权限拦截器实现
权限上下文
Java
public class DataPermissionContext {
private static final ThreadLocal<DataScope> SCOPE = new ThreadLocal<>();
public static void setScope(DataScope scope) {
SCOPE.set(scope);
}
public static DataScope getScope() {
return SCOPE.get();
}
public static void clear() {
SCOPE.remove();
}
/** 数据权限范围 */
public static class DataScope {
private final String deptId; // 当前部门
private final List<String> deptTree; // 部门树(含子部门)
private final String userId; // 当前用户
private final DataScopeType type; // 权限类型
public enum DataScopeType {
ALL, // 全部数据
DEPT_TREE, // 本部门及下级
DEPT, // 本部门
SELF, // 本人
CUSTOM // 自定义
}
}
}
拦截器核心
Java
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class DataPermissionInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
DataPermissionContext.DataScope scope = DataPermissionContext.getScope();
if (scope == null || scope.getType() == DataPermissionContext.DataScopeType.ALL) {
return invocation.proceed();
}
// 检查是否忽略数据权限
if (isIgnorePermission(ms)) {
return invocation.proceed();
}
BoundSql boundSql = ms.getBoundSql(parameter);
String originalSql = boundSql.getSql();
// 使用 JSqlParser 解析并注入权限条件
String newSql = addPermissionCondition(originalSql, scope);
// 替换 BoundSql
MappedStatement newMs = copyMappedStatement(ms, newSql, boundSql);
args[0] = newMs;
return invocation.proceed();
}
private String addPermissionCondition(String sql, DataPermissionContext.DataScope scope) {
try {
Statement stmt = CCJSqlParserUtil.parse(sql);
if (stmt instanceof Select) {
Select select = (Select) stmt;
SelectBody selectBody = select.getSelectBody();
if (selectBody instanceof PlainSelect) {
PlainSelect plainSelect = (PlainSelect) selectBody;
Expression where = plainSelect.getWhere();
Expression permissionExpr = buildPermissionExpression(scope);
if (where != null) {
AndExpression andExpr = new AndExpression(where, permissionExpr);
plainSelect.setWhere(andExpr);
} else {
plainSelect.setWhere(permissionExpr);
}
}
return select.toString();
}
} catch (JSQLParserException e) {
// 解析失败时降级返回原始 SQL
return sql;
}
return sql;
}
/** 构建权限表达式 */
private Expression buildPermissionExpression(DataPermissionContext.DataScope scope) {
DataPermissionContext.DataScopeType type = scope.getType();
Column column = new Column("dept_id");
switch (type) {
case DEPT_TREE:
// dept_id IN (1, 2, 3, 4)
InExpression inExpr = new InExpression();
inExpr.setLeftExpression(column);
ItemsList itemsList = new ExpressionList(
scope.getDeptTree().stream()
.map(StringValue::new)
.collect(Collectors.toList())
);
inExpr.setRightItemsList(itemsList);
return inExpr;
case DEPT:
// dept_id = 'current_dept'
return new EqualsTo(column, new StringValue(scope.getDeptId()));
case SELF:
// create_by = 'user_id'
return new EqualsTo(new Column("create_by"), new StringValue(scope.getUserId()));
default:
return new NullValue();
}
}
private boolean isIgnorePermission(MappedStatement ms) {
try {
String className = ms.getId().substring(0, ms.getId().lastIndexOf("."));
String methodName = ms.getId().substring(ms.getId().lastIndexOf(".") + 1);
Class<?> clazz = Class.forName(className);
Method method = null;
for (Method m : clazz.getMethods()) {
if (m.getName().equals(methodName)) {
method = m;
break;
}
}
return method != null && method.isAnnotationPresent(IgnoreDataPermission.class);
} catch (Exception e) {
return false;
}
}
private MappedStatement copyMappedStatement(MappedStatement original, String newSql, BoundSql oldBoundSql) {
BoundSql newBoundSql = new BoundSql(
original.getConfiguration(),
newSql,
oldBoundSql.getParameterMappings(),
oldBoundSql.getParameterObject()
);
MappedStatement.Builder builder = new MappedStatement.Builder(
original.getConfiguration(),
original.getId(),
new BoundSqlSqlSource(newBoundSql),
original.getSqlCommandType()
);
builder.resource(original.getResource());
builder.fetchSize(original.getFetchSize());
builder.statementType(original.getStatementType());
builder.parameterMap(original.getParameterMap());
builder.resultMaps(original.getResultMaps());
builder.cache(original.getCache());
builder.useCache(original.isUseCache());
return builder.build();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties props) {
// 可配置忽略权限的 Mapper 方法列表
}
private static class BoundSqlSqlSource implements SqlSource {
private final BoundSql boundSql;
BoundSqlSqlSource(BoundSql sql) { this.boundSql = sql; }
@Override
public BoundSql getBoundSql(Object parameterObject) { return boundSql; }
}
}
忽略权限注解
Java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface IgnoreDataPermission {
}
Java
public interface OrderMapper {
// 正常受数据权限约束
List<Order> selectOrders(@Param("status") String status);
// 后台管理:忽略数据权限,查看所有数据
@IgnoreDataPermission
List<Order> selectAllOrdersForAdmin();
}
拦截器链与执行顺序
MyBatis 支持多个拦截器叠加,数据权限拦截器需与其他拦截器(如租户拦截器、分页拦截器)正确协作。
Java
拦截器链执行顺序
┌─────────────────────────────────────┐
│ TenantInterceptor (租户过滤) │ ← 先执行,缩小到租户范围
│ └─ inject: WHERE tenant_id = 'A' │
│ ↓ │
│ DataPermissionInterceptor (数据权限)│ ← 后执行,在租户内再过滤
│ └─ inject: AND dept_id IN (...) │
│ ↓ │
│ PaginationInterceptor (分页) │ ← 最后执行,添加 LIMIT
│ └─ inject: LIMIT ?, ? │
│ ↓ │
│ 原始 SQL 执行 │
└─────────────────────────────────────┘
注册顺序决定执行顺序
Java
@Configuration
public class MyBatisConfig {
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> {
// 注册顺序 = 拦截顺序
configuration.addInterceptor(new TenantInterceptor()); // 1. 租户
configuration.addInterceptor(new DataPermissionInterceptor()); // 2. 数据权限
configuration.addInterceptor(new PaginationInterceptor()); // 3. 分页
};
}
}
拦截器按注册顺序依次执行,SQL 被逐层改写,最终形成完整语句。租户过滤必须先于数据权限过滤。
权限上下文填充
Spring MVC 拦截器
text
@Component
public class DataPermissionInterceptor implements HandlerInterceptor {
@Autowired
private UserService userService;
@Autowired
private DeptService deptService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
LoginUser user = SecurityUtils.getCurrentUser();
if (user == null) {
return true;
}
DataPermissionContext.DataScope scope = buildDataScope(user);
DataPermissionContext.setScope(scope);
return true;
}
private DataPermissionContext.DataScope buildDataScope(LoginUser user) {
DataPermissionContext.DataScopeType type = resolveScopeType(user);
DataPermissionContext.DataScope.Builder builder = new DataPermissionContext.DataScope.Builder();
builder.userId(user.getId());
builder.deptId(user.getDeptId());
builder.type(type);
if (type == DataPermissionContext.DataScopeType.DEPT_TREE) {
builder.deptTree(deptService.getSubDeptIds(user.getDeptId()));
}
return builder.build();
}
private DataPermissionContext.DataScopeType resolveScopeType(LoginUser user) {
// 根据角色返回不同的数据权限类型
if (user.hasRole("ADMIN")) return DataPermissionContext.DataScopeType.ALL;
if (user.hasRole("DEPT_MGR")) return DataPermissionContext.DataScopeType.DEPT_TREE;
if (user.hasRole("DEPT_LEAD")) return DataPermissionContext.DataScopeType.DEPT;
return DataPermissionContext.DataScopeType.SELF;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
DataPermissionContext.clear();
}
}
多表 JOIN 场景处理
JOIN 查询中需要明确指定哪个表的列进行过滤:
text
private Expression buildPermissionExpressionForJoin(
DataPermissionContext.DataScope scope, PlainSelect plainSelect) {
// 获取主表别名
String mainTableAlias = extractMainTableAlias(plainSelect);
String column = mainTableAlias != null ? mainTableAlias + ".dept_id" : "dept_id";
Column col = new Column(column);
// 构建 IN 表达式
InExpression inExpr = new InExpression();
inExpr.setLeftExpression(col);
inExpr.setRightItemsList(new ExpressionList(
scope.getDeptTree().stream()
.map(StringValue::new)
.collect(Collectors.toList())
));
return inExpr;
}
注意事项
- SQL 解析失败降级:JSqlParser 解析失败时应返回原始 SQL 并记录告警日志,不可直接抛异常阻断业务
- 忽略权限注解:后台管理、统计报表等场景使用
@IgnoreDataPermission绕过数据权限,但需审计日志记录- 拦截器顺序:数据权限拦截器必须在租户拦截器之后、分页拦截器之前注册
- 性能开销:每次 SQL 执行都需 AST 解析 + 重写,生产环境建议对相同 SQL 模板进行缓存
- INSERT/UPDATE/DELETE:数据权限主要约束查询,写操作应在业务层做权限校验,避免拦截器误拦截
要点总结
- 数据权限在角色权限基础上增加数据范围约束,实现"同角色、不同数据"的精细化控制
- 五种权限模型:全部数据、本部门及下级、本部门、本人、自定义,对应不同的 WHERE 条件
- 使用 JSqlParser 将 SQL 解析为 AST,在
PlainSelect.where中注入 AND 权限条件,避免字符串替换的精度问题 - 拦截器链按注册顺序执行:租户过滤 → 数据权限过滤 → 分页,逐层改写 SQL
- 通过
@IgnoreDataPermission注解标识豁免数据权限的方法,用于后台管理场景 - 数据权限上下文通过 Spring MVC 拦截器在
preHandle中填充,在afterCompletion中清理
存放路径:D:\git2\jwdev\articles\MYBATIS\专家\多租户与数据权限\数据权限拦截器.md
📝 发现内容有误?点击此处直接编辑