YunaiV / ruoyi-vue-pro

🔥 官方推荐 🔥 RuoYi-Vue 全新 Pro 版本,优化重构所有功能。基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 微信小程序,支持 RBAC 动态权限、数据权限、SaaS 多租户、Flowable 工作流、三方登录、支付、短信、商城、CRM、ERP 等功能。你的 ⭐️ Star ⭐️,是作者生发的动力!
https://doc.iocoder.cn/
MIT License
24.42k stars 5.09k forks source link

数据权限组件改进建议:改成更通用的,取消内置的部门数据权限 #477

Closed liyujiang-gzu closed 2 days ago

liyujiang-gzu commented 1 week ago

碰到问题,请在 https://github.com/YunaiV/ruoyi-vue-pro/issues 搜索是否存在相似的 issue。

不按照模板提交的 issue,会被系统自动删除。

基本信息

你猜测可能的原因

(必填)我花费了 2-4 小时自查,发现可能的原因是:xxxxxx

复现步骤

第一步,

第二步,

第三步,

报错信息

数据权限业务组件应该可以优化下,改成更通用的,取消部门数据权限,核心的重构代码如下:

/**
 * 数据权限规则接口
 * 通过实现接口,自定义数据规则。例如说,
 *
 * @author 芋道源码
 */
public interface DataPermissionRule {

    /**
     * 返回需要生效的表名数组
     * 为什么需要该方法?Data Permission 数组基于 SQL 重写,通过 Where 返回只有权限的数据
     * <p>
     * 如果需要基于实体名获得表名,可调用 {@link TableInfoHelper#getTableInfo(Class)} 获得
     *
     * @return 表名数组
     */
    Set<String> getTableNames();

    /**
     * 根据表名和别名,生成对应的 WHERE / OR 过滤条件
     *
     * @param tableName  表名
     * @param tableAlias 别名,可能为空
     * @return 过滤条件 Expression 表达式
     */
    Expression getExpression(String tableName, Alias tableAlias);

    /**
     * 添加数据权限过滤的数据编号列
     *
     * @param entityClass 实体类
     * @param columnName  列名
     */
    void addDataColumn(Class<? extends BaseDO> entityClass, String columnName);

    /**
     * 添加数据权限过滤的数据编号列
     *
     * @param tableName  表名
     * @param columnName 列名
     */
    void addDataColumn(String tableName, String columnName);

    /**
     * 添加数据权限过滤的用户编号列
     *
     * @param entityClass 实体类
     * @param columnName  列名
     */
    void addUserColumn(Class<? extends BaseDO> entityClass, String columnName);

    /**
     * 添加数据权限过滤的用户编号列
     *
     * @param tableName  表名
     * @param columnName 列名
     */
    void addUserColumn(String tableName, String columnName);

}
/**
 * {@link DataPermissionRuleImpl} 的自定义配置接口
 *
 * @author 芋道源码
 */
@FunctionalInterface
public interface DataPermissionRuleCustomizer {

    /**
     * 自定义该权限规则
     * 1. 调用 {@link DataPermissionRuleImpl#addDataColumn(Class, String)} 方法,配置基于数据编号的过滤规则
     * 2. 调用 {@link DataPermissionRuleImpl#addUserColumn(Class, String)} 方法,配置基于用户编号的过滤规则
     *
     * @param rule 权限规则
     */
    void customize(DataPermissionRule rule);

}
/**
 * 默认的 {@link DataPermissionRule} 实现类
 * <p>
 * 注意,使用 {@link DataPermissionRuleImpl} 时,需要保证表中有数据编号的字段,可自定义。
 * <p>
 * 实际业务场景下,会存在一个经典的问题?当用户修改数据时,冗余的数据编号是否需要修改?
 * 1. 一般情况下,数据编号不进行修改,则会导致用户看不到之前的数据。【yudao-server 采用该方案】
 * 2. 部分情况下,希望该用户还是能看到之前的数据,则有两种方式解决:【需要你改造该 {@link DataPermissionRuleImpl} 的实现代码】
 * 1)编写洗数据的脚本,将旧的数据编号修改成新数据的编号;【建议】
 * 最终过滤条件是`WHERE 数据编号 = ?`
 * 2)洗数据的话,可能涉及的数据量较大,也可以采用用户编号进行过滤的方式,此时需要获取到数据编号对应的所有用户编号;
 * 最终过滤条件是`WHERE 用户编号 IN (?, ?, ? ...)`
 * 3)想要保证原数据编号和用户编号都可以看的到,此时使用数据编号和用户编号一起过滤;
 * 最终过滤条件是`WHERE 数据编号 = ? OR 用户编号 IN (?, ?, ? ...)`
 *
 * @author 芋道源码
 */
@Slf4j
public class DataPermissionRuleImpl implements DataPermissionRule {

    /**
     * LoginUser 的 Context 缓存 Key
     */
    public static final String CONTEXT_KEY = DataPermissionRuleImpl.class.getSimpleName();
    /**
     * WHERE 语句中添加 AND NULL
     */
    public static final Expression EXPRESSION_NULL = new NullValue();

    /**
     * 基于数据的表字段配置 TODO 目前只支持一个数据权限字段,dept_id 和 user_id 不能同时配置。
     * 如基于部门的数据编号字段是 dept_id,基于店铺的数据编号字段是 shop_id,通过该配置自定义,如 group_id。
     * <p>
     * key:表名
     * value:字段名
     */
    private final static Map<String, String> DATA_COLUMNS = new HashMap<>();
    /**
     * 基于用户的表字段配置
     * 一般情况下,每个表的数据编号字段是 user_id,通过该配置自定义,如 admin_user_id。
     * <p>
     * key:表名
     * value:字段名
     */
    private final static Map<String, String> USER_COLUMNS = new HashMap<>();
    /**
     * 所有表名,是 {@link #DATA_COLUMNS} 和 {@link #USER_COLUMNS} 的合集
     */
    private final static Set<String> TABLE_NAMES = new HashSet<>();

    private final PermissionApi permissionApi;

    public DataPermissionRuleImpl(PermissionApi permissionApi) {
        this.permissionApi = permissionApi;
    }

    @Override
    public Set<String> getTableNames() {
        return TABLE_NAMES;
    }

    @Override
    public Expression getExpression(String tableName, Alias tableAlias) {
        // 只有有登录用户的情况下,才进行数据权限的处理
        LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
        if (loginUser == null) {
            return null;
        }
        // 只有管理员类型的用户,才进行自定义数据权限的处理,普通用户直接通过 user_id 进行数据权限过滤即可
        if (ObjectUtil.notEqual(loginUser.getUserType(), UserTypeEnum.ADMIN.getValue())) {
            return null;
        }

        // 获得数据权限
        DataPermissionRespDTO dataPermissionRespDTO = loginUser.getContextKeyValue(CONTEXT_KEY, DataPermissionRespDTO.class);
        // 从上下文中拿不到,则调用逻辑进行获取
        if (dataPermissionRespDTO == null) {
            dataPermissionRespDTO = permissionApi.getDataPermission(loginUser.getId());
            if (dataPermissionRespDTO == null) {
                log.error("[getExpression][LoginUser({}) 获取数据权限为 null]", JsonUtils.toJsonString(loginUser));
                throw new NullPointerException(String.format("LoginUser(%d) Table(%s/%s) 未返回数据权限",
                        loginUser.getId(), tableName, tableAlias.getName()));
            }
            // 添加到上下文中,避免重复计算
            loginUser.putContextKeyValue(CONTEXT_KEY, dataPermissionRespDTO);
        }

        // 情况一,如果是 ALL 可查看全部,则无需拼接条件
        if (dataPermissionRespDTO.getAll()) {
            return null;
        }

        // 情况二,即不能查看数据,又不能查看自己,则说明 100% 无权限
        if (CollUtil.isEmpty(dataPermissionRespDTO.getDataIds())
                && Boolean.FALSE.equals(dataPermissionRespDTO.getSelf())) {
            return new EqualsTo(null, null); // WHERE null = null,可以保证返回的数据为空
        }

        // 情况三,拼接 Shop 和 User 的条件,最后组合
        Expression dataExpression = buildDataExpression(tableName, tableAlias, dataPermissionRespDTO.getDataIds());
        Expression userExpression = buildUserExpression(tableName, tableAlias, dataPermissionRespDTO.getSelf(), loginUser.getId());
        if (dataExpression == null && userExpression == null) {
            // 芋艿:获得不到条件的时候,暂时不抛出异常,而是不返回数据
            log.warn("[getExpression][Table({}/{}) LoginUser({}) ShopDataPermission({}) 构建的条件为空]",
                    tableName, tableAlias, JsonUtils.toJsonString(loginUser), JsonUtils.toJsonString(dataPermissionRespDTO));
            return EXPRESSION_NULL; // AND NULL
        }
        if (dataExpression == null) {
            return userExpression;
        }
        if (userExpression == null) {
            return dataExpression;
        }
        // 目前,如果有指定数据 + 可查看自己,采用 OR 条件。即,WHERE (shop_id IN ? OR user_id = ?)
        return new Parenthesis(new OrExpression(dataExpression, userExpression));
    }

    @Override
    public void addDataColumn(Class<? extends BaseDO> entityClass, String columnName) {
        addDataColumn(getTableName(entityClass), columnName);
    }

    @Override
    public void addDataColumn(String tableName, String columnName) {
        if (CharSequenceUtil.isEmpty(tableName)) {
            return;
        }
        DATA_COLUMNS.put(tableName, columnName);
        TABLE_NAMES.add(tableName);
    }

    @Override
    public void addUserColumn(Class<? extends BaseDO> entityClass, String columnName) {
        addDataColumn(getTableName(entityClass), columnName);
    }

    @Override
    public void addUserColumn(String tableName, String columnName) {
        if (CharSequenceUtil.isEmpty(tableName)) {
            return;
        }
        USER_COLUMNS.put(tableName, columnName);
        TABLE_NAMES.add(tableName);
    }

    private Expression buildDataExpression(String tableName, Alias tableAlias, Map<String, Set<Long>> dataIdMap) {
        // 如果不存在配置,则无需作为条件
        String columnName = DATA_COLUMNS.get(tableName);
        if (StrUtil.isEmpty(columnName) || CollUtil.isEmpty(dataIdMap)) {
            return null;
        }
        HashSet<Long> emptySet = CollUtil.newHashSet();
        Set<Long> dataIds = dataIdMap.getOrDefault(columnName, emptySet);
        if (CollUtil.isEmpty(dataIds)) {
            return null;
        }
        // 拼接条件
        if (dataIds.size() == 1) {
            return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, columnName), new LongValue(CollUtil.newArrayList(dataIds).getFirst()));
        }
        return new InExpression(MyBatisUtils.buildColumn(tableName, tableAlias, columnName),
                new ExpressionList(CollectionUtils.convertList(dataIds, LongValue::new)));
    }

    private Expression buildUserExpression(String tableName, Alias tableAlias, Boolean self, Long userId) {
        // 如果不查看自己,则无需作为条件
        String columnName = USER_COLUMNS.get(tableName);
        if (Boolean.FALSE.equals(self) || StrUtil.isEmpty(columnName)) {
            return null;
        }
        // 拼接条件
        return new EqualsTo(MyBatisUtils.buildColumn(tableName, tableAlias, columnName), new LongValue(userId));
    }

    private String getTableName(Class<? extends BaseDO> entityClass) {
        TableInfo tableInfo = TableInfoHelper.getTableInfo(entityClass);
        if (tableInfo == null) {
            log.warn("从实体类({})获取不到数据库表信息", entityClass.getSimpleName());
            return null;
        }
        return tableInfo.getTableName();
    }

}
/**
 * 数据权限 Response DTO
 *
 * @author 芋道源码
 */
@Data
public class DataPermissionRespDTO {

    /**
     * 是否可查看全部数据
     */
    private Boolean all;
    /**
     * 是否可查看自己的数据
     */
    private Boolean self;
    /**
     * 可查看的数据字段及数据编号数组键值对
     */
    private Map<String, Set<Long>> dataIds;

    public DataPermissionRespDTO() {
        this.all = false;
        this.self = false;
        this.dataIds = new HashMap<>();
    }

}
    @Override
    @DataPermission(enable = false) // 关闭数据权限,不然就会出现递归获取数据权限的问题
    public DataPermissionRespDTO getDataPermission(Long userId) {
        DataPermissionRespDTO result = new DataPermissionRespDTO();
        result.setAll(false);
        result.setSelf(false);
        result.setDataIds(new HashMap<>());
        AdminUserDO user = userService.getUserFromCache(userId);
        // 用户信息不存在,则没有任何数据权限
        if (user == null) {
            return result;
        }
        // 默认有自己的数据权限
        result.setSelf(true);
        // 获得用户的角色
        List<RoleDO> roles = getEnableUserRoleListByUserIdFromCache(userId);
        // 如果角色为空,则只能查看自己
        if (CollUtil.isEmpty(roles)) {
            return result;
        }
        // 遍历每个角色,计算
        for (RoleDO role : roles) {
            // 角色为空时,跳过
            if (role.getDataScope() == null) {
                continue;
            }
            // 所有的数据权限
            if (Objects.equals(role.getDataScope(), DataScopeEnum.ALL.getScope())) {
                result.setAll(true);
                continue;
            }
            // TODO 数据编号(dept_id、shop_id)这里暂时写死,为了解耦,需要优化
            // 自己可管理的店铺数据
            Set<Long> userShopIds = shopAdminApi.getShopIdsByUserIdFromCache(userId);
            result.getDataIds().put(PermissionApi.DATA_ID_SHOP, userShopIds);
            // 自己所在的部门数据权限
            if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_ONLY.getScope())) {
                Set<Long> roleDeptIds = new HashSet<>();
                CollUtil.addAll(roleDeptIds, user.getDeptId());
                result.getDataIds().put(PermissionApi.DATA_ID_DEPT, roleDeptIds);
                continue;
            }
            // 自己所在的部门及指定部门数据权限
            if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_CUSTOM.getScope())) {
                Set<Long> roleDeptIds = new HashSet<>();
                CollUtil.addAll(roleDeptIds, user.getDeptId());
                CollUtil.addAll(roleDeptIds, role.getDataScopeDeptIds());
                result.getDataIds().put(PermissionApi.DATA_ID_DEPT, roleDeptIds);
                continue;
            }
            // 自己所在的部门及下级部门数据权限
            if (Objects.equals(role.getDataScope(), DataScopeEnum.DEPT_AND_CHILD.getScope())) {
                Set<Long> roleDeptIds = new HashSet<>();
                CollUtil.addAll(roleDeptIds, user.getDeptId());
                CollUtil.addAll(roleDeptIds, deptService.getChildDeptIdListFromCache(user.getDeptId()));
                result.getDataIds().put(PermissionApi.DATA_ID_DEPT, roleDeptIds);
                continue;
            }
            // 未知情况,记录错误日志
            log.error("[getDataPermission][LoginUser({}) role({}) 无法处理]", userId, toJsonString(result));
        }
        return result;
    }
liyujiang-gzu commented 1 week ago

取消内置部门数据权限的理由:

一些业务系统不得部门的需求; 部门编号和角色、会员耦合太严重,不通用。 用户相关的部门+用户相关的角色=>角色相关的部门,也许适合移到具体的业务模块内,部门编号都不和系统模块及会员模块耦合,可在在具体的业务模块内增加关联表来;

YunaiV commented 1 week ago

如果这个情况,可能我更倾向上,我其实更倾向,在写一个 Rule 哈

YunaiV commented 2 days ago

感谢贡献哈。

我先写到文档里,