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;
/**
* 读路径专用:成员凭 {@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());
}
Long objectId = resolveObjectId(joinPoint, checkObjectPermission.objectId());
// 分发优先级accessible可访问性门禁> memberOnly / permission权限码
// 分发优先级accessible可访问性门禁> visibilityFallback读路径成员/可见性兜底)> memberOnly / permission权限码
if (checkObjectPermission.accessible()) {
permissionService.checkAccessible(objectId);
} else if (checkObjectPermission.visibilityFallback()) {
permissionService.checkPermissionOrVisibility(objectId, checkObjectPermission.permission());
} else {
permissionService.checkPermission(objectId, checkObjectPermission.permission(),
checkObjectPermission.memberOnly());

View File

@@ -39,4 +39,15 @@ public interface ObjectPermissionService {
*/
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;
import cn.hutool.core.collection.CollUtil;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
import com.njcn.rdms.module.project.constant.ObjectRoleConstants;
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.ObjectDataScopeService;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -40,6 +43,8 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
private ProductMapper productMapper;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Resource
private UserVisibilityConfigApi userVisibilityConfigApi;
@Override
public String getObjectType() {
@@ -55,6 +60,49 @@ public class ProductObjectPermissionService implements ObjectPermissionService {
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
public void checkPermission(Long objectId, String permission, boolean memberOnly) {
if (objectId == null) {

View File

@@ -1,5 +1,6 @@
package com.njcn.rdms.module.project.framework.security.service;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.njcn.rdms.framework.security.core.util.SecurityFrameworkUtils;
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.ObjectDataScopeService;
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 lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -41,6 +44,8 @@ public class ProjectObjectPermissionService implements ObjectPermissionService {
private ProjectMapper projectMapper;
@Resource
private ObjectDataScopeService objectDataScopeService;
@Resource
private UserVisibilityConfigApi userVisibilityConfigApi;
@Override
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) {
Set<String> permissions = objectPermissionApi
.getObjectRolePermissions(roleId, ObjectRoleConstants.ROLE_SCOPE_OBJECT, ProjectObjectConstants.OBJECT_TYPE)

View File

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

View File

@@ -79,7 +79,7 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
@Override
@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) {
validateProjectExists(projectId);
validateExecutionExists(projectId, executionId);
@@ -153,7 +153,7 @@ public class ProjectExecutionAssigneeServiceImpl implements ProjectExecutionAssi
@Override
@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,
ExecutionAssigneeLogPageReqVO reqVO) {
validateProjectExists(projectId);

View File

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

View File

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

View File

@@ -432,7 +432,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
@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) {
validateExecutionExists(projectId, executionId);
// 进得来 = 看执行下全部任务,"我参与 / 所有"由前端发不发 reqVO.ownerId / involveUserId 决定。
@@ -443,7 +443,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
@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) {
// 内联 validate便于接住 execution 供前端 executionOwnerId 字段使用
ProjectExecutionDO execution = validateExecutionExists(projectId, executionId);
@@ -467,7 +467,7 @@ public class ProjectTaskServiceImpl implements ProjectTaskService {
@Override
@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) {
PageResult<ProjectTaskDO> pageResult = getTaskPage(projectId, executionId, reqVO);
return assembleTaskRespVOPage(projectId, executionId, pageResult);

View File

@@ -70,7 +70,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Override
@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) {
validateExecutionAndTaskExists(projectId, executionId, taskId);
List<TaskAssigneeDO> activeList = taskAssigneeMapper.selectActiveListByTaskId(taskId);
@@ -124,7 +124,7 @@ public class TaskAssigneeServiceImpl implements TaskAssigneeService {
@Override
@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,
TaskAssigneeLogPageReqVO reqVO) {
validateExecutionAndTaskExists(projectId, executionId, taskId);

View File

@@ -80,7 +80,7 @@ public class TaskWorklogServiceImpl implements TaskWorklogService {
@Override
@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,
TaskWorklogPageReqVO reqVO) {
validateExecutionAndTaskExists(projectId, executionId, taskId);