Commit c0168a8b104fd0874d1efe6915c7d4ddc0121569

Authored by YunaiV
1 parent 9ecd3ed9

fix(system): 批量删除部门时整批校验数据权限

批删前检查所有部门的数据范围,任一越权则整批拒绝。
userId 由 Controller 显式传入,保留授权跨租户和忽略数据权限行为。
其他写操作保持原有行为;23 个服务测试与 9 项 MySQL 写入检查通过。

参考:https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1582
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/controller/admin/dept/DeptController.java
... ... @@ -21,6 +21,7 @@ import jakarta.validation.Valid;
21 21 import java.util.List;
22 22  
23 23 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  24 +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
24 25  
25 26 @Tag(name = "管理后台 - 部门")
26 27 @RestController
... ... @@ -61,7 +62,7 @@ public class DeptController {
61 62 @Parameter(name = "ids", description = "编号列表", required = true)
62 63 @PreAuthorize("@ss.hasPermission('system:dept:delete')")
63 64 public CommonResult<Boolean> deleteDeptList(@RequestParam("ids") List<Long> ids) {
64   - deptService.deleteDeptList(ids);
  65 + deptService.deleteDeptList(ids, getLoginUserId());
65 66 return success(true);
66 67 }
67 68  
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java
... ... @@ -57,6 +57,7 @@ public interface ErrorCodeConstants {
57 57 ErrorCode DEPT_PARENT_ERROR = new ErrorCode(1_002_004_004, "不能设置自己为父部门");
58 58 ErrorCode DEPT_NOT_ENABLE = new ErrorCode(1_002_004_006, "部门({})不处于开启状态,不允许选择");
59 59 ErrorCode DEPT_PARENT_IS_CHILD = new ErrorCode(1_002_004_007, "不能设置自己的子部门为父部门");
  60 + ErrorCode DEPT_NOT_IN_SCOPE = new ErrorCode(1_002_004_008, "部门({})不在数据权限范围内,禁止操作");
60 61  
61 62 // ========== 岗位模块 1-002-005-000 ==========
62 63 ErrorCode POST_NOT_FOUND = new ErrorCode(1_002_005_000, "当前岗位不存在");
... ...
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/service/dept/DeptService.java
... ... @@ -40,8 +40,9 @@ public interface DeptService {
40 40 * 批量删除部门
41 41 *
42 42 * @param ids 部门编号数组
  43 + * @param userId 操作的用户编号,用于校验数据权限
43 44 */
44   - void deleteDeptList(List<Long> ids);
  45 + void deleteDeptList(List<Long> ids, Long userId);
45 46  
46 47 /**
47 48 * 获得部门信息
... ...
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;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.util.ObjectUtil;
  5 +import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO;
5 6 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
6 7 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
7 8 import cn.iocoder.yudao.framework.datapermission.core.annotation.DataPermission;
  9 +import cn.iocoder.yudao.framework.datapermission.core.aop.DataPermissionContextHolder;
8 10 import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO;
9 11 import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO;
10 12 import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
11 13 import cn.iocoder.yudao.module.system.dal.mysql.dept.DeptMapper;
12 14 import cn.iocoder.yudao.module.system.dal.redis.RedisKeyConstants;
  15 +import cn.iocoder.yudao.module.system.service.permission.PermissionService;
13 16 import com.google.common.annotations.VisibleForTesting;
14 17 import lombok.extern.slf4j.Slf4j;
15 18 import org.springframework.cache.annotation.CacheEvict;
16 19 import org.springframework.cache.annotation.Cacheable;
  20 +import org.springframework.context.annotation.Lazy;
17 21 import org.springframework.stereotype.Service;
18 22 import org.springframework.validation.annotation.Validated;
19 23  
... ... @@ -22,6 +26,7 @@ import java.util.*;
22 26  
23 27 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
24 28 import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
  29 +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.skipPermissionCheck;
25 30 import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
26 31  
27 32 /**
... ... @@ -37,6 +42,10 @@ public class DeptServiceImpl implements DeptService {
37 42 @Resource
38 43 private DeptMapper deptMapper;
39 44  
  45 + @Resource
  46 + @Lazy // 延迟,避免循环依赖报错
  47 + private PermissionService permissionService;
  48 +
40 49 @Override
41 50 @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST,
42 51 allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存
... ... @@ -91,7 +100,9 @@ public class DeptServiceImpl implements DeptService {
91 100 @Override
92 101 @CacheEvict(cacheNames = RedisKeyConstants.DEPT_CHILDREN_ID_LIST,
93 102 allEntries = true) // allEntries 清空所有缓存,因为操作一个部门,涉及到多个缓存
94   - public void deleteDeptList(List<Long> ids) {
  103 + public void deleteDeptList(List<Long> ids, Long userId) {
  104 + // 校验数据权限
  105 + validateDeptListDataScope(ids, userId);
95 106 // 校验是否有子部门
96 107 for (Long id : ids) {
97 108 if (deptMapper.selectCountByParentId(id) > 0) {
... ... @@ -103,6 +114,41 @@ public class DeptServiceImpl implements DeptService {
103 114 deptMapper.deleteByIds(ids);
104 115 }
105 116  
  117 + /**
  118 + * 校验部门们是否都在用户的数据权限范围内,任一不在则整体拒绝
  119 + *
  120 + * 原因:数据权限插件只会删除范围内的部门,导致批量删除时“部分成功”
  121 + * 相关 PR:https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1582
  122 + *
  123 + * @param ids 部门编号数组
  124 + * @param userId 用户编号
  125 + */
  126 + @VisibleForTesting
  127 + void validateDeptListDataScope(Collection<Long> ids, Long userId) {
  128 + if (CollUtil.isEmpty(ids)) {
  129 + return;
  130 + }
  131 + // 特殊:跨租户访问、忽略数据权限时,与数据权限插件保持一致,无需校验
  132 + if (skipPermissionCheck()) {
  133 + return;
  134 + }
  135 + DataPermission dataPermission = DataPermissionContextHolder.get();
  136 + if (dataPermission != null && !dataPermission.enable()) {
  137 + return;
  138 + }
  139 +
  140 + // 校验每个部门:与 DeptDataPermissionRule 对部门表的过滤保持一致
  141 + DeptDataPermissionRespDTO deptDataPermission = permissionService.getDeptDataPermission(userId);
  142 + if (Boolean.TRUE.equals(deptDataPermission.getAll())) {
  143 + return;
  144 + }
  145 + for (Long id : ids) {
  146 + if (!CollUtil.contains(deptDataPermission.getDeptIds(), id)) {
  147 + throw exception(DEPT_NOT_IN_SCOPE, id);
  148 + }
  149 + }
  150 + }
  151 +
106 152 @VisibleForTesting
107 153 void validateDeptExists(Long id) {
108 154 if (id == null) {
... ...
yudao-module-system/src/test/java/cn/iocoder/yudao/module/system/service/dept/DeptServiceImplTest.java
1 1 package cn.iocoder.yudao.module.system.service.dept;
2 2  
  3 +import cn.iocoder.yudao.framework.common.biz.system.permission.dto.DeptDataPermissionRespDTO;
3 4 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  5 +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
4 6 import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
  7 +import cn.iocoder.yudao.framework.datapermission.core.util.DataPermissionUtils;
  8 +import cn.iocoder.yudao.framework.security.core.LoginUser;
  9 +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
5 10 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
6 11 import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptListReqVO;
7 12 import cn.iocoder.yudao.module.system.controller.admin.dept.vo.dept.DeptSaveReqVO;
8 13 import cn.iocoder.yudao.module.system.dal.dataobject.dept.DeptDO;
9 14 import cn.iocoder.yudao.module.system.dal.mysql.dept.DeptMapper;
  15 +import cn.iocoder.yudao.module.system.service.permission.PermissionService;
10 16 import org.junit.jupiter.api.Test;
11 17 import org.springframework.context.annotation.Import;
  18 +import org.springframework.mock.web.MockHttpServletRequest;
  19 +import org.springframework.security.core.context.SecurityContextHolder;
  20 +import org.springframework.test.context.bean.override.mockito.MockitoBean;
12 21  
13 22 import jakarta.annotation.Resource;
14 23 import java.util.Arrays;
15 24 import java.util.List;
16 25 import java.util.Set;
17 26  
  27 +import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet;
18 28 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals;
19 29 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
20 30 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
21 31 import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*;
22 32 import static java.util.Collections.singletonList;
23 33 import static org.junit.jupiter.api.Assertions.*;
  34 +import static org.mockito.ArgumentMatchers.anyLong;
  35 +import static org.mockito.ArgumentMatchers.eq;
  36 +import static org.mockito.Mockito.*;
24 37  
25 38 /**
26 39 * {@link DeptServiceImpl} 的单元测试类
... ... @@ -35,6 +48,9 @@ public class DeptServiceImplTest extends BaseDbUnitTest {
35 48 @Resource
36 49 private DeptMapper deptMapper;
37 50  
  51 + @MockitoBean
  52 + private PermissionService permissionService;
  53 +
38 54 @Test
39 55 public void testCreateDept() {
40 56 // 准备参数
... ... @@ -113,12 +129,19 @@ public class DeptServiceImplTest extends BaseDbUnitTest {
113 129 deptMapper.insert(deptDO2);
114 130 // 准备参数
115 131 List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO2.getId());
  132 + Long userId = randomLongId();
  133 + // mock 方法(数据权限包含全部部门)
  134 + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO();
  135 + deptDataPermission.setDeptIds(asSet(deptDO1.getId(), deptDO2.getId()));
  136 + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission);
116 137  
117 138 // 调用
118   - deptService.deleteDeptList(ids);
  139 + deptService.deleteDeptList(ids, userId);
119 140 // 校验数据不存在了
120 141 assertNull(deptMapper.selectById(deptDO1.getId()));
121 142 assertNull(deptMapper.selectById(deptDO2.getId()));
  143 + // 校验使用传入的用户编号,获取数据权限
  144 + verify(permissionService).getDeptDataPermission(eq(userId));
122 145 }
123 146  
124 147 @Test
... ... @@ -136,9 +159,138 @@ public class DeptServiceImplTest extends BaseDbUnitTest {
136 159  
137 160 // 准备参数(包含有子部门的 parentDept)
138 161 List<Long> ids = Arrays.asList(parentDept.getId(), anotherDept.getId());
  162 + Long userId = randomLongId();
  163 + // mock 方法(数据权限为全部)
  164 + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO();
  165 + deptDataPermission.setAll(true);
  166 + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission);
  167 +
  168 + // 调用, 并断言异常
  169 + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_EXITS_CHILDREN);
  170 + }
  171 +
  172 + @Test
  173 + public void testDeleteDeptList_notInScope() {
  174 + // mock 数据
  175 + DeptDO deptDO1 = randomPojo(DeptDO.class);
  176 + deptMapper.insert(deptDO1);
  177 + DeptDO deptDO2 = randomPojo(DeptDO.class);
  178 + deptMapper.insert(deptDO2);
  179 + // 准备参数
  180 + List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO2.getId());
  181 + Long userId = randomLongId();
  182 + // mock 方法(数据权限只包含 deptDO1)
  183 + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO();
  184 + deptDataPermission.setDeptIds(asSet(deptDO1.getId()));
  185 + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission);
139 186  
140 187 // 调用, 并断言异常
141   - assertServiceException(() -> deptService.deleteDeptList(ids), DEPT_EXITS_CHILDREN);
  188 + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_NOT_IN_SCOPE, deptDO2.getId());
  189 + // 校验整体拒绝,范围内的部门也未被删除
  190 + assertNotNull(deptMapper.selectById(deptDO1.getId()));
  191 + assertNotNull(deptMapper.selectById(deptDO2.getId()));
  192 + }
  193 +
  194 + @Test
  195 + public void testDeleteDeptList_selfScope() {
  196 + // mock 数据
  197 + DeptDO deptDO = randomPojo(DeptDO.class);
  198 + deptMapper.insert(deptDO);
  199 + // 准备参数
  200 + List<Long> ids = singletonList(deptDO.getId());
  201 + Long userId = randomLongId();
  202 + // mock 方法(数据权限为仅本人,部门表无对应的用户字段,无法操作任何部门)
  203 + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO();
  204 + deptDataPermission.setSelf(true);
  205 + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission);
  206 +
  207 + // 调用, 并断言异常
  208 + assertServiceException(() -> deptService.deleteDeptList(ids, userId), DEPT_NOT_IN_SCOPE, deptDO.getId());
  209 + assertNotNull(deptMapper.selectById(deptDO.getId()));
  210 + }
  211 +
  212 + @Test
  213 + public void testDeleteDeptList_allScope() {
  214 + // mock 数据
  215 + DeptDO deptDO1 = randomPojo(DeptDO.class);
  216 + deptMapper.insert(deptDO1);
  217 + DeptDO deptDO2 = randomPojo(DeptDO.class);
  218 + deptMapper.insert(deptDO2);
  219 + // 准备参数
  220 + List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO2.getId());
  221 + Long userId = randomLongId();
  222 + // mock 方法(数据权限为全部,deptIds 为空)
  223 + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO();
  224 + deptDataPermission.setAll(true);
  225 + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission);
  226 +
  227 + // 调用
  228 + deptService.deleteDeptList(ids, userId);
  229 + // 校验数据不存在了
  230 + assertNull(deptMapper.selectById(deptDO1.getId()));
  231 + assertNull(deptMapper.selectById(deptDO2.getId()));
  232 + }
  233 +
  234 + @Test
  235 + public void testDeleteDeptList_duplicateIds() {
  236 + // mock 数据
  237 + DeptDO deptDO1 = randomPojo(DeptDO.class);
  238 + deptMapper.insert(deptDO1);
  239 + DeptDO deptDO2 = randomPojo(DeptDO.class);
  240 + deptMapper.insert(deptDO2);
  241 + // 准备参数(deptDO1 重复)
  242 + List<Long> ids = Arrays.asList(deptDO1.getId(), deptDO1.getId(), deptDO2.getId());
  243 + Long userId = randomLongId();
  244 + // mock 方法
  245 + DeptDataPermissionRespDTO deptDataPermission = new DeptDataPermissionRespDTO();
  246 + deptDataPermission.setDeptIds(asSet(deptDO1.getId(), deptDO2.getId()));
  247 + when(permissionService.getDeptDataPermission(eq(userId))).thenReturn(deptDataPermission);
  248 +
  249 + // 调用
  250 + deptService.deleteDeptList(ids, userId);
  251 + // 校验数据不存在了
  252 + assertNull(deptMapper.selectById(deptDO1.getId()));
  253 + assertNull(deptMapper.selectById(deptDO2.getId()));
  254 + }
  255 +
  256 + @Test
  257 + public void testDeleteDeptList_visitTenant() {
  258 + // mock 数据
  259 + DeptDO deptDO = randomPojo(DeptDO.class);
  260 + deptMapper.insert(deptDO);
  261 + // 准备参数
  262 + List<Long> ids = singletonList(deptDO.getId());
  263 + Long userId = randomLongId();
  264 + // mock 登录用户(跨租户访问)
  265 + LoginUser loginUser = new LoginUser().setId(userId).setUserType(UserTypeEnum.ADMIN.getValue())
  266 + .setTenantId(1L).setVisitTenantId(2L);
  267 + SecurityFrameworkUtils.setLoginUser(loginUser, new MockHttpServletRequest());
  268 +
  269 + try {
  270 + // 调用
  271 + deptService.deleteDeptList(ids, userId);
  272 + } finally {
  273 + SecurityContextHolder.clearContext();
  274 + }
  275 + // 校验数据不存在了,且未获取目标租户的数据权限
  276 + assertNull(deptMapper.selectById(deptDO.getId()));
  277 + verify(permissionService, never()).getDeptDataPermission(anyLong());
  278 + }
  279 +
  280 + @Test
  281 + public void testDeleteDeptList_ignoreDataPermission() {
  282 + // mock 数据
  283 + DeptDO deptDO = randomPojo(DeptDO.class);
  284 + deptMapper.insert(deptDO);
  285 + // 准备参数
  286 + List<Long> ids = singletonList(deptDO.getId());
  287 + Long userId = randomLongId();
  288 +
  289 + // 调用(忽略数据权限)
  290 + DataPermissionUtils.executeIgnore(() -> deptService.deleteDeptList(ids, userId));
  291 + // 校验数据不存在了
  292 + assertNull(deptMapper.selectById(deptDO.getId()));
  293 + verify(permissionService, never()).getDeptDataPermission(anyLong());
142 294 }
143 295  
144 296 @Test
... ...