數據權限管理中心
由於公司大部分項目都是使用mybatis,也是使用mybatis的攔截器進行分頁處理,所以技術上也直接選擇從攔截器入手
需求場景
第一種場景:行級數據處理
原sql:
select id,username,region from sys_user ;
需要封裝成:
select * from (
select id,username,region from sys_user
) where 1=1 and region like “3210%";
解釋
用戶只能查詢當前所屬市以及下屬地市數據 其中 like 部分也可以為動態參數(下面會講到)
此場景還有以下情況:
# 判斷
select * from (select id,username,region from sys_user ) where 1=1 and region != 320101;
# 枚舉
select * from (select id,username,region from sys_user ) where 1=1 and region in (320101,320102,320103);
...
第二種場景:列級數據處理
原sql:
select id,username,region from sys_user ;
- 用戶A可以看到 id,username,region
- 用戶B只能查看 id,username 的值,region的值沒有權限查看。
應用流程圖
應用鏈路邏輯圖
技術實現
mybatis攔截器
在編寫mybatis的攔截器之前,我們先來了解下mybaits的攔截目標方法
-
1、Executor (update, query, flushStatements, commit, rollback, getTransaction, close, isClosed)
-
2、ParameterHandler (getParameterObject, setParameters)
-
3、StatementHandler (prepare, parameterize, batch, update, query)
-
4、ResultSetHandler (handleResultSets, handleOutputParameters)
這里選擇StatementHandler 的 prepare 方法作為sql執行之前的攔截進行sql封裝,使用ResultSetHandler 的 handleResultSets 方法作為sql執行之后的結果攔截過濾。
sql執行前
PrepareInterceptor.java
/**
* mybatis數據權限攔截器 - prepare
* @author GaoYuan
* @date 2018/4/17 上午9:52
*/
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = { Connection.class,Integer.class })
})
@Component
public class PrepareInterceptor implements Interceptor {
/** 日志 */
private static final Logger log = LoggerFactory.getLogger(PrepareInterceptor.class);
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {}
@Override
public Object intercept(Invocation invocation) throws Throwable {
if(log.isInfoEnabled()){
log.info("進入 PrepareInterceptor 攔截器...");
}
if(invocation.getTarget() instanceof RoutingStatementHandler) {
RoutingStatementHandler handler = (RoutingStatementHandler) invocation.getTarget();
StatementHandler delegate = (StatementHandler) ReflectUtil.getFieldValue(handler, "delegate");
//通過反射獲取delegate父類BaseStatementHandler的mappedStatement屬性
MappedStatement mappedStatement = (MappedStatement) ReflectUtil.getFieldValue(delegate, "mappedStatement");
//千萬不能用下面注釋的這個方法,會造成對象丟失,以致轉換失敗
//MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
PermissionAop permissionAop = PermissionUtils.getPermissionByDelegate(mappedStatement);
if(permissionAop == null){
if(log.isInfoEnabled()){
log.info("數據權限放行...");
}
return invocation.proceed();
}
if(log.isInfoEnabled()){
log.info("數據權限處理【拼接SQL】...");
}
BoundSql boundSql = delegate.getBoundSql();
ReflectUtil.setFieldValue(boundSql, "sql", permissionSql(boundSql.getSql()));
}
return invocation.proceed();
}
/**
* 權限sql包裝
* @author GaoYuan
* @date 2018/4/17 上午9:51
*/
protected String permissionSql(String sql) {
StringBuilder sbSql = new StringBuilder(sql);
String userMethodPath = PermissionConfig.getConfig("permission.client.userid.method");
//當前登錄人
String userId = (String)ReflectUtil.reflectByPath(userMethodPath);
//如果用戶為 1 則只能查詢第一條
if("1".equals(userId)){
//sbSql = sbSql.append(" limit 1 ");
//如果有動態參數 regionCd
if(true){
String premission_param = "regionCd";
//select * from (select id,name,region_cd from sys_exam ) where region_cd like '${}%'
String methodPath = PermissionConfig.getConfig("permission.client.params." + premission_param);
String regionCd = (String)ReflectUtil.reflectByPath(methodPath);
sbSql = new StringBuilder("select * from (").append(sbSql).append(" ) s where s.regionCd like concat("+ regionCd +",'%') ");
}
}
return sbSql.toString();
}
}
sql執行后
ResultInterceptor.java
/**
* mybatis數據權限攔截器 - handleResultSets
* 對結果集進行過濾
* @author GaoYuan
* @date 2018/4/17 上午9:52
*/
@Intercepts({
@Signature(type = ResultSetHandler.class, method = "handleResultSets", args={Statement.class})
})
@Component
public class ResultInterceptor implements Interceptor {
/** 日志 */
private static final Logger log = LoggerFactory.getLogger(ResultInterceptor.class);
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {}
@Override
public Object intercept(Invocation invocation) throws Throwable {
if(log.isInfoEnabled()){
log.info("進入 ResultInterceptor 攔截器...");
}
ResultSetHandler resultSetHandler1 = (ResultSetHandler) invocation.getTarget();
//通過java反射獲得mappedStatement屬性值
//可以獲得mybatis里的resultype
MappedStatement mappedStatement = (MappedStatement)ReflectUtil.getFieldValue(resultSetHandler1, "mappedStatement");
//獲取切面對象
PermissionAop permissionAop = PermissionUtils.getPermissionByDelegate(mappedStatement);
//執行請求方法,並將所得結果保存到result中
Object result = invocation.proceed();
if(permissionAop != null) {
if (result instanceof ArrayList) {
ArrayList resultList = (ArrayList) result;
for (int i = 0; i < resultList.size(); i++) {
Object oi = resultList.get(i);
Class c = oi.getClass();
Class[] types = {String.class};
Method method = c.getMethod("setRegionCd", types);
// 調用obj對象的 method 方法
method.invoke(oi, "");
if(log.isInfoEnabled()){
log.info("數據權限處理【過濾結果】...");
}
}
}
}
return result;
}
}
其中 PermissionAop
為 dao 層自定義切面,用於開關控制是否啟用數據權限過濾。
難點
- 如何在攔截器獲取dao層注解內容;
- 如何獲取當前登錄人標識;
- 如何傳遞動態參數;
- 需要考慮到與sql分頁的優先級。
解答
攔截器獲取dao層注解
不同方法的攔截器獲取方法稍微有所區別,具體在上面的 PrepareInterceptor.java
與 ResultInterceptor.java
代碼中自行查看。
獲取當前登錄人標識
由於不同框架或者不同項目,獲取當天登錄人的方法可能不一樣,那么就只能通過配置的方式動態將獲取當前登錄人的方法
傳遞給權限中心。 配置文件中添加:
# 客戶端獲取當前登錄人標識
permission.client.userid.method=com.raising.sc.permission.example.util.UserUtils.getUserId
然后利用Java反射機制,觸發getUserId( )方法。
傳遞動態參數
比如用戶A只能查詢自己單位以及下屬單位的所有數據; 配置中心配置的where部分的sql如下:
org_cd like concat(${orgCd},'%')
然后通過PrepareInterceptor.java
讀取到以上sql,並且通過數據庫或者配置文件中設置的參數【orgCd】相關聯的方法(類似獲取當前登錄人標識
的方式),提前在權限參數(orgCd)配置好對應的方法路徑、參數值類型、返回值類型等。
配置文件或者數據庫獲取到 orgCd 對應的方法路徑:
com.raising.sc.permission.example.util.UserUtils.getRegionCdByUserId
當然,現在這樣只是簡單的動態參數,其余的還需要后續的開發,這里只是最簡單的嘗試。
拓展
從產品的角度來說,此模塊需要有三個部分組成:
1、foruo-permission-admin 數據權限管理平台 2、foruo-permission-server 數據權限服務端(提供權限相關接口) 3、foruo-permission-client 數據權限客戶端(封裝API)
在結合 應用鏈路邏輯圖
即可完成此模塊內容。
涉及知識點:
- Mybatis攔截器
- Java反射機制