一、背景介紹
WEB類型軟體産品,在Java(SpringBoot)+MybatisPlus架構場景下,本文針對下面兩個問題,提供解決方案:
- 多租戶的産品,想在表内級别上,實作租戶資料隔離(分表、分庫方案不在本文讨論範圍内)。
- ToB、ToG類型的軟體産品,需要實作資料權限鑒權。例如使用者資料、部門資料、租戶資料等不同級别的鑒權。
Demo源碼倉庫: java-test: java練習Demo項目 - Gitee.com
二、MybatisPlus插件
MyBatis-Plus官網
MyBatis-Plus插件
目前MybatisPlus官方文檔中已有的插件功能:
- 自動分頁: PaginationInnerInterceptor
- 多租戶: TenantLineInnerInterceptor
- 動态表名: DynamicTableNameInnerInterceptor
- 樂觀鎖: OptimisticLockerInnerInterceptor
- sql 性能規範: IllegalSQLInnerInterceptor
- 防止全表更新與删除: BlockAttackInnerInterceptor
各種插件的使用方法,網上資料也比較多,大家可自行百度。
另外,在MybatisPlus 3.x及以後的版本裡,我們可以從源碼裡找到DataPermissionInterceptor資料權限處理器插件,雖然截止本文編寫時(20230117),官網文檔中還沒有此插件的說明,但已經能百度到DataPermissionInterceptor攔截器的一些使用案例了。
個人感覺相比多租戶攔截器TenantLineInnerInterceptor的用法,官方提供的資料權限攔截器DataPermissionInterceptor使用起來還是過于複雜,而且針對CRUD操作的鑒權功能也不夠強大,是以參考多租戶攔截器的實作原理,對資料權限攔截器進行了改造,後續有空了會将改造後的代碼推薦給官方,看是否可以被采納。見Demo源碼倉庫。
MybatisPlus的maven依賴:
<dependencies>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<!-- 截止本文編寫時,最新的MP版本 -->
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.29</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.3.4.RELEASE</version>
</dependency>
</dependencies>
本文後續的例子中,所用的資料庫結構:
/*
Navicat Premium Data Transfer
Source Server : mysql8
Source Server Type : MySQL
Source Server Version : 80027
Source Host : localhost:3306
Source Schema : wsp-test
Target Server Type : MySQL
Target Server Version : 80027
File Encoding : 65001
Date: 17/01/2023 16:26:17
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for wsp_org
-- ----------------------------
DROP TABLE IF EXISTS `wsp_org`;
CREATE TABLE `wsp_org` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
`create_time` timestamp NOT NULL COMMENT '建立時間',
`update_time` timestamp NOT NULL COMMENT '更新時間',
`org_name` varchar(300) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '部門名稱',
`org_address` varchar(300) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '部門位址',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '部門表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for wsp_role
-- ----------------------------
DROP TABLE IF EXISTS `wsp_role`;
CREATE TABLE `wsp_role` (
`id` bigint UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
`create_time` timestamp NOT NULL COMMENT '建立時間',
`update_time` timestamp NOT NULL COMMENT '更新時間',
`tenant_id` bigint NULL DEFAULT NULL COMMENT '租戶id',
`role_name` varchar(300) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '角色名稱',
`role_code` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '角色編碼',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '角色表' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Table structure for wsp_user
-- ----------------------------
DROP TABLE IF EXISTS `wsp_user`;
CREATE TABLE `wsp_user` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
`create_time` timestamp NOT NULL COMMENT '建立時間',
`create_by` bigint unsigned NOT NULL COMMENT '建立人',
`update_time` timestamp NOT NULL COMMENT '更新時間',
`tenant_id` bigint NOT NULL COMMENT '租戶id',
`org_id` bigint NOT NULL COMMENT '部門id',
`role_id` bigint NOT NULL COMMENT '角色id',
`name` varchar(300) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '姓名',
`age` int unsigned NOT NULL DEFAULT '0' COMMENT '年齡',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '使用者表' ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
三、基于MybatisPlus的多租戶插件
1、開發步驟
TenantLineInnerInterceptor是MybatisPlus中提供的多租戶插件,其使用方法大緻分為下面三步:
步驟一、設定環境變量,配置攔截規則
對誇租戶的表設定白名單,忽略多租戶攔截,這些配置可以放到配置檔案中進行環境配置,例如:
tenant:
enable: true
column: tenant_id
filterTables:
ignoreTables:
- wsp_org
ignoreLoginNames:
例如wsp_org表結構中,沒有tenant_id多租戶字段,那麼多租戶攔截器不攔截該表。
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
/**
* 多租戶配置類
*
* @author [email protected]
* @Date 2023-01-11
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
/**
* 是否開啟多租戶
*/
private Boolean enable = true;
/**
* 租戶id字段名
*/
private String column = "tenant_id";
/**
* 需要進行租戶id過濾的表名集合
*/
private List<String> filterTables;
/**
* 需要忽略的多租戶的表,此配置優先filterTables,若此配置為空則啟用filterTables
*/
private List<String> ignoreTables;
/**
* 需要排除租戶過濾的登入使用者名
*/
private List<String> ignoreLoginNames;
}
步驟二、實作TenantLineHandler接口
實作TenantLineHandler接口
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.sky.wsp.mybatis.plus.utils.SecurityContextHolder;
import com.sky.wsp.mybatis.plus.config.properties.TenantProperties;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import java.util.List;
/**
* 多租戶處理類
*
* @author [email protected]
* @Date 2023-01-11
*/
public class MultiTenantHandler implements TenantLineHandler {
private final TenantProperties properties;
public MultiTenantHandler(TenantProperties properties) {
this.properties = properties;
}
/**
* 擷取租戶 ID 值表達式,隻支援單個 ID 值
* <p>
*
* @return 租戶 ID 值表達式
*/
@Override
public Expression getTenantId() {
Long tenantId = SecurityContextHolder.getTenantId();
return new LongValue(tenantId);
}
/**
* 擷取租戶字段名
* <p>
* 預設字段名叫: tenant_id
*
* @return 租戶字段名
*/
@Override
public String getTenantIdColumn() {
return properties.getColumn();
}
/**
* 根據表名判斷是否忽略拼接多租戶條件
* <p>
* 預設都要進行解析并拼接多租戶條件
*
* @param tableName 表名
* @return 是否忽略, true:表示忽略,false:需要解析并拼接多租戶條件
*/
@Override
public boolean ignoreTable(String tableName) {
//忽略指定使用者對租戶的資料過濾
List<String> ignoreLoginNames=properties.getIgnoreLoginNames();
String loginName=SecurityContextHolder.getUsername();
if(null!=ignoreLoginNames && ignoreLoginNames.contains(loginName)){
return true;
}
//忽略指定表對租戶資料的過濾
List<String> ignoreTables = properties.getIgnoreTables();
if (null != ignoreTables && ignoreTables.contains(tableName)) {
return true;
}
return false;
}
}
步驟三、啟用TenantLineInnerInterceptor多租戶攔截器
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.sky.wsp.mybatis.plus.config.properties.TenantProperties;
import com.sky.wsp.mybatis.plus.handler.DBMetaObjectHandler;
import com.sky.wsp.mybatis.plus.handler.MultiTenantHandler;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MybatisPlus配置類
*
* @author [email protected]
* @Date 2023-01-11
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({
TenantProperties.class,
DataPermissionProperties.class
})
public class MybatisPlusConfig {
/**
* 單頁分頁條數限制(預設無限制,參見 插件#handlerLimit 方法)
*/
private static final Long MAX_LIMIT = 1000L;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties tenantProperties) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
if (Boolean.TRUE.equals(tenantProperties.getEnable())) {
// 啟用多租戶攔截
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MultiTenantHandler(tenantProperties)));
}
return interceptor;
}
@Bean
@ConditionalOnMissingBean
public MetaObjectHandler metaObjectHandler() {
return new DBMetaObjectHandler();
}
}
2、執行結果
針對MybatisPlus提供的API、自定義Mapper中的statement(不清楚statement概念的同學可自行百度)我都進行了測試,均可正常攔截,下面附上一些攔截前後SQL對比的例子:
例1:使用MybatisPlus的insert方法,添加資料時會自動初始化tenant_id列
處理前 | 處理後 |
---|---|
INSERT INTO wsp_user ( create_time, create_by, update_time, org_id, role_id, NAME, age ) VALUES ( ?, ?, ?, ?, ?, ?, ? ) | INSERT INTO wsp_user ( create_time, create_by, update_time, org_id, role_id, NAME, age, tenant_id ) VALUES (?, ?, ?, ?, ?, ?, ?, 1) |
例2:使用MybatisPlus的selectById方法,添加資料時會自動初始化tenant_id列
處理前 | 處理後 |
---|---|
SELECT id, create_time, update_time, tenant_id, org_id, role_id, NAME, age FROM wsp_user WHERE id =? | SELECT id, create_time, update_time, tenant_id, org_id, role_id, NAME, age FROM wsp_user WHERE id = ? AND tenant_id = 1 |
例3:使用自定義Mapper的statement方法
處理前 | 處理後 |
---|---|
SELECT id, create_time, update_time, tenant_id, org_id, NAME, age FROM wsp_user AS USER WHERE USER.id = ? | SELECT id, create_time, update_time, tenant_id, org_id, NAME, age FROM wsp_user AS USER WHERE USER.id = ? AND USER.tenant_id = 1 |
例4:使用自定義Mapper的statement方法,進行多表關聯查詢
處理前 | 處理後 |
---|---|
SELECT USER .tenant_id, USER.org_id, org.org_name, org.org_address, USER.role_id, role.role_name, role.role_code, USER.id AS user_id, USER.NAME AS user_name, USER.age AS user_age FROM wsp_user AS USER LEFT JOIN wsp_org AS org ON USER.org_id = org.id LEFT JOIN wsp_role AS role ON USER.role_id = role.id WHERE USER.id = ? | SELECT USER .tenant_id, USER.org_id, org.org_name, org.org_address, USER.role_id, role.role_name, role.role_code, USER.id AS user_id, USER.NAME AS user_name, USER.age AS user_age FROM wsp_user AS USER LEFT JOIN wsp_org AS org ON USER.org_id = org.id LEFT JOIN wsp_role AS role ON USER.role_id = role.id AND role.tenant_id = 1 WHERE USER.id = ? AND USER.tenant_id = 1 |
四、基于MybatisPlus實作自定義資料權限插件
由于官方提供的資料權限攔截器DataPermissionInterceptor,隻能自己拼裝SQL來實作資料鑒權,拼裝SQL操作比較困難,是以參考多租戶攔截器,對資料權限攔截器進行了改造,簡化了使用難度,見Demo源碼倉庫:
- 支援自定義資料權限标記列,即使用表的哪個列進行資料權限過濾
- 支援自定義表白名單、賬号白名單
- 資料權限包括:是否是建立者、是否有部門資料權限
- select查詢時,自動補充資料權限過濾條件
- insert添加時,自動校驗插入資料的部門外鍵,是否在目前登入人的操作權限範圍内
- update更新時,自動校驗更新資料的部門外鍵,是否在目前登入人的操作權限範圍内
- delete删除時,自動補充資料權限過濾條件
注意:資料權限的id外鍵,在建立資料時,是無法通過攔截器進行初始化的,因為一個賬号的資料權限,通常會包含多個部門,那建立資料時,到底是屬于哪個部門下的資料,不好判斷,是以由使用者自己(開發人員)在業務代碼中對資料權限id進行初始化。
1、開發步驟
類似多租戶插件,資料權限插件使用方法也大緻分為下面三步:
步驟一、設定環境變量,配置攔截規則
對誇部門共享的表設定白名單,忽略多資料權限攔截,這些配置可以放到配置檔案中進行環境配置,例如:
data:
permission:
enable: true
# 建立人的标記列
dataCreatorColumn: create_by
# 部門資料權限的标記列
dataPermissionIdColumn: org_id
filterTables:
ignoreTables:
# 不進行資料鑒權攔截的表
- wsp_org
- wsp_role
ignoreLoginNames:
例如wsp_org、wsp_role表結構中,沒有org_id部門外鍵,那麼資料權限攔截器不攔截該表。
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
/**
* 資料權限配置類
*
* @author [email protected]
* @Date 2023-01-11
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "data.permission")
public class DataPermissionProperties {
/**
* 是否開啟資料權限攔截
*/
private Boolean enable = true;
/**
* 資料建立人字段名
*/
private String dataCreatorColumn = "creator";
/**
* 資料權限id字段名
*/
private String dataPermissionIdColumn = "permission_id";
/**
* 需要進行資料權限id過濾的表名集合
*/
private List<String> filterTables;
/**
* 需要忽略的多資料權限的表,此配置優先filterTables,若此配置為空則啟用filterTables
*/
private List<String> ignoreTables;
/**
* 需要排除資料權限過濾的登入使用者名
*/
private List<String> ignoreLoginNames;
}
步驟二、實作MyDataPermissionHandler接口
實作MyDataPermissionHandler接口,(這個接口也是參考多租戶的接口建立的)
import com.sky.wsp.mybatis.plus.config.properties.DataPermissionProperties;
import com.sky.wsp.mybatis.plus.plugins.handler.MyDataPermissionHandler;
import com.sky.wsp.mybatis.plus.utils.SecurityContextHolder;
import java.util.List;
/**
* 基于使用者組織機構(Org)的資料權限處理類
*
* @author [email protected]
* @Date 2023-01-11
*/
public class OrgDataPermissionHandler implements MyDataPermissionHandler {
private final DataPermissionProperties properties;
public OrgDataPermissionHandler(DataPermissionProperties properties) {
this.properties = properties;
}
@Override
public Long getDataCreator() {
// user_id作為creator
return SecurityContextHolder.getUserId();
}
@Override
public String getDataCreatorColumn() {
// user_id作為creator
return properties.getDataCreatorColumn();
}
@Override
public List<Long> getDataPermissionIdSet() {
// org_id作為資料權限
return SecurityContextHolder.getOrgIds();
}
@Override
public String getDataPermissionIdColumn() {
// org_id作為資料權限
return properties.getDataPermissionIdColumn();
}
@Override
public boolean ignoreTable(String tableName) {
//忽略指定使用者對資料權限的過濾
List<String> ignoreLoginNames=properties.getIgnoreLoginNames();
String loginName=SecurityContextHolder.getUsername();
if(null!=ignoreLoginNames && ignoreLoginNames.contains(loginName)){
return true;
}
//忽略指定表對資料權限的過濾
List<String> ignoreTables = properties.getIgnoreTables();
if (null != ignoreTables && ignoreTables.contains(tableName)) {
return true;
}
return false;
}
}
步驟三、啟用MyDataPermissionInterceptor多租戶攔截器
MyDataPermissionInterceptor就是參考多租戶攔截器實作的資料權限攔截器,核心邏輯都在這個類裡。
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.sky.wsp.mybatis.plus.config.properties.DataPermissionProperties;
import com.sky.wsp.mybatis.plus.config.properties.TenantProperties;
import com.sky.wsp.mybatis.plus.handler.DBMetaObjectHandler;
import com.sky.wsp.mybatis.plus.handler.MultiTenantHandler;
import com.sky.wsp.mybatis.plus.handler.OrgDataPermissionHandler;
import com.sky.wsp.mybatis.plus.plugins.inner.MyDataPermissionInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* MybatisPlus配置類
*
* @author [email protected]
* @Date 2023-01-11
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties({
TenantProperties.class,
DataPermissionProperties.class
})
public class MybatisPlusConfig {
/**
* 單頁分頁條數限制(預設無限制,參見 插件#handlerLimit 方法)
*/
private static final Long MAX_LIMIT = 1000L;
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantProperties tenantProperties, DataPermissionProperties dataPermissionProperties) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
if (Boolean.TRUE.equals(tenantProperties.getEnable())) {
// 啟用多租戶攔截
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new MultiTenantHandler(tenantProperties)));
}
if (Boolean.TRUE.equals(dataPermissionProperties.getEnable())) {
// 啟用資料權限攔截
interceptor.addInnerInterceptor(new MyDataPermissionInterceptor(new OrgDataPermissionHandler(dataPermissionProperties)));
}
// 分頁攔截
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setMaxLimit(MAX_LIMIT);
interceptor.addInnerInterceptor(paginationInterceptor);
// 樂觀鎖攔截
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
@Bean
@ConditionalOnMissingBean
public MetaObjectHandler metaObjectHandler() {
return new DBMetaObjectHandler();
}
}
2、執行結果
針對MybatisPlus提供的API、自定義Mapper中的statement(不清楚statement概念的同學可自行百度)我都進行了測試,均可正常攔截,下面附上一些攔截前後SQL對比的例子:
處理前 | 處理後 |
---|---|
SELECT USER .tenant_id, USER.org_id, org.org_name, org.org_address, USER.role_id, role.role_name, role.role_code, USER.id AS user_id, USER.NAME AS user_name, USER.age AS user_age FROM wsp_user AS USER LEFT JOIN wsp_org AS org ON USER.org_id = org.id LEFT JOIN wsp_role AS role ON USER.role_id = role.id WHERE USER.id = ? | SELECT USER .tenant_id, USER.org_id, org.org_name, org.org_address, USER.role_id, role.role_name, role.role_code, USER.id AS user_id, USER.NAME AS user_name, USER.age AS user_age FROM wsp_user AS USER LEFT JOIN wsp_org AS org ON USER.org_id = org.id LEFT JOIN wsp_role AS role ON USER.role_id = role.id AND role.tenant_id = 1 WHERE USER.id = ? AND USER.tenant_id = 1 AND ( create_by = 1 OR USER.org_id IN ( 4, 5, 6 )) |
其他的增删改查的例子,同多租戶攔截器,這裡就不贅述了。
五、忽略攔截
在一些場景下,無需多租戶攔截、無需資料鑒權攔截,或者對于一些超級管理者使用的接口,希望誇租戶查詢、免資料鑒權時,可以通過下面幾種方式實作忽略攔截:
- 使用MybatisPlus架構自帶的@InterceptorIgnore注解,以用在Mapper類上,也可以用在方法上
- 添加超級使用者賬号白名單,在自定義的Handler裡進行邏輯判斷,跳過攔截
- 添加資料表白名單,在自定義的Handler裡進行邏輯判斷,跳過攔截
/**
* 使用@InterceptorIgnore注解,忽略多租戶攔截 <br/>
* 注解@InterceptorIgnore可以用在Mapper類上,也可以用在方法上
*
* @param id
* @return
*/
@InterceptorIgnore(tenantLine = "true")
UserOrgVO myFindByIdNoTenant(@Param(value = "id") Long id);
tenant:
enable: true
column: tenant_id
filterTables:
ignoreTables:
# 不進行多租戶攔截的表
- wsp_org
ignoreLoginNames:
# 這裡配置了ID,需要使用username的,可在MultiTenantHandler中自己實作判斷邏輯
- 99
- 98
data:
permission:
enable: true
dataCreatorColumn: create_by
dataPermissionIdColumn: org_id
filterTables:
ignoreTables:
# 不進行資料鑒權攔截的表
- wsp_org
- wsp_role
ignoreLoginNames:
# 這裡配置了ID,需要使用username的,可在OrgDataPermissionHandler中自己實作判斷邏輯
- 99
- 98
六、參考
- Saas.資料權限控制(Sql解析)
- or 和 in 的效率對比
- 常見的功能權限模型