fix(auth): scope menus and permission registration

This commit is contained in:
Vickey
2026-06-10 19:04:05 +08:00
parent 654badf78e
commit bf4a5d52f8
3 changed files with 110 additions and 3 deletions
@@ -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<R<List<MenuTreeVO>>> 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<MenuTreeBO> entityBOList = menuService.listTree(entityQuery);
entityBOList = filterByUserMenuResources(entityBOList, header.getUserId(), header.getTenantId());
List<MenuTreeVO> entityVOList = new ArrayList<>(entityBOList.size());
for (MenuTreeBO node : entityBOList) {
entityVOList.add(toTreeVO(node));
}
return R.ok(entityVOList);
});
}));
}
private List<MenuTreeBO> filterByUserMenuResources(List<MenuTreeBO> nodes, Long userId, Long tenantId) {
List<ResourceBO> resources = roleResourceBindService.listResourceByUserId(userId, tenantId);
Set<String> 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<String> visibleMenuCodes) {
List<MenuTreeBO> children = Objects.requireNonNullElse(node.getChildren(), List.<MenuTreeBO>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) {
@@ -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<String> 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.
@@ -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<FacadeScannedApiBO> 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";
}
}
}