feat(security): 添加权限检查的可见性兜底功能

- 在 CheckObjectPermission 注解中新增 visibilityFallback 属性,用于读路径的成员/可见性兜底
- 实现 checkPermissionOrVisibility 方法,支持成员权限码验证或用户可见性配置兜底两种方式
- 修改 ObjectPermissionAspect 切面逻辑,增加 visibilityFallback 条件分支处理
- 更新项目执行和任务相关的服务类,在查询注解中启用 visibilityFallback 功能
- 添加完整的单元测试覆盖 visibilityFallback 的各种场景,包括成员权限、可见性配置等
- 集成用户可见性配置 API,支持按方向代码进行可见性判断
This commit is contained in:
2026-06-08 13:16:32 +08:00
parent ab5b00470c
commit d7c52e12d7
14 changed files with 295 additions and 27 deletions

View File

@@ -38,4 +38,11 @@ public @interface CheckObjectPermission {
*/ */
boolean accessible() default false; boolean accessible() default false;
/**
* 读路径专用:成员凭 {@link #permission()} 查询权限码,否则凭「用户可见性配置」(system_user_visibility_config) 兜底。
* 为 true 时切面调用 checkPermissionOrVisibility需与 permission 同时给值。
* 优先级accessible > visibilityFallback > memberOnly > permission。仅用于读接口写路径禁用。
*/
boolean visibilityFallback() default false;
} }

View File

@@ -41,9 +41,11 @@ public class ObjectPermissionAspect {
throw invalidParamException("暂不支持对象类型:{}", checkObjectPermission.objectType()); throw invalidParamException("暂不支持对象类型:{}", checkObjectPermission.objectType());
} }
Long objectId = resolveObjectId(joinPoint, checkObjectPermission.objectId()); Long objectId = resolveObjectId(joinPoint, checkObjectPermission.objectId());
// 分发优先级accessible可访问性门禁> memberOnly / permission权限码 // 分发优先级accessible可访问性门禁> visibilityFallback读路径成员/可见性兜底)> memberOnly / permission权限码
if (checkObjectPermission.accessible()) { if (checkObjectPermission.accessible()) {
permissionService.checkAccessible(objectId); permissionService.checkAccessible(objectId);
} else if (checkObjectPermission.visibilityFallback()) {
permissionService.checkPermissionOrVisibility(objectId, checkObjectPermission.permission());
} else { } else {
permissionService.checkPermission(objectId, checkObjectPermission.permission(), permissionService.checkPermission(objectId, checkObjectPermission.permission(),
checkObjectPermission.memberOnly()); checkObjectPermission.memberOnly());

View File

@@ -39,4 +39,15 @@ public interface ObjectPermissionService {
*/ */
void checkAccessible(Long objectId); void checkAccessible(Long objectId);
/**
* 读路径鉴权(成员 OR 可见性配置兜底):
* 成员凭 permission 权限码放行;非成员 / 成员无该权限码时,凭用户可见性配置(通道 3
* type=all 或方向命中)放行;都不满足抛 ..._OBJECT_PERMISSION_DENIED。
* 仅用于对象内读接口,不调超管短路 / 组织负责人通道 / 完整数据范围。
*
* @param objectId 对象编号(如 projectId
* @param permission 权限码,如 {@code project:task:query}
*/
void checkPermissionOrVisibility(Long objectId, String permission);
} }

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.framework.security.service; package com.njcn.rdms.module.project.framework.security.service;
import cn.hutool.core.collection.CollUtil;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants; import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
import com.njcn.rdms.module.project.constant.ProductObjectConstants; import com.njcn.rdms.module.project.constant.ProductObjectConstants;
@@ -11,6 +12,8 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope; import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService; import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -40,6 +43,8 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
private ProductMapper productMapper; private ProductMapper productMapper;
@Resource @Resource
private ObjectDataScopeService objectDataScopeService; private ObjectDataScopeService objectDataScopeService;
@Resource
private UserVisibilityConfigApi userVisibilityConfigApi;
@Override @Override
public String getObjectType() { public String getObjectType() {
@@ -55,6 +60,49 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
return false; return false;
} }
@Override
public void checkPermissionOrVisibility(Long objectId, String permission) {
if (objectId == null) {
throw invalidParamException("对象编号不能为空");
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 1) 成员 + 权限码
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProductObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (!userRoles.isEmpty()) {
String normalizedPermission = normalizePermission(permission);
boolean allowed = userRoles.stream()
.map(UserObjectRoleDO::getRoleId)
.distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (allowed) {
return;
}
}
// 2) 用户可见性配置兜底(按产品方向命中)
if (visibleByUserVisibilityConfig(loginUserId, objectId)) {
return;
}
// 3) 脱敏抛异常
log.warn("[checkPermissionOrVisibility] 无读权限objectId={}, permission={}", objectId, permission);
throw exception(ErrorCodeConstants.PRODUCT_OBJECT_PERMISSION_DENIED);
}
private boolean visibleByUserVisibilityConfig(Long loginUserId, Long productId) {
UserVisibilityConfigRespDTO cfg = userVisibilityConfigApi.getConfig(loginUserId).getCheckedData();
if (cfg == null) {
return false;
}
if ("all".equals(cfg.getType())) {
return true;
}
if ("directions".equals(cfg.getType()) && CollUtil.isNotEmpty(cfg.getDirectionCodes())) {
ProductDO product = productMapper.selectById(productId);
return product != null && cfg.getDirectionCodes().contains(product.getDirectionCode());
}
return false;
}
@Override @Override
public void checkPermission(Long objectId, String permission, boolean memberOnly) { public void checkPermission(Long objectId, String permission, boolean memberOnly) {
if (objectId == null) { if (objectId == null) {

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.framework.security.service; package com.njcn.rdms.module.project.framework.security.service;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils; import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants; import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
@@ -12,6 +13,8 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope; import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService; import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -41,6 +44,8 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@Resource @Resource
private ObjectDataScopeService objectDataScopeService; private ObjectDataScopeService objectDataScopeService;
@Resource
private UserVisibilityConfigApi userVisibilityConfigApi;
@Override @Override
public String getObjectType() { public String getObjectType() {
@@ -129,6 +134,53 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
} }
} }
@Override
public void checkPermissionOrVisibility(Long objectId, String permission) {
if (objectId == null) {
throw invalidParamException("对象编号不能为空");
}
Long loginUserId = SecurityFrameworkUtils.getLoginUserId();
// 1) 成员 + 权限码:沿用 checkPermission 口径(成员里仍按 query 权限码细分)
List<UserObjectRoleDO> userRoles = userObjectRoleMapper
.selectActiveListByObjectAndUserId(ProjectObjectConstants.OBJECT_TYPE, objectId, loginUserId);
if (!userRoles.isEmpty()) {
String normalizedPermission = normalizePermission(permission);
boolean allowed = userRoles.stream()
.map(UserObjectRoleDO::getRoleId)
.distinct()
.anyMatch(roleId -> getRolePermissions(roleId).contains(normalizedPermission));
if (allowed) {
return;
}
}
// 2) 非成员 / 成员无该权限码:落用户可见性配置兜底(只查通道 3不开超管短路 / 组织负责人 / 完整 scope
if (visibleByUserVisibilityConfig(loginUserId, objectId)) {
return;
}
// 3) 都不满足:脱敏抛异常(权限码 / 配置只进日志,见 用户可见错误文案规范)
log.warn("[checkPermissionOrVisibility] 无读权限objectId={}, permission={}", objectId, permission);
throw exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED);
}
/**
* 用户可见性配置(通道 3兜底type=all 直接放行type=directions 时项目方向命中放行。
* 不消费 type=projects与现有数据范围口径一致
*/
private boolean visibleByUserVisibilityConfig(Long loginUserId, Long projectId) {
UserVisibilityConfigRespDTO cfg = userVisibilityConfigApi.getConfig(loginUserId).getCheckedData();
if (cfg == null) {
return false;
}
if ("all".equals(cfg.getType())) {
return true;
}
if ("directions".equals(cfg.getType()) && CollUtil.isNotEmpty(cfg.getDirectionCodes())) {
ProjectDO project = projectMapper.selectById(projectId);
return project != null && cfg.getDirectionCodes().contains(project.getDirectionCode());
}
return false;
}
private Set<String> getRolePermissions(Long roleId) { private Set<String> getRolePermissions(Long roleId) {
Set<String> permissions = objectPermissionApi Set<String> permissions = objectPermissionApi
.getObjectRolePermissions(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE) .getObjectRolePermissions(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE)

View File

@@ -46,7 +46,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY) permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) { public ProjectExecutionStatusBoardRespVO getExecutionStatusBoard(Long projectId, ProjectExecutionStatusBoardReqVO reqVO) {
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE); List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectExecutionConstants.OBJECT_TYPE);
return buildExecutionStatusBoard(projectId, reqVO, statusModels); return buildExecutionStatusBoard(projectId, reqVO, statusModels);
@@ -54,7 +54,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) { public ProjectTaskStatusBoardRespVO getTaskStatusBoard(Long projectId, Long executionId, ProjectTaskStatusBoardReqVO reqVO) {
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); List<ObjectStatusModelDO> statusModels = objectStatusModelMapper.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);
return buildTaskStatusBoard(projectId, executionId, reqVO, statusModels); return buildTaskStatusBoard(projectId, executionId, reqVO, statusModels);
@@ -62,7 +62,7 @@ public class ProjectStatusBoardServiceImpl implements ProjectStatusBoardService
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO) { public ProjectTaskBoardPageRespVO getTaskBoardPage(Long projectId, Long executionId, ProjectTaskBoardPageReqVO reqVO) {
List<ObjectStatusModelDO> statusModels = objectStatusModelMapper List<ObjectStatusModelDO> statusModels = objectStatusModelMapper
.selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE); .selectListByObjectTypeEnabled(ProjectTaskConstants.OBJECT_TYPE);

View File

@@ -79,7 +79,7 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY) permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
public List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId) { public List<ExecutionAssigneeRespVO> getExecutionAssigneeList(Long projectId, Long executionId) {
validateProjectExists(projectId); validateProjectExists(projectId);
validateExecutionExists(projectId, executionId); validateExecutionExists(projectId, executionId);
@@ -153,7 +153,7 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY) permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
public PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId, public PageResult<ExecutionAssigneeLogRespVO> getExecutionAssigneeLogPage(Long projectId, Long executionId,
ExecutionAssigneeLogPageReqVO reqVO) { ExecutionAssigneeLogPageReqVO reqVO) {
validateProjectExists(projectId); validateProjectExists(projectId);

View File

@@ -212,7 +212,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY) permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) { public PageResult<ProjectExecutionDO> getExecutionPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
validateProjectExists(projectId); validateProjectExists(projectId);
// "我参与 / 所有"视角完全由前端发不发 reqVO.involveUserId 决定。 // "我参与 / 所有"视角完全由前端发不发 reqVO.involveUserId 决定。
@@ -227,7 +227,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY) permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId) { public ProjectExecutionRespVO getExecutionRespVO(Long projectId, Long executionId) {
ProjectExecutionDO execution = getExecution(projectId, executionId); ProjectExecutionDO execution = getExecution(projectId, executionId);
ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class); ProjectExecutionRespVO respVO = BeanUtils.toBean(execution, ProjectExecutionRespVO.class);
@@ -241,7 +241,7 @@ public class ProjectExecutionServiceImpl implements ProjectExecutionService {
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectExecutionConstants.PERMISSION_QUERY) permission = ProjectExecutionConstants.PERMISSION_QUERY, visibilityFallback = true)
public PageResult<ProjectExecutionRespVO> getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO) { public PageResult<ProjectExecutionRespVO> getExecutionRespVOPage(Long projectId, ProjectExecutionPageReqVO reqVO) {
PageResult<ProjectExecutionDO> pageResult = getExecutionPage(projectId, reqVO); PageResult<ProjectExecutionDO> pageResult = getExecutionPage(projectId, reqVO);
PageResult<ProjectExecutionRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectExecutionRespVO.class); PageResult<ProjectExecutionRespVO> voPageResult = BeanUtils.toBean(pageResult, ProjectExecutionRespVO.class);

View File

@@ -68,7 +68,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public PageResult<ProjectTaskRespVO> getAggregateTaskPage(Long projectId, ProjectTaskAggregatePageReqVO reqVO) { public PageResult<ProjectTaskRespVO> getAggregateTaskPage(Long projectId, ProjectTaskAggregatePageReqVO reqVO) {
// 空数组语义短路:前端明确"按 0 个执行状态/执行 id 过滤" → 返空集合,不让 MyBatis 退化成"不过滤" // 空数组语义短路:前端明确"按 0 个执行状态/执行 id 过滤" → 返空集合,不让 MyBatis 退化成"不过滤"
if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty()) if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
@@ -91,7 +91,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public ProjectTaskStatusBoardRespVO getAggregateTaskStatusBoard(Long projectId, ProjectTaskAggregateStatusBoardReqVO reqVO) { public ProjectTaskStatusBoardRespVO getAggregateTaskStatusBoard(Long projectId, ProjectTaskAggregateStatusBoardReqVO reqVO) {
// 空数组语义短路:跳过 SQL,但保留状态列骨架(前端看板依赖全列表渲染),count 全部 0 // 空数组语义短路:跳过 SQL,但保留状态列骨架(前端看板依赖全列表渲染),count 全部 0
if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty()) if ((reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
@@ -148,7 +148,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public ProjectTaskBoardPageRespVO getAggregateTaskBoardPage(Long projectId, ProjectTaskAggregateBoardPageReqVO reqVO) { public ProjectTaskBoardPageRespVO getAggregateTaskBoardPage(Long projectId, ProjectTaskAggregateBoardPageReqVO reqVO) {
// 空数组语义短路:executionStatusCodes 或 executionIds 为空数组 → 该范围明确为空,跳过 SQL,保留列骨架(每列 list=[] / total=0) // 空数组语义短路:executionStatusCodes 或 executionIds 为空数组 → 该范围明确为空,跳过 SQL,保留列骨架(每列 list=[] / total=0)
boolean emptyExecScope = (reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty()) boolean emptyExecScope = (reqVO.getExecutionStatusCodes() != null && reqVO.getExecutionStatusCodes().isEmpty())
@@ -238,7 +238,7 @@ public class ProjectTaskAggregateServiceImpl implements ProjectTaskAggregateServ
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public ProjectTaskSummaryRespVO getAggregateTaskSummary(Long projectId, ProjectTaskSummaryReqVO reqVO) { public ProjectTaskSummaryRespVO getAggregateTaskSummary(Long projectId, ProjectTaskSummaryReqVO reqVO) {
LocalDate today = today(); LocalDate today = today();
LocalDate weekStart = weekStart(today); LocalDate weekStart = weekStart(today);

View File

@@ -432,7 +432,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { public PageResult<ProjectTaskDO> getTaskPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
validateExecutionExists(projectId, executionId); validateExecutionExists(projectId, executionId);
// 进得来 = 看执行下全部任务,"我参与 / 所有"由前端发不发 reqVO.ownerId / involveUserId 决定。 // 进得来 = 看执行下全部任务,"我参与 / 所有"由前端发不发 reqVO.ownerId / involveUserId 决定。
@@ -443,7 +443,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) { public ProjectTaskRespVO getTaskRespVO(Long projectId, Long executionId, Long taskId) {
// 内联 validate便于接住 execution 供前端 executionOwnerId 字段使用 // 内联 validate便于接住 execution 供前端 executionOwnerId 字段使用
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId); ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
@@ -467,7 +467,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) { public PageResult<ProjectTaskRespVO> getTaskRespVOPage(Long projectId, Long executionId, ProjectTaskPageReqVO reqVO) {
PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO); PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
return assembleTaskRespVOPage(projectId, executionId, pageResult); return assembleTaskRespVOPage(projectId, executionId, pageResult);

View File

@@ -70,7 +70,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public List<TaskAssigneeRespVO> getAssigneeList(Long projectId, Long executionId, Long taskId) { public List<TaskAssigneeRespVO> getAssigneeList(Long projectId, Long executionId, Long taskId) {
validateExecutionAndTaskExists(projectId, executionId, taskId); validateExecutionAndTaskExists(projectId, executionId, taskId);
List<TaskAssigneeDO> activeList = taskAssigneeMapper.selectActiveListByTaskId(taskId); List<TaskAssigneeDO> activeList = taskAssigneeMapper.selectActiveListByTaskId(taskId);
@@ -124,7 +124,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public PageResult<TaskAssigneeLogRespVO> getAssigneeLogPage(Long projectId, Long executionId, Long taskId, public PageResult<TaskAssigneeLogRespVO> getAssigneeLogPage(Long projectId, Long executionId, Long taskId,
TaskAssigneeLogPageReqVO reqVO) { TaskAssigneeLogPageReqVO reqVO) {
validateExecutionAndTaskExists(projectId, executionId, taskId); validateExecutionAndTaskExists(projectId, executionId, taskId);

View File

@@ -80,7 +80,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
@Override @Override
@CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId", @CheckObjectPermission(objectType = ProjectObjectConstants.OBJECT_TYPE, objectId = "#projectId",
permission = ProjectTaskConstants.PERMISSION_QUERY) permission = ProjectTaskConstants.PERMISSION_QUERY, visibilityFallback = true)
public PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId, public PageResult<TaskWorklogRespVO> getWorklogPage(Long projectId, Long executionId, Long taskId,
TaskWorklogPageReqVO reqVO) { TaskWorklogPageReqVO reqVO) {
validateExecutionAndTaskExists(projectId, executionId, taskId); validateExecutionAndTaskExists(projectId, executionId, taskId);

View File

@@ -36,8 +36,9 @@ import static org.mockito.Mockito.when;
* {@link com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission},且: * {@link com.njcn.rdms.module.project.framework.security.annotation.CheckObjectPermission},且:
* <ul> * <ul>
* <li>{@code objectId="#projectId"} 解析为 projectId而非 executionId/taskId</li> * <li>{@code objectId="#projectId"} 解析为 projectId而非 executionId/taskId</li>
* <li>permission 为同层 QUERY 码(执行域 project:execution:query、任务域 project:task:query</li> * <li>permission 为同层 QUERY 码(执行域 project:execution:query、任务域 project:task:query
* <li>无权checkPermission 抛异常)时方法体被拦、不执行。</li> * 且因挂了 {@code visibilityFallback=true} 走可见性兜底通道 {@code checkPermissionOrVisibility}(而非 checkPermission</li>
* <li>无权checkPermissionOrVisibility 抛异常)时方法体被拦、不执行。</li>
* </ul> * </ul>
* 沿用 {@link ObjectPermissionAspectTest} 的 {@link AspectJProxyFactory} 手动织入方式,纯单测、不连库、不起容器。 * 沿用 {@link ObjectPermissionAspectTest} 的 {@link AspectJProxyFactory} 手动织入方式,纯单测、不连库、不起容器。
* 真实的「有无权限」判定逻辑由 ProjectObjectPermissionServiceTest 覆盖,本类不重复。 * 真实的「有无权限」判定逻辑由 ProjectObjectPermissionServiceTest 覆盖,本类不重复。
@@ -59,7 +60,7 @@ class AssigneeWorklogReadPermissionTest extends BaseMockitoUnitTest {
assertThrows(ServiceException.class, () -> proxy.getExecutionAssigneeList(PROJECT_ID, EXECUTION_ID)); assertThrows(ServiceException.class, () -> proxy.getExecutionAssigneeList(PROJECT_ID, EXECUTION_ID));
verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectExecutionConstants.PERMISSION_QUERY, false); verify(objectPermissionService).checkPermissionOrVisibility(PROJECT_ID, ProjectExecutionConstants.PERMISSION_QUERY);
} }
@Test @Test
@@ -71,7 +72,7 @@ class AssigneeWorklogReadPermissionTest extends BaseMockitoUnitTest {
assertThrows(ServiceException.class, assertThrows(ServiceException.class,
() -> proxy.getExecutionAssigneeLogPage(PROJECT_ID, EXECUTION_ID, new ExecutionAssigneeLogPageReqVO())); () -> proxy.getExecutionAssigneeLogPage(PROJECT_ID, EXECUTION_ID, new ExecutionAssigneeLogPageReqVO()));
verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectExecutionConstants.PERMISSION_QUERY, false); verify(objectPermissionService).checkPermissionOrVisibility(PROJECT_ID, ProjectExecutionConstants.PERMISSION_QUERY);
} }
@Test @Test
@@ -81,7 +82,7 @@ class AssigneeWorklogReadPermissionTest extends BaseMockitoUnitTest {
assertThrows(ServiceException.class, () -> proxy.getAssigneeList(PROJECT_ID, EXECUTION_ID, TASK_ID)); assertThrows(ServiceException.class, () -> proxy.getAssigneeList(PROJECT_ID, EXECUTION_ID, TASK_ID));
verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectTaskConstants.PERMISSION_QUERY, false); verify(objectPermissionService).checkPermissionOrVisibility(PROJECT_ID, ProjectTaskConstants.PERMISSION_QUERY);
} }
@Test @Test
@@ -92,7 +93,7 @@ class AssigneeWorklogReadPermissionTest extends BaseMockitoUnitTest {
assertThrows(ServiceException.class, assertThrows(ServiceException.class,
() -> proxy.getAssigneeLogPage(PROJECT_ID, EXECUTION_ID, TASK_ID, new TaskAssigneeLogPageReqVO())); () -> proxy.getAssigneeLogPage(PROJECT_ID, EXECUTION_ID, TASK_ID, new TaskAssigneeLogPageReqVO()));
verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectTaskConstants.PERMISSION_QUERY, false); verify(objectPermissionService).checkPermissionOrVisibility(PROJECT_ID, ProjectTaskConstants.PERMISSION_QUERY);
} }
@Test @Test
@@ -103,7 +104,7 @@ class AssigneeWorklogReadPermissionTest extends BaseMockitoUnitTest {
assertThrows(ServiceException.class, assertThrows(ServiceException.class,
() -> proxy.getWorklogPage(PROJECT_ID, EXECUTION_ID, TASK_ID, new TaskWorklogPageReqVO())); () -> proxy.getWorklogPage(PROJECT_ID, EXECUTION_ID, TASK_ID, new TaskWorklogPageReqVO()));
verify(objectPermissionService).checkPermission(PROJECT_ID, ProjectTaskConstants.PERMISSION_QUERY, false); verify(objectPermissionService).checkPermissionOrVisibility(PROJECT_ID, ProjectTaskConstants.PERMISSION_QUERY);
} }
/** /**
@@ -122,7 +123,7 @@ class AssigneeWorklogReadPermissionTest extends BaseMockitoUnitTest {
*/ */
private void denyPermission(String permission) { private void denyPermission(String permission) {
doThrow(exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED)) doThrow(exception(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED))
.when(objectPermissionService).checkPermission(eq(PROJECT_ID), eq(permission), eq(false)); .when(objectPermissionService).checkPermissionOrVisibility(eq(PROJECT_ID), eq(permission));
} }
} }

View File

@@ -11,6 +11,7 @@ import com.njcn.rdms.module.project.enums.ErrorCodeConstants;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScope; import com.njcn.rdms.module.project.service.datascope.ObjectDataScope;
import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService; import com.njcn.rdms.module.project.service.datascope.ObjectDataScopeService;
import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi; import com.njcn.rdms.module.system.api.permission.ObjectPermissionApi;
import com.njcn.rdms.module.system.api.permission.dto.UserVisibilityConfigRespDTO;
import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum; import com.njcn.rdms.module.system.enums.permission.PermissionScopeTypeEnum;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@@ -42,6 +43,8 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
private ProjectMapper projectMapper; private ProjectMapper projectMapper;
@Mock @Mock
private ObjectDataScopeService objectDataScopeService; private ObjectDataScopeService objectDataScopeService;
@Mock
private com.njcn.rdms.module.system.api.permission.UserVisibilityConfigApi userVisibilityConfigApi;
@Test @Test
void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() { void checkPermission_whenMemberOnlyAndActiveMember_shouldPass() {
@@ -213,6 +216,150 @@ class ProjectObjectPermissionServiceTest extends BaseMockitoUnitTest {
verifyNoInteractions(objectDataScopeService); verifyNoInteractions(objectDataScopeService);
} }
// ===== checkPermissionOrVisibility =====
@Test
void checkPermissionOrVisibility_whenMemberHasQuery_shouldPassWithoutVisibility() {
Long projectId = 1201L, loginUserId = 2201L;
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3201L)));
when(objectPermissionApi.getObjectRolePermissions(3201L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
.thenReturn(success(Set.of("project:task:query")));
try (MockedStatic<SecurityFrameworkUtils> s = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermissionOrVisibility(projectId, "project:task:query"));
}
// 成员凭权限码即放行,不触达可见性配置兜底
verifyNoInteractions(userVisibilityConfigApi);
}
@Test
void checkPermissionOrVisibility_whenMemberNoQueryButVisibilityAll_shouldPass() {
Long projectId = 1202L, loginUserId = 2202L;
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3202L)));
when(objectPermissionApi.getObjectRolePermissions(3202L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
.thenReturn(success(Set.of("project:task:update"))); // 成员有角色但无 query
when(userVisibilityConfigApi.getConfig(loginUserId)).thenReturn(success(cfgAll()));
try (MockedStatic<SecurityFrameworkUtils> s = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermissionOrVisibility(projectId, "project:task:query"));
}
}
@Test
void checkPermissionOrVisibility_whenMemberNoQueryButVisibilityDirectionHit_shouldPass() {
Long projectId = 1203L, loginUserId = 2203L;
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3203L)));
when(objectPermissionApi.getObjectRolePermissions(3203L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
.thenReturn(success(Set.of("project:task:update")));
when(userVisibilityConfigApi.getConfig(loginUserId)).thenReturn(success(cfgDirections("D-NET")));
when(projectMapper.selectById(projectId)).thenReturn(projectOf(projectId, "D-NET"));
try (MockedStatic<SecurityFrameworkUtils> s = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermissionOrVisibility(projectId, "project:task:query"));
}
}
@Test
void checkPermissionOrVisibility_whenNonMemberButVisibilityAll_shouldPass() {
Long projectId = 1204L, loginUserId = 2204L;
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
when(userVisibilityConfigApi.getConfig(loginUserId)).thenReturn(success(cfgAll()));
try (MockedStatic<SecurityFrameworkUtils> s = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermissionOrVisibility(projectId, "project:task:query"));
}
// 非成员不查角色权限码
verifyNoInteractions(objectPermissionApi);
}
@Test
void checkPermissionOrVisibility_whenNonMemberDirectionHit_shouldPass() {
Long projectId = 1205L, loginUserId = 2205L;
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
when(userVisibilityConfigApi.getConfig(loginUserId)).thenReturn(success(cfgDirections("D-NET")));
when(projectMapper.selectById(projectId)).thenReturn(projectOf(projectId, "D-NET"));
try (MockedStatic<SecurityFrameworkUtils> s = mockLoginUser(loginUserId)) {
assertDoesNotThrow(() -> permissionService.checkPermissionOrVisibility(projectId, "project:task:query"));
}
}
@Test
void checkPermissionOrVisibility_whenNonMemberNoConfig_shouldThrowDenied() {
Long projectId = 1206L, loginUserId = 2206L;
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
when(userVisibilityConfigApi.getConfig(loginUserId)).thenReturn(success(null)); // 无配置
try (MockedStatic<SecurityFrameworkUtils> s = mockLoginUser(loginUserId)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> permissionService.checkPermissionOrVisibility(projectId, "project:task:query"));
assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码");
}
}
@Test
void checkPermissionOrVisibility_whenNonMemberDirectionMiss_shouldThrowDenied() {
Long projectId = 1207L, loginUserId = 2207L;
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(Collections.emptyList());
when(userVisibilityConfigApi.getConfig(loginUserId)).thenReturn(success(cfgDirections("D-OTHER")));
when(projectMapper.selectById(projectId)).thenReturn(projectOf(projectId, "D-NET"));
try (MockedStatic<SecurityFrameworkUtils> s = mockLoginUser(loginUserId)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> permissionService.checkPermissionOrVisibility(projectId, "project:task:query"));
assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
}
}
@Test
void checkPermissionOrVisibility_whenMemberNoQueryAndNoVisibility_shouldThrowDenied() {
Long projectId = 1208L, loginUserId = 2208L;
when(userObjectRoleMapper.selectActiveListByObjectAndUserId("project", projectId, loginUserId))
.thenReturn(List.of(createMember(projectId, loginUserId, 3208L)));
when(objectPermissionApi.getObjectRolePermissions(3208L,
PermissionScopeTypeEnum.OBJECT.getScopeType(), "project"))
.thenReturn(success(Set.of("project:task:update")));
when(userVisibilityConfigApi.getConfig(loginUserId)).thenReturn(success(null));
try (MockedStatic<SecurityFrameworkUtils> s = mockLoginUser(loginUserId)) {
ServiceException ex = assertThrows(ServiceException.class,
() -> permissionService.checkPermissionOrVisibility(projectId, "project:task:query"));
assertEquals(ErrorCodeConstants.PROJECT_OBJECT_PERMISSION_DENIED.getCode(), ex.getCode());
assertFalse(ex.getMessage().contains(":"), "message 不应外泄权限码");
}
}
private UserVisibilityConfigRespDTO cfgAll() {
UserVisibilityConfigRespDTO cfg = new UserVisibilityConfigRespDTO();
cfg.setType("all");
return cfg;
}
private UserVisibilityConfigRespDTO cfgDirections(String... codes) {
UserVisibilityConfigRespDTO cfg = new UserVisibilityConfigRespDTO();
cfg.setType("directions");
cfg.setDirectionCodes(Set.of(codes));
return cfg;
}
private ProjectDO projectOf(Long projectId, String directionCode) {
ProjectDO project = new ProjectDO();
project.setId(projectId);
project.setDirectionCode(directionCode);
return project;
}
private UserObjectRoleDO createMember(Long projectId, Long loginUserId, Long roleId) { private UserObjectRoleDO createMember(Long projectId, Long loginUserId, Long roleId) {
UserObjectRoleDO member = new UserObjectRoleDO(); UserObjectRoleDO member = new UserObjectRoleDO();
member.setId(9001L); member.setId(9001L);