diff --git a/dc3-common/dc3-common-auth/src/main/java/io/github/pnoker/common/auth/controller/MenuController.java b/dc3-common/dc3-common-auth/src/main/java/io/github/pnoker/common/auth/controller/MenuController.java index 490d6c159..6d74e99f5 100644 --- a/dc3-common/dc3-common-auth/src/main/java/io/github/pnoker/common/auth/controller/MenuController.java +++ b/dc3-common/dc3-common-auth/src/main/java/io/github/pnoker/common/auth/controller/MenuController.java @@ -20,11 +20,13 @@ package io.github.pnoker.common.auth.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import io.github.pnoker.common.auth.entity.bo.MenuBO; import io.github.pnoker.common.auth.entity.bo.MenuTreeBO; +import io.github.pnoker.common.auth.entity.bo.ResourceBO; import io.github.pnoker.common.auth.entity.builder.MenuBuilder; import io.github.pnoker.common.auth.entity.query.MenuQuery; import io.github.pnoker.common.auth.entity.vo.MenuTreeVO; import io.github.pnoker.common.auth.entity.vo.MenuVO; import io.github.pnoker.common.auth.service.MenuService; +import io.github.pnoker.common.auth.service.RoleResourceBindService; import io.github.pnoker.common.base.BaseController; import io.github.pnoker.common.constant.service.AuthConstant; import io.github.pnoker.common.entity.R; @@ -47,6 +49,8 @@ import reactor.core.publisher.Mono; import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -69,6 +73,8 @@ public class MenuController implements BaseController { private final MenuService menuService; + private final RoleResourceBindService roleResourceBindService; + @PreAuthorize("@perm.can('menu', 'add')") @Operation(summary = "新增菜单管理", description = "新增一条菜单记录") @PostMapping("/add") @@ -139,15 +145,44 @@ public class MenuController implements BaseController { @Operation(summary = "查询菜单管理列表", description = "分页查询菜单管理列表") @PostMapping("/list_tree") public Mono>> listTree(@RequestBody(required = false) MenuQuery entityQuery) { - // Read access to global menu data is open to all authenticated users. - return async(() -> { + return getUserHeader().flatMap(header -> async(() -> { List entityBOList = menuService.listTree(entityQuery); + entityBOList = filterByUserMenuResources(entityBOList, header.getUserId(), header.getTenantId()); List entityVOList = new ArrayList<>(entityBOList.size()); for (MenuTreeBO node : entityBOList) { entityVOList.add(toTreeVO(node)); } return R.ok(entityVOList); - }); + })); + } + + private List filterByUserMenuResources(List nodes, Long userId, Long tenantId) { + List resources = roleResourceBindService.listResourceByUserId(userId, tenantId); + Set visibleMenuCodes = resources.stream() + .map(ResourceBO::getResourceCode) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (visibleMenuCodes.contains("*")) { + return nodes; + } + return nodes.stream() + .map(node -> retainAccessibleMenuNode(node, visibleMenuCodes)) + .filter(Objects::nonNull) + .toList(); + } + + private MenuTreeBO retainAccessibleMenuNode(MenuTreeBO node, Set visibleMenuCodes) { + List children = Objects.requireNonNullElse(node.getChildren(), List.of()) + .stream() + .map(child -> retainAccessibleMenuNode(child, visibleMenuCodes)) + .filter(Objects::nonNull) + .toList(); + boolean selfVisible = visibleMenuCodes.contains("menu:" + Objects.requireNonNullElse(node.getMenuCode(), "")); + if (!selfVisible && children.isEmpty()) { + return null; + } + node.setChildren(children); + return node; } private MenuTreeVO toTreeVO(MenuTreeBO node) { diff --git a/dc3-common/dc3-common-resource-registrar/src/main/java/io/github/pnoker/common/resource/registrar/scan/ApiEndpointScanner.java b/dc3-common/dc3-common-resource-registrar/src/main/java/io/github/pnoker/common/resource/registrar/scan/ApiEndpointScanner.java index fc0a739f7..dcc817406 100644 --- a/dc3-common/dc3-common-resource-registrar/src/main/java/io/github/pnoker/common/resource/registrar/scan/ApiEndpointScanner.java +++ b/dc3-common/dc3-common-resource-registrar/src/main/java/io/github/pnoker/common/resource/registrar/scan/ApiEndpointScanner.java @@ -21,6 +21,7 @@ import io.github.pnoker.common.constant.common.SymbolConstant; import io.github.pnoker.common.facade.entity.bo.FacadeScannedApiBO; import io.github.pnoker.common.resource.registrar.config.ResourceRegistrarProperties; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.util.AntPathMatcher; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.method.HandlerMethod; @@ -34,6 +35,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Walks the WebFlux handler mappings and turns every HTTP endpoint into a @@ -53,6 +56,9 @@ public class ApiEndpointScanner { private static final List DEFAULT_EXCLUDES = List.of("/actuator/**", "/error", "/error/**", "/favicon.ico"); + private static final Pattern PERMISSION_CAN_PATTERN = Pattern.compile( + "@perm\\.can\\(\\s*['\"]([^'\"]+)['\"]\\s*,\\s*['\"]([^'\"]+)['\"]\\s*\\)"); + private final RequestMappingHandlerMapping handlerMapping; private final ResourceRegistrarProperties properties; @@ -68,6 +74,11 @@ public class ApiEndpointScanner { */ private static String buildApiName(HandlerMethod handler, String httpMethod, boolean isSingleGet) { + String protectedApiName = resolvePreAuthorizeApiName(handler); + if (Objects.nonNull(protectedApiName)) { + return protectedApiName; + } + String className = handler.getBeanType().getSimpleName(); // Strip "Controller" suffix, convert camelCase to snake_case → domain String domain = className @@ -89,6 +100,22 @@ public class ApiEndpointScanner { return domain + SymbolConstant.COLON + scope; } + private static String resolvePreAuthorizeApiName(HandlerMethod handler) { + PreAuthorize preAuthorize = handler.getMethodAnnotation(PreAuthorize.class); + if (Objects.isNull(preAuthorize)) { + preAuthorize = handler.getBeanType().getAnnotation(PreAuthorize.class); + } + if (Objects.isNull(preAuthorize)) { + return null; + } + + Matcher matcher = PERMISSION_CAN_PATTERN.matcher(preAuthorize.value()); + if (!matcher.find()) { + return null; + } + return matcher.group(1) + SymbolConstant.COLON + matcher.group(2); + } + /** * Scan all registered WebFlux request mappings and return the endpoint inventory * visible to the permission resource registrar. diff --git a/dc3-common/dc3-common-resource-registrar/src/test/java/io/github/pnoker/common/resource/registrar/scan/ApiEndpointScannerTest.java b/dc3-common/dc3-common-resource-registrar/src/test/java/io/github/pnoker/common/resource/registrar/scan/ApiEndpointScannerTest.java index 3cb8a7ce2..3e9b1cc1d 100644 --- a/dc3-common/dc3-common-resource-registrar/src/test/java/io/github/pnoker/common/resource/registrar/scan/ApiEndpointScannerTest.java +++ b/dc3-common/dc3-common-resource-registrar/src/test/java/io/github/pnoker/common/resource/registrar/scan/ApiEndpointScannerTest.java @@ -21,6 +21,7 @@ import io.github.pnoker.common.facade.entity.bo.FacadeScannedApiBO; import io.github.pnoker.common.resource.registrar.config.ResourceRegistrarProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -122,6 +123,22 @@ class ApiEndpointScannerTest { assertThat(scanner.scan()).isEmpty(); } + @Test + void preAuthorizePermissionIsUsedAsApiNameWhenPresent() { + register(ProjectConventionController.class); + ApiEndpointScanner scanner = new ApiEndpointScanner(handlerMapping, new ResourceRegistrarProperties()); + + List apis = scanner.scan(); + + assertThat(apis).extracting(FacadeScannedApiBO::getMethod, FacadeScannedApiBO::getPath, + FacadeScannedApiBO::getApiName) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple("GET", "/api/convention/get_by_id", "convention:get"), + org.assertj.core.groups.Tuple.tuple("POST", "/api/convention/list", "convention:list"), + org.assertj.core.groups.Tuple.tuple("POST", "/api/convention/update", "convention:update"), + org.assertj.core.groups.Tuple.tuple("POST", "/api/convention/delete", "convention:delete")); + } + private void register(Class controllerClass) { try { Object bean = controllerClass.getDeclaredConstructor().newInstance(); @@ -203,4 +220,32 @@ class ApiEndpointScannerTest { return "ok"; } } + + @RestController + @RequestMapping("/api/convention") + static class ProjectConventionController { + @PreAuthorize("@perm.can('convention', 'get')") + @GetMapping("/get_by_id") + public String getById() { + return "ok"; + } + + @PreAuthorize("@perm.can('convention', 'list')") + @PostMapping("/list") + public String list() { + return "ok"; + } + + @PreAuthorize("@perm.can('convention', 'update')") + @PostMapping("/update") + public String update() { + return "ok"; + } + + @PreAuthorize("@perm.can('convention', 'delete')") + @PostMapping("/delete") + public String delete() { + return "ok"; + } + } }