背景

为了在共库共表的情况下实现行级数据权限控制,唯一的方法就是修改 SQL 语句,增加权限字段条件。

而在修改 SQL 时,由于 SELECT 选择的表源可能是多层嵌套的,且选择项中可能不存在权限字段,所以单纯的 SELECT * FROM ( … ) WHERE permission_condition 并不能解决所有情况。

而使用 SQL 抽象语法树遍历,在限制访问表的 SQL 对应层级添加 WHERE 权限条件,及在可能存在的 GROUP BY 语句中添加权限字段项的,即可在不修改原有数据访问 SQL 及业务代码的情况下实现行级数据权限控制。

可以通过开发 MyBatis 插件对准备执行的 SQL 进行拦截,在拦截中修改 SQL,再执行修改后的 SQL。

由于 MyBatis 插件开发的资料比较多,这里就不再赘述;这里只叙述如何通过使用 Druid SQL Parser 对 SQL 进行遍历,从而可以对现有的涉及要限制访问表的 SQL 添加权限字段,达到数据行级权限控制。

预备知识

Druid 抽象语法树知识

Druid 抽象语法树知识Druid_SQL_AST

这篇文档中介绍了我们实现遍历抽象语法树修改 SQL 所必需的知识:

  • Druid SQL AST 有哪些主要节点,增删改查语句分别对应什么节点
  • 什么 SQL token 会被解析成什么节点类

Druid 抽象语法树遍历器

Druid 抽象语法树遍历器示例SQL_Parser_Demo_visitor

Oracle 版本示例遍历器:

public static class ExportTableAliasVisitor extends OracleASTVisitorAdapter {
    private Map<String, SQLTableSource> aliasMap = new HashMap<String, SQLTableSource>();
    public boolean visit(OracleSelectTableReference x) {
        String alias = x.getAlias();
        aliasMap.put(alias, x);
        return true;
    }

    public Map<String, SQLTableSource> getAliasMap() {
        return aliasMap;
    }
}

使用遍历器:

finnal String dbType = JdbcConstants.ORACLE; // JdbcConstants.MYSQL或者JdbcConstants.POSTGRESQL
String sql = "select * from mytable a where a.id = 3";
List<SQLStatement> stmtList = SQLUtils.parseStatements(sql, dbType);

ExportTableAliasVisitor visitor = new ExportTableAliasVisitor();
for (SQLStatement stmt : stmtList) {
    stmt.accept(visitor);
}

SQLTableSource tableSource = visitor.getAliasMap().get("a");
System.out.println(tableSource);

留意示例中一个很重要的地方stmt.accept() 调用了 ExportTableAliasVisitor.visit() 方法进行遍历,且 ExportTableAliasVisitor.visit() 方法接受的参数是 OracleSelectTableReference,而 stmt.accept() 调用时没有明显涉及 OracleSelectTableReference 的实例。

以下进行 Druid 抽象语法树遍历机制的源码解析,源码查看可以通过在 IDE 中 Ctrl + Click 点击源文件中的类名打开对应源码。

stmt.accept() 入手,使用 System.out.println(stmt.getClass()) 可得其实际实现类为:SQLSelectStatement

public class SQLSelectStatement extends SQLStatementImpl {
	protected SQLSelect select;
	protected void accept0(SQLASTVisitor visitor) {
		if (visitor.visit(this)) {
			acceptChild(visitor, this.select);
		}
		visitor.endVisit(this);
	}
}

虽然没找到 accept() 但是找到了个 accept0(),记住这个方法,然后继续追溯 SQLSelectStatement 继承的基类来找 accept() 方法。

public abstract class SQLStatementImpl extends SQLObjectImpl implements SQLStatement {
	public final void accept(SQLASTVisitor visitor) {
        if (visitor == null) {
            throw new IllegalArgumentException();
        }

        visitor.preVisit(this);

        accept0(visitor);

        visitor.postVisit(this);
    }
    
    protected abstract void accept0(SQLASTVisitor visitor);
    
	protected final void acceptChild(SQLASTVisitor visitor, SQLObject child) {
        if (child == null) {
            return;
        }

        child.accept(visitor);
    }
}

分析其调用关系,SQLSelectStatement.accept() 调用的实际上是 SQLObjectImpl.accept()SQLObjectImpl.accept() 中进行了预处理 visitor.preVisit(this) 和后处理 visitor.postVisit(this),实际执行业务逻辑的是 accept0(visitor)

分析 SQLObjectImpl.accept0() 可知,其为一个抽象方法,而在这条分析链中,刚好有这个方法的实现方法 SQLSelectStatement.accept0(),这就是我们开头要记住的方法。

SQLSelectStatement.accept0() 中,才真正第一次调用到 visitor.visit(this),也就是样例实现的 visit() 同名方法,但入参还是和 OracleSelectTableReference 类型不符。但是我们可以看到,如果 visit() 方法返回的布尔值为真,则会将对该类型节点的子节点进行递归遍历 visit() 调用(参考其他 SQLObjectImpl 的具体实现类可实证)。也就是说 visit() 返回的布尔值,实际上是控制是否对该节点的子节点进行递归。

要解答为何参数不同仍进行遍历这个问题,就要分析 ExportTableAliasVisitor 继承的基类 OracleASTVisitorAdapter。在 OracleASTVisitorAdapter 中,一切问题的答案都将连起来:

public class OracleASTVisitorAdapter extends SQLASTVisitorAdapter implements OracleASTVisitor {	
	// ...
    @Override
    public boolean visit(OracleAnalyticWindowing x) {

        return true;
    }

    @Override
    public boolean visit(OracleDbLinkExpr x) {

        return true;
    }

    @Override
    public boolean visit(OracleDeleteStatement x) {

        return true;
    }

    @Override
    public boolean visit(OracleIntervalExpr x) {

        return true;
    }

    @Override
    public boolean visit(OracleOuterExpr x) {

        return true;
    }
    
    // ...
    
}

可以看见,OracleASTVisitorAdapter 中对每个 SQLObjectImpl 的派生类及其派生类的派生类等等,进行了完全了列举和实现。至此我们可以隐约感觉到,这与方法的重载与多态有关。现在我们已经具备问题答案的所有线索了,让我们重新分析一遍:

  1. SQLSelectStatement.accept() 调用了 SQLObjectImpl.accept()
  2. SQLObjectImpl.accept() 调用了 SQLSelectStatement.accept0()
  3. SQLSelectStatement.accept0() 调用了 SQLStatementImpl.appendChild() 对其子节点 SQLSelect 进行了(递归)遍历
  4. 而遍历到 OracleSelectTableReference 子节点,则是因为虽然 ExportTableAliasVisitor 中没有实现对应参数的 visit(),但其继承的基类 OracleASTVisitorAdapter 中有对应参数方法的实现,这是函数的多态。而又因为 OracleASTVisitorAdapter 的每个 visit() 实现方法默认返回 true 即默认递归遍历,这样一层又一层的递归遍历及多态调用,最终抵达了 ExportTableAliasVisitor.visit(OracleSelectTableReference x)

至此分析完毕,故我们分析出我们要在哪个节点终止递归,并进行分析修改即可。Druid 用层次状态机解决这复杂的抽象语法树遍历问题,以免去手动列举处理所有情况组合,真的高。

实现

public class RowDataAccessControlVisitor extends OracleASTVisitorAdapter {
	/* 权限字段 */
	private String targetFieldName;
	/* 限制条件 */
	private String condition;
	/* 限制访问的表名 */
	private String[] targetTables;
	/* 数据库类型 */
	private final String dbType = JdbcConstants.ORACLE;
	
	// Insert 语句建议在业务逻辑代码中限制

	// Select 语句无需实现,使用基类的实现方法即可
	
	public boolean visit(OracleUpdateStatement updateStatement) {
		SQLTableSource tableSource = updateStatement.getFrom();
		if (tableSource != null) {
			String alias = tableSource.getAlias();
			if (tableSource instanceof SQLExprTableSource && isTargetTableSource((SQLExprTableSource)tableSource, targetTables))
				updateStatement.addCondition(SQLUtils.toSQLExpr(formCondition(alias), dbType));
		}
		return true;
	}
	
	public boolean visit(OracleDeleteStatement deleteStatement) {
		SQLTableSource tableSource = deleteStatement.getFrom();
		if (tableSource != null) {
			String alias = tableSource.getAlias();
			if (tableSource instanceof SQLExprTableSource && isTargetTableSource((SQLExprTableSource)tableSource, targetTables))
				deleteStatement.addCondition(SQLUtils.toSQLExpr(formCondition(alias), dbType));
		}
		return true;
	}
	
	// 出口状态
	public boolean visit(OracleSelectTableReference selectTableReferenece) {		
		/* 符合目标表源名 */
		if (isTargetTableSource(selectTableReferenece, targetTables)) {
			SQLObject parent = selectTableReferenece.getParent();
			String alias = selectTableReferenece.getAlias();
			
			/* 回溯到选择语句 */
			while (!(parent instanceof OracleSelectQueryBlock) && parent != null)
				parent = parent.getParent();
				
			/* 插入行控制条件 */
			if (parent != null) 
				((OracleSelectQueryBlock)parent).addCondition(formCondition(alias));
		}
		
		return false;
	}
	
	/* 判断是否与目标表名一致 */
	private boolean isTargetTableSource(SQLExprTableSource tableSource, String[] targetTables) {
		for (String target : targetTables)
			if (tableSource.getName().getSimpleName().toString().equalsIgnoreCase(target))
				return true;
		return false;
	}
	
	/* 别名条件 */
	private String getAliasFieldName (String alias) {
		return alias == null ? targetFieldName : alias + "." + targetFieldName; 
	}
	
	/* 组成条件 */
	private String formCondition (String alias) {
		return alias == null ? targetFieldName + " " + condition :
			alias + "." + targetFieldName + " " + condition;
	}
	
	/* 省略 Getter/Setter */
	// ... 
}