ERP系统中数据级别的权限控制
- 概述
在开发MagicErp系统时,都离不开权限的管理,权限 = 功能权限 + 数据权限。功能权限,一般是基于RBAC(Role-Based Access Control)实现。而数据权限,则根据不同的业务场景,则权限设计不尽相同。
- 什么是数据权限
数据权限可以确保不同用户只能访问指定范围的数据。
例如在一个ERP管理系统中,普通员工只能查看自己的员工数据,部门领导可以查看所属部门的所有员工数据,HR可以查看公司所有员工的数据。再比如,仓库人员可能只能查看、编辑自己的入库单,而部门领导则可以查看、编辑自己所在部门的所有入库单。这种权限控制可以实现不同用户访问和操作不同的数据行。
- 实现方案
- 硬编码实现
使用硬编码实现起来非常简单,只需要在查询数据时按用户拥有的权限进行sql过滤,在更新数据时校验用户是否可以操作该数据。但是,在ERP这种系统中,类似这种数据需求会越来越多,如果全部采用硬编码的方式,无疑会给我们带来非常大的开发与维护成本
- 全局数据权限过滤
实现原理是,每次对数据库操作时,拦截执行的sql,自动拼接 WHERE data_column = ? 条件来进行数据的过滤。
例如,某个用户可以查看部门1和部门2的员工数据,那么自动拼接的sql是
SELECT * FROM user where dept_id in (部门1,部门2)
- 全局数据权限
接下来,以MagicErp为例,具体说明全局数据权限的实现
1. 数据权限注解
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataPermission {
/**
* 当前类或方法是否开启数据权限
* 即使不添加 @DataPermission 注解,默认是开启状态
* 可通过设置 enable 为 false 禁用
*/
boolean enable() default true;
/**
* 生效的数据权限规则数组,优先级高于 {@link #excludeRules()}
*/
Class<? extends DataPermissionRule>[] includeRules() default {};
/**
* 排除的数据权限规则数组,优先级最低
*/
Class<? extends DataPermissionRule>[] excludeRules() default {};
}
2.数据权限注解的拦截器
@DataPermission
public class DataPermissionAnnotationInterceptor implements MethodInterceptor {
/**
* DataPermission 空对象,用于方法无 {@link DataPermission} 注解时,使用 DATA_PERMISSION_NULL 进行占位
*/
static final DataPermission DATA_PERMISSION_NULL = DataPermissionAnnotationInterceptor.class.getAnnotation(DataPermission.class);
@Getter
private final Map<MethodClassKey, DataPermission> dataPermissionCache = new ConcurrentHashMap<>();
@Override
public Object invoke(MethodInvocation methodInvocation) throws Throwable {
// 入栈
DataPermission dataPermission = this.findAnnotation(methodInvocation);
if (dataPermission != null) {
DataPermissionContextHolder.add(dataPermission);
}
try {
// 执行逻辑
return methodInvocation.proceed();
} finally {
// 出栈
if (dataPermission != null) {
DataPermissionContextHolder.remove();
}
}
}
private DataPermission findAnnotation(MethodInvocation methodInvocation) {
// 1. 从缓存中获取
Method method = methodInvocation.getMethod();
Object targetObject = methodInvocation.getThis();
Class<?> clazz = targetObject != null ? targetObject.getClass() : method.getDeclaringClass();
MethodClassKey methodClassKey = new MethodClassKey(method, clazz);
DataPermission dataPermission = dataPermissionCache.get(methodClassKey);
if (dataPermission != null) {
return dataPermission != DATA_PERMISSION_NULL ? dataPermission : null;
}
// 2.1 从方法中获取
dataPermission = AnnotationUtils.findAnnotation(method, DataPermission.class);
// 2.2 从类上获取
if (dataPermission == null) {
dataPermission = AnnotationUtils.findAnnotation(clazz, DataPermission.class);
}
// 2.3 添加到缓存中
dataPermissionCache.put(methodClassKey, dataPermission != null ? dataPermission : DATA_PERMISSION_NULL);
return dataPermission;
}
3.数据权限拦截器
public class DataPermissionDatabaseInterceptor extends JsqlParserSupport implements InnerInterceptor {
private final DataPermissionRuleFactory ruleFactory;
@Getter
private final MappedStatementCache mappedStatementCache = new MappedStatementCache();
@Override // SELECT 场景
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
// 获得 Mapper 对应的数据权限的规则
List<DataPermissionRule> rules = ruleFactory.getDataPermissionRule(ms.getId());
if (mappedStatementCache.noRewritable(ms, rules)) { // 如果无需重写,则跳过
return;
}
PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);
try {
// 初始化上下文
ContextHolder.init(rules);
// 处理 SQL
mpBs.sql(parserSingle(mpBs.sql(), null));
} finally {
// 添加是否需要重写的缓存
addMappedStatementCache(ms);
// 清空上下文
ContextHolder.clear();
}
}
@Override // 只处理 UPDATE / DELETE 场景,不处理 INSERT 场景(因为 INSERT 不需要数据权限)
public void beforePrepare(StatementHandler sh, Connection connection, Integer transactionTimeout) {
PluginUtils.MPStatementHandler mpSh = PluginUtils.mpStatementHandler(sh);
MappedStatement ms = mpSh.mappedStatement();
SqlCommandType sct = ms.getSqlCommandType();
if (sct == SqlCommandType.UPDATE || sct == SqlCommandType.DELETE) {
// 获得 Mapper 对应的数据权限的规则
List<DataPermissionRule> rules = ruleFactory.getDataPermissionRule(ms.getId());
if (mappedStatementCache.noRewritable(ms, rules)) { // 如果无需重写,则跳过
return;
}
PluginUtils.MPBoundSql mpBs = mpSh.mPBoundSql();
try {
// 初始化上下文
ContextHolder.init(rules);
// 处理 SQL
mpBs.sql(parserMulti(mpBs.sql(), null));
} finally {
// 添加是否需要重写的缓存
addMappedStatementCache(ms);
// 清空上下文
ContextHolder.clear();
}
}
}
}
4.数据权限规则工厂类
public class DataPermissionRuleFactoryImpl implements DataPermissionRuleFactory {
/**
* 数据权限规则数组
*/
private final List<DataPermissionRule> rules;
@Override
public List<DataPermissionRule> getDataPermissionRules() {
return rules;
}
@Override
public List<DataPermissionRule> getDataPermissionRule(String mappedStatementId) {
// 1. 无数据权限
if (CollUtil.isEmpty(rules)) {
return Collections.emptyList();
}
// 2. 未配置,则默认开启
DataPermission dataPermission = DataPermissionContextHolder.get();
if (dataPermission == null) {
return rules;
}
// 3. 已配置,但禁用
if (!dataPermission.enable()) {
return Collections.emptyList();
}
// 4. 已配置,只选择部分规则
if (ArrayUtil.isNotEmpty(dataPermission.includeRules())) {
return rules.stream().filter(rule -> ArrayUtil.contains(dataPermission.includeRules(), rule.getClass()))
.collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询
}
// 5. 已配置,只排除部分规则
if (ArrayUtil.isNotEmpty(dataPermission.excludeRules())) {
return rules.stream().filter(rule -> !ArrayUtil.contains(dataPermission.excludeRules(), rule.getClass()))
.collect(Collectors.toList()); // 一般规则不会太多,所以不采用 HashSet 查询
}
// 6. 已配置,全部规则
return rules;
}
}
- 数据权限规则接口
public interface DataPermissionRule {
/**
* 返回需要生效的表名数组
* 为什么需要该方法?Data Permission 数组基于 SQL 重写,通过 Where 返回只有权限的数据
*
* 如果需要基于实体名获得表名,可调用 {@link TableInfoHelper#getTableInfo(Class)} 获得
*
* @return 表名数组
*/
Set<String> getTableNames();
/**
* 根据表名和别名,生成对应的 WHERE / OR 过滤条件
*
* @param tableName 表名
* @param tableAlias 别名,可能为空
* @return 过滤条件 Expression 表达式
*/
Expression getExpression(String tableName, Alias tableAlias);
}
6.bean的配置类
@Configuration
public class DataPermissionConfiguration {
@Bean
public DataPermissionRuleFactory dataPermissionRuleFactory(List<DataPermissionRule> rules) {
return new DataPermissionRuleFactoryImpl(rules);
}
@Bean
public DataPermissionDatabaseInterceptor dataPermissionDatabaseInterceptor(MybatisPlusInterceptor interceptor,
DataPermissionRuleFactory ruleFactory) {
// 创建 DataPermissionDatabaseInterceptor 拦截器
DataPermissionDatabaseInterceptor inner = new DataPermissionDatabaseInterceptor(ruleFactory);
// 添加到 interceptor 中
List<InnerInterceptor> inners = new ArrayList<>(interceptor.getInterceptors());
inners.add(index, inner);
interceptor.setInterceptors(inners);
return inner;
}
@Bean
public DataPermissionAnnotationAdvisor dataPermissionAnnotationAdvisor() {
return new DataPermissionAnnotationAdvisor();
}
}
- 基于部门的数据权限
public class DeptDataPermissionRule implements DataPermissionRule {
/**
* AdminUser 的 Context 缓存 Key
*/
protected static final String CONTEXT_KEY = DeptDataPermissionRule.class.getSimpleName();
private static final String DEPT_COLUMN_NAME = "dept_id";
private static final String USER_COLUMN_NAME = "user_id";
static final Expression EXPRESSION_NULL = new NullValue();
private final RoleManager roleManager;
/**
* 基于部门的表字段配置
* 一般情况下,每个表的部门编号字段是 dept_id,通过该配置自定义。
*
* key:表名
* value:字段名
*/
private final Map<String, String> deptColumns = new HashMap<>();
/**
* 基于用户的表字段配置
* 一般情况下,每个表的部门编号字段是 dept_id,通过该配置自定义。
*
* key:表名
* value:字段名
*/
private final Map<String, String> userColumns = new HashMap<>();
/**
* 所有表名,是 {@link #deptColumns} 和 {@link #userColumns} 的合集
*/
private final Set<String> TABLE_NAMES = new HashSet<>();
@Override
public Set<String> getTableNames() {
return TABLE_NAMES;
}
@Override
public Expression getExpression(String tableName, Alias tableAlias) {
// 只有有登陆用户的情况下,才进行数据权限的处理
Admin loginUser = AdminUserContext.getAdmin();
if (loginUser == null) {
return null;
}
// 超级管理员拥有所有数据权限
if(loginUser.getFounder() == 1){
return null;
}
// 获得数据权限
DataPermissionDTO deptDataPermission = loginUser.getContext(CONTEXT_KEY, DataPermissionDTO.class);
// 从上下文中拿不到,则调用逻辑进行获取
if (deptDataPermission == null) {
deptDataPermission = roleManager.getDataPermission(loginUser.getUid());
// 添加到上下文中,避免重复计算
loginUser.setContext(CONTEXT_KEY, deptDataPermission);
}
// 情况一,如果是 ALL 可查看全部,则无需拼接条件
if (deptDataPermission.getAll()) {
return null;
}
// 情况二,即不能查看部门,又不能查看自己,则说明 100% 无权限
if (CollUtil.isEmpty(deptDataPermission.getDeptIds())
&& Boolean.FALSE.equals(deptDataPermission.getSelf())) {
return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空
}
// 情况三,拼接 Dept 条件,最后组合
Expression deptExpression = buildDeptExpression(tableName,tableAlias, deptDataPermission.getDeptIds());
if (deptExpression == null) {
log.warn("[getExpression][LoginUser({}) Table({}/{}) DeptDataPermission({}) 构建的条件为空]",
JsonUtil.objectToJson(loginUser), tableName, tableAlias, JsonUtil.objectToJson(deptDataPermission));
return EXPRESSION_NULL;
}
return deptExpression;
}
private Expression buildDeptExpression(String tableName, Alias tableAlias, Set<Long> deptIds) {
// 如果不存在配置,则无需作为条件
String columnName = deptColumns.get(tableName);
if (StrUtil.isEmpty(columnName)) {
return null;
}
// 如果为空,则无条件
if (CollUtil.isEmpty(deptIds)) {
return null;
}
// 拼接条件
return new InExpression(MyBatisPlusUtils.buildColumn(tableName, tableAlias, columnName),
new ExpressionList(CollectionUtils.convertList(deptIds, LongValue::new)));
}
private Expression buildUserExpression(String tableName, Alias tableAlias, Boolean self, Long userId) {
// 如果不查看自己,则无需作为条件
if (Boolean.FALSE.equals(self)) {
return null;
}
String columnName = userColumns.get(tableName);
if (StrUtil.isEmpty(columnName)) {
return null;
}
// 拼接条件
return new EqualsTo(MyBatisPlusUtils.buildColumn(tableName, tableAlias, columnName), new LongValue(userId));
}
// ==================== 添加配置 ====================
public void addDeptColumn(Class<?> entityClass) {
addDeptColumn(entityClass, DEPT_COLUMN_NAME);
}
public void addDeptColumn(Class<?> entityClass, String columnName) {
String tableName = TableInfoHelper.getTableInfo(entityClass).getTableName();
addDeptColumn(tableName, columnName);
}
public void addDeptColumn(String tableName, String columnName) {
deptColumns.put(tableName, columnName);
TABLE_NAMES.add(tableName);
}
public void addUserColumn(Class<?> entityClass) {
addUserColumn(entityClass, USER_COLUMN_NAME);
}
public void addUserColumn(Class<?> entityClass, String columnName) {
String tableName = TableInfoHelper.getTableInfo(entityClass).getTableName();
addUserColumn(tableName, columnName);
}
public void addUserColumn(String tableName, String columnName) {
userColumns.put(tableName, columnName);
TABLE_NAMES.add(tableName);
}
}
- 最后定制哪些表需要生效数据权限
addDeptColumn第一个参数是数据库实体类,第二个参数是部门id的数据库字段名
默认的部门id字段是dept_id,也可以手动指定部门id的字段名称
public class DataPermissionTableCustomizer {
@Bean
public DeptDataPermissionRuleCustomizer deptDataPermissionRuleCustomizer() {
return rule -> {
// 用户管理
rule.addDeptColumn(AdminUser.class, "dept_id");
// 仓库管理
rule.addDeptColumn(WarehouseDO.class);
// 采购计划管理
rule.addDeptColumn(ProcurementPlan.class);
// 入库单管理
rule.addDeptColumn(WarehouseEntryDO.class);
// 出库单管理
rule.addDeptColumn(WarehouseOutDO.class);
// 供应商退货
rule.addDeptColumn(SupplierReturnDO.class);
// 订单退货
rule.addDeptColumn(OrderReturnDO.class);
// 订单
rule.addDeptColumn(OrderDO.class);
// 借出单
rule.addDeptColumn(LendForm.class);
// 换货单
rule.addDeptColumn(ChangeForm.class);
// 库存报损单
rule.addDeptColumn(StockDamageReport.class);
// 库存盘点单
rule.addDeptColumn(StockInventory.class);
// 销售经理
rule.addDeptColumn(MarketingManagerDO.class);
};
}
}