From c0168a8b104fd0874d1efe6915c7d4ddc0121569 Mon Sep 17 00:00:00 2001 From: YunaiV Date: Fri, 23 Oct 2026 20:43:33 +0800 Subject: [PATCH] fix(system): 批量删除部门时整批校验数据权限 --- yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java | 3 ++- yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java | 1 + yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java | 3 ++- yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java | 48 +++++++++++++++++++++++++++++++++++++++++++++++- yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 206 insertions(+), 5 deletions(-) diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java index 7a243b7..ea2b4ff 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java @@ -21,6 +21,7 @@ import jakarta.validation.Valid; import java.util.List; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; @Tag(name = "管理后台 - 部门") @RestController @@ -61,7 +62,7 @@ public class DeptController { @Parameter(name = "ids", description = "编号列表", required = true) @PreAuthorize("@ss.hasPermission('system:dept:delete')") public CommonResult deleteDeptList(@RequestParam("ids") List ids) { - deptService.deleteDeptList(ids); + deptService.deleteDeptList(ids, getLoginUserId()); return success(true); } diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java index 459f395..7737053 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java @@ -57,6 +57,7 @@ public interface ErrorCodeConstants { ErrorCode DEPT_PARENT_ERROR = new ErrorCode(1_002_004_004, "不能设置自己为父部门"); ErrorCode DEPT_NOT_ENABLE = new ErrorCode(1_002_004_006, "部门({})不处于开启状态,不允许选择"); ErrorCode DEPT_PARENT_IS_CHILD = new ErrorCode(1_002_004_007, "不能设置自己的子部门为父部门"); + ErrorCode DEPT_NOT_IN_SCOPE = new ErrorCode(1_002_004_008, "部门({})不在数据权限范围内,禁止操作"); // ========== 岗位模块 1-002-005-000 ========== ErrorCode POST_NOT_FOUND = new ErrorCode(1_002_005_000, "当前岗位不存在"); diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java index 57cec8d..0854146 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java @@ -40,8 +40,9 @@ public interface DeptService { * 批量删除部门 * * @param ids 部门编号数组 + * @param userId 操作的用户编号,用于校验数据权限 */ - void deleteDeptList(List ids); + void deleteDeptList(List ids, Long userId); /** * 获得部门信息 diff --git a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java index 49db60e..fb6ee6d 100644 --- a/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java +++ b/yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImpl.java @@ -2,18 +2,22 @@ package cn.iocoder.yudao.module.system.service.dept; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.ObjectUtil; +import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission; +import cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionContextHolder; import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO; import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO; import cn.iocoder.yudao.module.system.dal.mysql.dept.DeptMapper; import cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants; +import cn.iocoder.yudao.module.system.service.permission.PermissionService; import com.google.common.annotations.VisibleForTesting; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.CacheEvict; import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; @@ -22,6 +26,7 @@ import java.util.*; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.skipPermissionCheck; import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; /** @@ -37,6 +42,10 @@ public class DeptServiceImpl implements DeptService { @Resource private DeptMapper deptMapper; + @Resource + @Lazy // 延迟,避免循环依赖报错 + private PermissionService permissionService; + @Override @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST, allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存 @@ -91,7 +100,9 @@ public class DeptServiceImpl implements DeptService { @Override @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST, allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存 - public void deleteDeptList(List ids) { + public void deleteDeptList(List ids, Long userId) { + // 校验数据权限 + validateDeptListDataScope(ids, userId); // 校验是否有子部门 for (Long id : ids) { if (deptMapper.selectCountByParentId(id) > 0) { @@ -103,6 +114,41 @@ public class DeptServiceImpl implements DeptService { deptMapper.deleteByIds(ids); } + /** + * 校验部门们是否都在用户的数据权限范围内,任一不在则整体拒绝 + * + * 原因:数据权限插件只会删除范围内的部门,导致批量删除时“部分成功” + * 相关 PR:https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1582 + * + * @param ids 部门编号数组 + * @param userId 用户编号 + */ + @VisibleForTesting + void validateDeptListDataScope(Collection ids, Long userId) { + if (CollUtil.isEmpty(ids)) { + return; + } + // 特殊:跨租户访问、忽略数据权限时,与数据权限插件保持一致,无需校验 + if (skipPermissionCheck()) { + return; + } + DataPermission dataPermission = DataPermissionContextHolder.get(); + if (dataPermission != null && !dataPermission.enable()) { + return; + } + + // 校验每个部门:与 DeptDataPermissionRule 对部门表的过滤保持一致 + DeptDataPermissionRespDTO deptDataPermission = permissionService.getDeptDataPermission(userId); + if (Boolean.TRUE.equals(deptDataPermission.getAll())) { + return; + } + for (Long id : ids) { + if (!CollUtil.contains(deptDataPermission.getDeptIds(), id)) { + throw exception(DEPT_NOT_IN_SCOPE, id); + } + } + } + @VisibleForTesting void validateDeptExists(Long id) { if (id == null) { diff --git a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java index df16d4f..e974820 100644 --- a/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java +++ b/yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java @@ -1,26 +1,39 @@ package cn.iocoder.yudao.module.system.service.dept; +import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.common.util.object.ObjectUtils; +import cn.iocoder.yudao.framework.datapermission.core.util.DataPermissionUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO; import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO; import cn.iocoder.yudao.module.system.dal.mysql.dept.DeptMapper; +import cn.iocoder.yudao.module.system.service.permission.PermissionService; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import jakarta.annotation.Resource; import java.util.Arrays; import java.util.List; import java.util.Set; +import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*; import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; import static java.util.Collections.singletonList; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; /** * {@link DeptServiceImpl} 的单元测试类 @@ -35,6 +48,9 @@ public class DeptServiceImplTest extends BaseDbUnitTest { @Resource private DeptMapper deptMapper; + @MockitoBean + private PermissionService permissionService; + @Test public void testCreateDept() { // 准备参数 @@ -113,12 +129,19 @@ public class DeptServiceImplTest extends BaseDbUnitTest { deptMapper.insert(deptDO2); // 准备参数 List ids = Arrays.asList(deptDO1.getId(), deptDO2.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限包含全部部门) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setDeptIds(asSet(deptDO1.getId(), deptDO2.getId())); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); // 调用 - deptService.deleteDeptList(ids); + deptService.deleteDeptList(ids, userId); // 校验数据不存在了 assertNull(deptMapper.selectById(deptDO1.getId())); assertNull(deptMapper.selectById(deptDO2.getId())); + // 校验使用传入的用户编号,获取数据权限 + verify(permissionService).getDeptDataPermission(eq(userId)); } @Test @@ -136,9 +159,138 @@ public class DeptServiceImplTest extends BaseDbUnitTest { // 准备参数(包含有子部门的 parentDept) List ids = Arrays.asList(parentDept.getId(), anotherDept.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限为全部) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setAll(true); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); + + // 调用, 并断言异常 + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_EXITS_CHILDREN); + } + + @Test + public void testDeleteDeptList_notInScope() { + // mock 数据 + DeptDO deptDO1 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO1); + DeptDO deptDO2 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO2); + // 准备参数 + List ids = Arrays.asList(deptDO1.getId(), deptDO2.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限只包含 deptDO1) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setDeptIds(asSet(deptDO1.getId())); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); // 调用, 并断言异常 - assertServiceException(() -> deptService.deleteDeptList(ids), DEPT_EXITS_CHILDREN); + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_NOT_IN_SCOPE, deptDO2.getId()); + // 校验整体拒绝,范围内的部门也未被删除 + assertNotNull(deptMapper.selectById(deptDO1.getId())); + assertNotNull(deptMapper.selectById(deptDO2.getId())); + } + + @Test + public void testDeleteDeptList_selfScope() { + // mock 数据 + DeptDO deptDO = randomPojo(DeptDO.class); + deptMapper.insert(deptDO); + // 准备参数 + List ids = singletonList(deptDO.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限为仅本人,部门表无对应的用户字段,无法操作任何部门) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setSelf(true); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); + + // 调用, 并断言异常 + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_NOT_IN_SCOPE, deptDO.getId()); + assertNotNull(deptMapper.selectById(deptDO.getId())); + } + + @Test + public void testDeleteDeptList_allScope() { + // mock 数据 + DeptDO deptDO1 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO1); + DeptDO deptDO2 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO2); + // 准备参数 + List ids = Arrays.asList(deptDO1.getId(), deptDO2.getId()); + Long userId = randomLongId(); + // mock 方法(数据权限为全部,deptIds 为空) + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setAll(true); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); + + // 调用 + deptService.deleteDeptList(ids, userId); + // 校验数据不存在了 + assertNull(deptMapper.selectById(deptDO1.getId())); + assertNull(deptMapper.selectById(deptDO2.getId())); + } + + @Test + public void testDeleteDeptList_duplicateIds() { + // mock 数据 + DeptDO deptDO1 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO1); + DeptDO deptDO2 = randomPojo(DeptDO.class); + deptMapper.insert(deptDO2); + // 准备参数(deptDO1 重复) + List ids = Arrays.asList(deptDO1.getId(), deptDO1.getId(), deptDO2.getId()); + Long userId = randomLongId(); + // mock 方法 + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO(); + deptDataPermission.setDeptIds(asSet(deptDO1.getId(), deptDO2.getId())); + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission); + + // 调用 + deptService.deleteDeptList(ids, userId); + // 校验数据不存在了 + assertNull(deptMapper.selectById(deptDO1.getId())); + assertNull(deptMapper.selectById(deptDO2.getId())); + } + + @Test + public void testDeleteDeptList_visitTenant() { + // mock 数据 + DeptDO deptDO = randomPojo(DeptDO.class); + deptMapper.insert(deptDO); + // 准备参数 + List ids = singletonList(deptDO.getId()); + Long userId = randomLongId(); + // mock 登录用户(跨租户访问) + LoginUser loginUser = new LoginUser().setId(userId).setUserType(UserTypeEnum.ADMIN.getValue()) + .setTenantId(1L).setVisitTenantId(2L); + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest()); + + try { + // 调用 + deptService.deleteDeptList(ids, userId); + } finally { + SecurityContextHolder.clearContext(); + } + // 校验数据不存在了,且未获取目标租户的数据权限 + assertNull(deptMapper.selectById(deptDO.getId())); + verify(permissionService, never()).getDeptDataPermission(anyLong()); + } + + @Test + public void testDeleteDeptList_ignoreDataPermission() { + // mock 数据 + DeptDO deptDO = randomPojo(DeptDO.class); + deptMapper.insert(deptDO); + // 准备参数 + List ids = singletonList(deptDO.getId()); + Long userId = randomLongId(); + + // 调用(忽略数据权限) + DataPermissionUtils.executeIgnore(() -> deptService.deleteDeptList(ids, userId)); + // 校验数据不存在了 + assertNull(deptMapper.selectById(deptDO.getId())); + verify(permissionService, never()).getDeptDataPermission(anyLong()); } @Test -- libgit2 0.21.4