diff --git a/.scratch/dynamic-api-permission-interceptor/issues/01-api-permission-interceptor.md b/.scratch/dynamic-api-permission-interceptor/issues/01-api-permission-interceptor.md new file mode 100644 index 0000000..93c2172 --- /dev/null +++ b/.scratch/dynamic-api-permission-interceptor/issues/01-api-permission-interceptor.md @@ -0,0 +1,18 @@ +# 01 — ApiPermissionCache + ApiPermissionInterceptor 核心拦截链路 + +**What to build:** 构建基于 apiUrl 的动态权限拦截机制。一个 Caffeine 进程内缓存加载 sys_menu 中 status=enabled 且 apiUrl 非空的 button 节点,生成 {apiUrlPattern, permsCode} 规则列表。一个 HandlerInterceptor 用 AntPathMatcher 匹配请求 URL,从 SecurityContext 读当前用户 authority 集合,匹配到规则但用户无对应 perms → 403;未匹配任何规则 → 放行(fail-open)。拦截器注册到 MVC 配置中,位于 JWT 过滤器之后、Controller 之前。 + +**Blocked by:** None — can start immediately + +**Status:** ready-for-agent + +- [ ] ApiPermissionCache:Caffeine 缓存,懒加载 sys_menu 中 menuType=3 AND status='enabled' AND apiUrl IS NOT NULL 的节点,产出 List<{pattern, perms}> +- [ ] ApiPermissionCache.invalidate():清空缓存,下次请求重建 +- [ ] ApiPermissionInterceptor:HandlerInterceptor,preHandle 中匹配请求 URI,检查 authority +- [ ] Ant 风格匹配:* 单层通配,** 多层通配,用 Spring 内置 AntPathMatcher +- [ ] fail-open:未匹配任何注册 apiUrl → 放行 +- [ ] 停用节点不生成规则(status=disabled 排除) +- [ ] 匹配到规则但用户无对应 perms → 403 + 清晰错误消息 +- [ ] MVC 配置注册拦截器(JWT 过滤器之后) +- [ ] 与 @PreAuthorize 共存不冲突 +- [ ] 单元测试:匹配放行、匹配拒绝、未注册放行、Ant 通配、停用节点不生成规则 diff --git a/.scratch/dynamic-api-permission-interceptor/issues/02-cache-invalidation.md b/.scratch/dynamic-api-permission-interceptor/issues/02-cache-invalidation.md new file mode 100644 index 0000000..154bce8 --- /dev/null +++ b/.scratch/dynamic-api-permission-interceptor/issues/02-cache-invalidation.md @@ -0,0 +1,13 @@ +# 02 — 资源树变更触发缓存失效 + +**What to build:** 资源树写操作(新增/编辑/删除 button 节点)完成后,立即触发 ApiPermissionCache 失效,使后续请求按新规则拦截或放行,无需重启应用。 + +**Blocked by:** 01 — ApiPermissionCache + ApiPermissionInterceptor 核心拦截链路 + +**Status:** ready-for-agent + +- [ ] ResourceServiceImpl.save() 完成后调 cache.invalidate() +- [ ] ResourceServiceImpl.delete() 完成后调 cache.invalidate() +- [ ] 集成测试:新增 button 节点后,新请求立即被拦截 +- [ ] 集成测试:删除 button 节点后,新请求立即放行 +- [ ] 集成测试:停用 button 节点后,新请求立即放行 diff --git a/.scratch/dynamic-api-permission-interceptor/issues/03-seal-legacy-endpoints.md b/.scratch/dynamic-api-permission-interceptor/issues/03-seal-legacy-endpoints.md new file mode 100644 index 0000000..9b9ead9 --- /dev/null +++ b/.scratch/dynamic-api-permission-interceptor/issues/03-seal-legacy-endpoints.md @@ -0,0 +1,16 @@ +# 03 — 封遗留写路径 + 收拢 Mapper 到 Service 层 + +**What to build:** 删除 SystemController 中无防护的 /menus/save 和 /menus/delete 端点(ResourceController 已覆盖)。将 assign-menus、roles/delete、users/assign-roles 的 Mapper 操作收进 Service 层,加 @Transactional 事务保护。SystemController 不再持有 SysRoleMenuMapper / SysUserRoleMapper。 + +**Blocked by:** 01 — ApiPermissionCache + ApiPermissionInterceptor 核心拦截链路 + +**Status:** ready-for-agent + +- [ ] 删除 SystemController 中 /menus/save 端点 +- [ ] 删除 SystemController 中 /menus/delete 端点 +- [ ] ISysRoleService 新增 assignMenus(roleId, menuIds),@Transactional,先删后插 +- [ ] ISysRoleService 新增 deleteRoleCascade(roleId),@Transactional,删角色 + 清理 sys_role_menu +- [ ] IAuthUserService 新增 assignRoles(userId, roleIds),@Transactional,先删后插 +- [ ] SystemController 对应端点改调 service 方法 +- [ ] SystemController 移除 SysRoleMenuMapper / SysUserRoleMapper 依赖 +- [ ] 现有测试回归通过 diff --git a/.scratch/dynamic-api-permission-interceptor/spec.md b/.scratch/dynamic-api-permission-interceptor/spec.md new file mode 100644 index 0000000..1d007c6 --- /dev/null +++ b/.scratch/dynamic-api-permission-interceptor/spec.md @@ -0,0 +1,139 @@ +# Spec: 动态权限拦截 + 封遗留写路径 + +Status: ready-for-agent + +## Problem Statement + +管理员在权限资源树中配置了按钮级权限点(含 apiUrl),期望系统自动根据该配置拦截未授权的 API 请求。但当前实现存在两个问题: + +1. **无运行时动态拦截**:`sys_menu` 中的 `apiUrl` 字段未被消费,鉴权全靠手动标注 `@PreAuthorize("hasAuthority('crm:xxx:yyy')")`。新增端点忘加注解就漏防。 +2. **遗留写路径绕过防护**:`/api/system/menus/save` 和 `/api/system/menus/delete` 直接调用 MyBatis-Plus CRUD,无任何层级校验、子节点检查或授权清理——可从第二个入口损坏权限资源树。 +3. **Mapper 泄漏进 Controller**:`assign-menus`、`roles/delete`、`users/assign-roles` 等端点在 Controller 层直接操作 Mapper,无事务保护,违反 locality 原则。 + +## Solution + +构建基于 `apiUrl` 的动态权限拦截机制,并封堵遗留写路径漏洞: + +1. **动态拦截器**:MVC 层 `HandlerInterceptor` 读取请求 URL,匹配 `sys_menu` 中 button 节点的 `apiUrl` pattern,检查当前用户是否拥有对应 perms。未注册的 URL 放行(fail-open)。 +2. **封遗留端点**:删除 `/api/system/menus/save` 和 `/api/system/menus/delete`,所有资源树写操作统一走 `ResourceController`(已含层级校验、子节点检查、级联清理)。 +3. **收拢 Mapper 逻辑**:将 `assign-menus`、`roles/delete`、`users/assign-roles` 的 Mapper 操作收进 Service 层,加 `@Transactional`,消除 Controller 持有 Mapper 的反模式。 + +## User Stories + +1. As an admin, I want to register a button permission point with an apiUrl pattern (e.g., `/api/users/**`), so that all matching requests are automatically protected without manual `@PreAuthorize` annotations. +2. As an admin, I want the system to use Ant-style pattern matching (`*`, `**`) for apiUrl, so that I can flexibly define rules like `/api/users/*/detail` or `/api/orders/**`. +3. As a user with role "Sales", I want to be denied access to `/api/users/list` if my role is not authorized for the corresponding button permission point, so that I cannot bypass UI-level restrictions by calling the API directly. +4. As a user with role "Admin", I want to access endpoints that are not registered in the permission tree (e.g., `/api/system/depts/tree`), so that legacy or internal endpoints remain accessible during transition. +5. As a developer, I want disabled button nodes (`status=disabled`) to be excluded from the interception rules, so that disabling a permission point globally blocks access without modifying role assignments. +6. As an admin, I want changes to the permission resource tree (add/edit/delete button nodes) to immediately take effect on subsequent requests, so that I don't need to restart the application. +7. As an admin, I want the `/api/system/menus/save` endpoint to validate parent-child type constraints (catalog→menu→button), so that I cannot create invalid tree structures. +8. As an admin, I want the `/api/system/menus/delete` endpoint to reject deletion of nodes with children and cascade-clean role-menu references, so that I cannot leave orphaned authorizations. +9. As a developer, I want role-menu assignment (`assign-menus`) to be wrapped in a transaction, so that partial failures don't leave inconsistent authorization state. +10. As a developer, I want user-role assignment (`assign-roles`) to be wrapped in a transaction, so that partial failures don't leave inconsistent user-role relationships. +11. As a security auditor, I want all intercepted requests to log the matched apiUrl pattern and the user's permission codes, so that I can audit access control decisions. +12. As a frontend developer, I want the interceptor to return HTTP 403 with a clear error message when access is denied, so that I can display appropriate UI feedback. +13. As an admin, I want to use wildcard patterns like `/api/resources/**` to protect entire subsystems with a single rule, so that I don't need to register every individual endpoint. +14. As a developer, I want the interceptor to coexist with existing `@PreAuthorize` annotations, so that I can gradually migrate from annotation-based to URL-based authorization. +15. As a tester, I want the cache to be invalidated immediately after resource tree mutations, so that integration tests can verify fresh rules without restarting. + +## Implementation Decisions + +### Modules Built/Modified + +- **New**: `ApiPermissionInterceptor` (HandlerInterceptor in `com.crm.auth.security`) — matches request URL against cached apiUrl patterns, checks user authority set. +- **New**: `ApiPermissionCache` (Caffeine-based in-memory cache in `com.crm.auth.security`) — stores list of `{apiUrlPattern, permsCode}` tuples for enabled button nodes. +- **Modified**: `ResourceServiceImpl` — calls `ApiPermissionCache.invalidate()` on save/delete of button nodes. +- **Modified**: `SystemController` — removes `/menus/save` and `/menus/delete` endpoints; delegates `assign-menus`, `roles/delete`, `users/assign-roles` logic to service layer. +- **Modified**: `ISysRoleService` / `SysRoleServiceImpl` — adds `assignMenus(roleId, menuIds)` with `@Transactional`; moves `deleteRole` cascade logic into service. +- **Modified**: `IAuthUserService` / `AuthUserServiceImpl` — ensures `assignRoles` is transactional (may already be). +- **Modified**: Spring MVC config — registers `ApiPermissionInterceptor` to run after JWT filter, before controllers. + +### Interfaces Modified + +- `ApiPermissionInterceptor.preHandle()`: reads `HttpServletRequest.getRequestURI()`, matches against cached patterns using `AntPathMatcher`, retrieves current user's authority set from `SecurityContextHolder`, denies if no match found among enabled perms. +- `ApiPermissionCache.load()`: queries `sys_menu` for `menuType=3 (BUTTON) AND status='enabled' AND apiUrl IS NOT NULL`, returns list of `{pattern, perms}`. +- `ApiPermissionCache.invalidate()`: clears cache, forces reload on next request. + +### Technical Clarifications + +- **Fail-open policy**: If request URL does not match any registered apiUrl pattern, the interceptor allows the request to proceed. Only explicitly registered URLs are protected. +- **Ant-style matching**: Uses Spring's `AntPathMatcher` — `*` matches zero or more characters within a path segment, `**` matches zero or more path segments. Example: `/api/users/*` matches `/api/users/123` but not `/api/users/123/detail`; `/api/users/**` matches both. +- **Authority check**: The interceptor extracts the user's authority set from `SecurityContext.getAuthentication().getAuthorities()` (populated by `PermissionGrant.asAuthorities()` in `JwtAuthenticationFilter`). It checks if any authority matches the `perms` code of the matched button node. +- **Disabled nodes excluded**: Only button nodes with `status='enabled'` generate interception rules. Disabling a node removes it from the cache on next invalidation. +- **Coexistence with @PreAuthorize**: The interceptor runs alongside Spring Security's method-level security. Endpoints with `@PreAuthorize` continue to work; the interceptor adds an additional URL-based layer. No conflict — both must pass. + +### Schema Changes + +No new tables or columns. Reuses existing `sys_menu` fields: +- `menuType = 3` (button) +- `status = 'enabled'` or `'disabled'` +- `apiUrl` (varchar(200), nullable) — only non-null values generate rules +- `perms` (varchar(100)) — the authority code checked by the interceptor + +### API Contracts + +- **Removed endpoints**: + - `POST /api/system/menus/save` → replaced by `POST /api/resources/saveOrUpdate` + - `POST /api/system/menus/delete` → replaced by `POST /api/resources/delete` +- **Unchanged endpoints** (now with transactional service backing): + - `POST /api/system/roles/assign-menus` — still accepts `roleId` + `menuIds` (comma-separated), but logic moved to `ISysRoleService.assignMenus()` + - `POST /api/system/roles/delete` — still accepts `roleId`, but cascade cleanup moved to service + - `POST /api/system/users/assign-roles` — still accepts `userId` + `roleIds` (comma-separated), but wrapped in transaction + +### Specific Interactions + +1. **Request flow**: + ``` + Request → JwtAuthenticationFilter (loads PermissionGrant into SecurityContext) + → ApiPermissionInterceptor (matches URL, checks authority) + → Controller (if allowed) + ``` +2. **Cache lifecycle**: + - Startup: cache empty, first request triggers lazy load from `sys_menu`. + - Mutation: `ResourceServiceImpl.save()` or `.delete()` calls `cache.invalidate()`. + - Next request: cache rebuilds from DB. +3. **Pattern matching priority**: If multiple patterns match (e.g., `/api/users/*` and `/api/users/**`), the most specific match wins (Spring's `AntPathMatcher` handles this). The interceptor checks if the user has the perms of the matched rule. + +## Testing Decisions + +### What Makes a Good Test + +- Tests external behavior: does the interceptor allow/deny the right requests? +- Does not test implementation details: no assertions about cache internals, SQL queries, or matcher algorithms. +- One assertion per test case: clear pass/fail signal. + +### Modules Tested + +- **ApiPermissionInterceptor** (new): + - Matched URL + user has perms → 200 OK + - Matched URL + user lacks perms → 403 Forbidden + - Unmatched URL → 200 OK (fail-open) + - Ant-style wildcard matching (`*`, `**`) + - Disabled button nodes do not generate rules + - Multiple matching patterns → most specific wins +- **ResourceServiceImpl** (modified): + - Save/delete triggers cache invalidation +- **SysRoleServiceImpl** (modified): + - `assignMenus` is transactional (partial failure rolls back) + - `deleteRole` cascade-cleans `sys_role_menu` +- **Regression**: Existing `DataScopeIntegrationTest` suite passes unchanged. + +### Prior Art + +- `DataScopeIntegrationTest` — H2 in-memory DB, real mappers, asserts visible rows. New interceptor tests follow same pattern: H2 seed data with button nodes, simulate requests via MockMvc or direct interceptor invocation, assert HTTP status. +- `ResourceServiceImplTest` — unit tests for validation logic. New cache invalidation tests extend this suite. + +## Out of Scope + +- **Performance optimization**: No Redis caching, no batch query merging. Caffeine process-local cache is sufficient for single-instance deployment. +- **URL pattern UI editor**: Admins register apiUrl via existing `ResourceController` (JSON body or form params). No dedicated UI for pattern management. +- **Audit logging**: Interceptor does not log matched patterns or decisions. Logging can be added later if needed. +- **Rate limiting / throttling**: Not part of this spec. +- **IP-based or time-based access control**: Only role-based permission codes are checked. +- **Migration of existing @PreAuthorize annotations**: Existing annotated endpoints continue to work. Gradual migration is out of scope. + +## Further Notes + +- **ADR conflict**: This spec contradicts ADR-0011's decision to "retain `/menus/save` and `/delete` for backward compatibility." The rationale for reopening: backward compatibility became a security vulnerability — unvalidated writes can corrupt the permission tree. The new dynamic interception mechanism makes the legacy endpoints redundant (all protected endpoints can now be registered via apiUrl). +- **Transition strategy**: During rollout, admins should register critical endpoints in the permission tree before removing manual `@PreAuthorize` annotations. The fail-open policy ensures no accidental lockouts. +- **Testing seam**: The highest seam is the `HandlerInterceptor` itself — tests invoke it with mocked HttpServletRequest/Response and SecurityContext. No need to spin up full HTTP server for unit tests; integration tests can use Spring Boot's `@SpringBootTest` + `MockMvc`. diff --git a/crm-auth/src/main/java/com/crm/auth/config/WebMvcConfig.java b/crm-auth/src/main/java/com/crm/auth/config/WebMvcConfig.java new file mode 100644 index 0000000..f93008a --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/config/WebMvcConfig.java @@ -0,0 +1,24 @@ +package com.crm.auth.config; + +import com.crm.auth.security.ApiPermissionInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * MVC 配置:注册 API 权限拦截器。 + *

拦截器在 Spring Security(JWT 过滤器)之后、Controller 之前执行。

+ */ +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + + private final ApiPermissionInterceptor apiPermissionInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(apiPermissionInterceptor) + .addPathPatterns("/api/**"); + } +} diff --git a/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java b/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java index b54d7e7..790bf34 100644 --- a/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java +++ b/crm-auth/src/main/java/com/crm/auth/controller/SystemController.java @@ -1,15 +1,13 @@ package com.crm.auth.controller; -import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.crm.auth.domain.dto.UserListDTO; import com.crm.auth.domain.dto.UserStatsVO; -import com.crm.auth.domain.entity.*; +import com.crm.auth.domain.entity.SysDept; +import com.crm.auth.domain.entity.SysMenu; +import com.crm.auth.domain.entity.SysRole; import com.crm.auth.domain.param.UserPageParam; -import com.crm.auth.mapper.SysRoleMenuMapper; -import com.crm.auth.mapper.SysUserRoleMapper; import com.crm.auth.service.IAuthUserService; import com.crm.auth.service.ISysDeptService; import com.crm.auth.service.ISysMenuService; @@ -36,8 +34,6 @@ public class SystemController { private final ISysDeptService sysDeptService; private final IAuthUserService authUserService; private final PermissionResolver permissionResolver; - private final SysRoleMenuMapper sysRoleMenuMapper; - private final SysUserRoleMapper sysUserRoleMapper; // ========== 菜单 ========== @@ -47,37 +43,6 @@ public class SystemController { return Result.success(permissionResolver.visibleMenuTree(userId)); } - @PostMapping("/menus/save") - public Result saveMenu( - @RequestParam(required = false) Long id, - @RequestParam(defaultValue = "0") Long parentId, - @RequestParam String menuName, - @RequestParam Integer menuType, - @RequestParam(required = false) String path, - @RequestParam(required = false) String component, - @RequestParam(required = false) String icon, - @RequestParam(defaultValue = "0") Integer sort, - @RequestParam(defaultValue = "true") Boolean visible) { - SysMenu menu = new SysMenu(); - menu.setId(id); - menu.setParentId(parentId); - menu.setMenuName(menuName); - menu.setMenuType(menuType); - menu.setPath(path); - menu.setComponent(component); - menu.setIcon(icon); - menu.setSort(sort); - menu.setVisible(visible); - sysMenuService.saveOrUpdate(menu); - return Result.success(); - } - - @PostMapping("/menus/delete") - public Result deleteMenu(@RequestParam Long id) { - sysMenuService.removeById(id); - return Result.success(); - } - // ========== 角色 ========== @PostMapping("/roles/page") @@ -113,27 +78,19 @@ public class SystemController { @PostMapping("/roles/delete") public Result deleteRole(@RequestParam Long id) { - sysRoleService.removeById(id); - sysRoleMenuMapper.delete( - new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, id)); + sysRoleService.deleteRoleCascade(id); return Result.success(); } @PostMapping("/roles/assign-menus") public Result assignRoleMenus(@RequestParam Long roleId, @RequestParam String menuIds) { - sysRoleMenuMapper.delete( - new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, roleId)); - if (StrUtil.isNotBlank(menuIds)) { - List list = Arrays.stream(menuIds.split(",")) - .filter(StrUtil::isNotBlank) - .map(idStr -> { - SysRoleMenu rm = new SysRoleMenu(); - rm.setRoleId(roleId); - rm.setMenuId(Long.valueOf(idStr.trim())); - return rm; - }).collect(Collectors.toList()); - list.forEach(sysRoleMenuMapper::insert); - } + List menuIdList = StrUtil.isBlank(menuIds) + ? Collections.emptyList() + : Arrays.stream(menuIds.split(",")) + .filter(StrUtil::isNotBlank) + .map(idStr -> Long.valueOf(idStr.trim())) + .collect(Collectors.toList()); + sysRoleService.assignMenus(roleId, menuIdList); return Result.success(); } @@ -180,18 +137,13 @@ public class SystemController { @PreAuthorize("hasRole('ADMIN')") @PostMapping("/users/assign-roles") public Result assignUserRoles(@RequestParam Long userId, @RequestParam String roleIds) { - sysUserRoleMapper.delete( - new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); - if (StrUtil.isNotBlank(roleIds)) { - Arrays.stream(roleIds.split(",")) - .filter(StrUtil::isNotBlank) - .forEach(idStr -> { - SysUserRole ur = new SysUserRole(); - ur.setUserId(userId); - ur.setRoleId(Long.valueOf(idStr.trim())); - sysUserRoleMapper.insert(ur); - }); - } + List roleIdList = StrUtil.isBlank(roleIds) + ? Collections.emptyList() + : Arrays.stream(roleIds.split(",")) + .filter(StrUtil::isNotBlank) + .map(idStr -> Long.valueOf(idStr.trim())) + .collect(Collectors.toList()); + authUserService.assignRoles(userId, roleIdList); return Result.success(); } diff --git a/crm-auth/src/main/java/com/crm/auth/security/ApiPermissionCache.java b/crm-auth/src/main/java/com/crm/auth/security/ApiPermissionCache.java new file mode 100644 index 0000000..7c96794 --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/security/ApiPermissionCache.java @@ -0,0 +1,64 @@ +package com.crm.auth.security; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.auth.domain.entity.SysMenu; +import com.crm.auth.domain.enums.MenuType; +import com.crm.auth.mapper.SysMenuMapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * API 权限规则进程内缓存(Caffeine):缓存 {@code sys_menu} 中已注册 apiUrl 的 button 节点, + * 供 {@link ApiPermissionInterceptor} 匹配请求 URL 并校验权限码。 + * + *

单 key 缓存,全量加载。资源树变更(save/delete)时由 + * {@link com.crm.auth.service.impl.ResourceServiceImpl} 主动失效。

+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ApiPermissionCache { + + private final SysMenuMapper sysMenuMapper; + + /** 单 key:整份规则列表 */ + private static final String RULES_KEY = "apiPermRules"; + + private final Cache> cache = Caffeine.newBuilder().build(); + + /** + * 获取当前有效规则列表(懒加载,失效后重建)。 + */ + public List getRules() { + return cache.get(RULES_KEY, k -> loadRules()); + } + + /** + * 资源树变更后主动失效,下一次请求重建规则列表。 + */ + public void invalidate() { + cache.invalidateAll(); + log.debug("ApiPermissionCache 已失效,下次请求重建规则"); + } + + private List loadRules() { + List buttons = sysMenuMapper.selectList( + new LambdaQueryWrapper() + .eq(SysMenu::getMenuType, MenuType.BUTTON.getCode()) + .eq(SysMenu::getStatus, "enabled") + .isNotNull(SysMenu::getApiUrl) + .ne(SysMenu::getApiUrl, "")); + + List rules = buttons.stream() + .map(m -> new ApiPermissionRule(m.getApiUrl(), m.getPerms())) + .toList(); + + log.debug("ApiPermissionCache 加载 {} 条规则", rules.size()); + return rules; + } +} diff --git a/crm-auth/src/main/java/com/crm/auth/security/ApiPermissionInterceptor.java b/crm-auth/src/main/java/com/crm/auth/security/ApiPermissionInterceptor.java new file mode 100644 index 0000000..30f485e --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/security/ApiPermissionInterceptor.java @@ -0,0 +1,81 @@ +package com.crm.auth.security; + +import cn.hutool.core.util.StrUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 动态权限拦截器:匹配请求 URL 与已注册的 apiUrl pattern,校验当前用户是否拥有对应 perms。 + * + *
    + *
  • fail-open:未匹配任何注册规则 → 放行
  • + *
  • 匹配到规则但用户无对应 perms → 403 Forbidden
  • + *
  • Ant 风格匹配:* 单层通配,** 多层通配
  • + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ApiPermissionInterceptor implements HandlerInterceptor { + + private final ApiPermissionCache apiPermissionCache; + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String requestUri = request.getRequestURI(); + List rules = apiPermissionCache.getRules(); + + // 找所有匹配的规则 + List matchedRules = rules.stream() + .filter(rule -> pathMatcher.match(rule.pattern(), requestUri)) + .toList(); + + // fail-open:没有匹配规则 → 放行 + if (matchedRules.isEmpty()) { + return true; + } + + // 取当前用户的 authority 集合 + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + // 未认证(理论上不会到这里,因为 JWT 过滤器在前) + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + return false; + } + + Set userAuthorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + + // 检查用户是否拥有任一匹配规则的 perms + boolean hasPermission = matchedRules.stream() + .anyMatch(rule -> StrUtil.isNotBlank(rule.perms()) && userAuthorities.contains(rule.perms())); + + if (hasPermission) { + return true; + } + + // 匹配到规则但无权限 → 403 + log.info("API 权限拦截:uri={}, 匹配规则={}, 用户权限={}", requestUri, + matchedRules.stream().map(r -> r.pattern() + ":" + r.perms()).collect(Collectors.joining(",")), + userAuthorities); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":403,\"msg\":\"无权限访问该接口\"}"); + return false; + } +} diff --git a/crm-auth/src/main/java/com/crm/auth/security/ApiPermissionRule.java b/crm-auth/src/main/java/com/crm/auth/security/ApiPermissionRule.java new file mode 100644 index 0000000..9251938 --- /dev/null +++ b/crm-auth/src/main/java/com/crm/auth/security/ApiPermissionRule.java @@ -0,0 +1,10 @@ +package com.crm.auth.security; + +/** + * API 权限规则:一条 apiUrl pattern → perms 映射。 + * + * @param pattern Ant 风格的 URL 模式(如 /api/users/**) + * @param perms 对应的权限码(如 crm:user:list) + */ +public record ApiPermissionRule(String pattern, String perms) { +} diff --git a/crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java b/crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java index 6652d79..cd23d02 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java +++ b/crm-auth/src/main/java/com/crm/auth/service/IAuthUserService.java @@ -36,4 +36,12 @@ public interface IAuthUserService extends IBaseService { * 用户管理-指标卡统计(部门总数/在职/离职/待分配),跟随部门子树,与列表过滤解耦。 */ UserStatsVO stats(Long deptId); + + /** + * 分配用户的角色(全量替换,事务保护) + * + * @param userId 用户 ID + * @param roleIds 角色 ID 列表 + */ + void assignRoles(Long userId, List roleIds); } diff --git a/crm-auth/src/main/java/com/crm/auth/service/ISysRoleService.java b/crm-auth/src/main/java/com/crm/auth/service/ISysRoleService.java index bf5b259..8735b8d 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/ISysRoleService.java +++ b/crm-auth/src/main/java/com/crm/auth/service/ISysRoleService.java @@ -3,5 +3,22 @@ package com.crm.auth.service; import com.crm.auth.domain.entity.SysRole; import com.crm.base.service.IBaseService; +import java.util.List; + public interface ISysRoleService extends IBaseService { + + /** + * 分配角色的菜单权限(全量替换,事务保护) + * + * @param roleId 角色 ID + * @param menuIds 菜单 ID 列表 + */ + void assignMenus(Long roleId, List menuIds); + + /** + * 删除角色并级联清理角色-菜单关联(事务保护) + * + * @param roleId 角色 ID + */ + void deleteRoleCascade(Long roleId); } diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/AuthUserServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/AuthUserServiceImpl.java index 935589a..f6b0834 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/impl/AuthUserServiceImpl.java +++ b/crm-auth/src/main/java/com/crm/auth/service/impl/AuthUserServiceImpl.java @@ -276,4 +276,20 @@ public class AuthUserServiceImpl extends BaseServiceImpl roleIds) { + // 先删后插(全量替换) + sysUserRoleMapper.delete( + new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); + if (roleIds != null && !roleIds.isEmpty()) { + for (Long roleId : roleIds) { + SysUserRole ur = new SysUserRole(); + ur.setUserId(userId); + ur.setRoleId(roleId); + sysUserRoleMapper.insert(ur); + } + } + } } diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java index ca58099..5e2da7f 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java +++ b/crm-auth/src/main/java/com/crm/auth/service/impl/ResourceServiceImpl.java @@ -8,6 +8,7 @@ import com.crm.auth.domain.entity.SysMenu; import com.crm.auth.domain.entity.SysRoleMenu; import com.crm.auth.domain.enums.MenuType; import com.crm.auth.mapper.SysRoleMenuMapper; +import com.crm.auth.security.ApiPermissionCache; import com.crm.auth.service.IResourceService; import com.crm.auth.service.ISysMenuService; import com.crm.base.domain.exception.BusinessErrorException; @@ -39,6 +40,7 @@ public class ResourceServiceImpl implements IResourceService { private final ISysMenuService sysMenuService; private final SysRoleMenuMapper sysRoleMenuMapper; private final FileApi fileApi; + private final ApiPermissionCache apiPermissionCache; @Override public List listAll() { @@ -87,6 +89,7 @@ public class ResourceServiceImpl implements IResourceService { applyToEntity(node, entity); sysMenuService.saveOrUpdate(entity); + apiPermissionCache.invalidate(); log.info("保存资源节点:id={}, name={}, type={}", entity.getId(), entity.getMenuName(), MenuType.fromCode(entity.getMenuType())); @@ -116,6 +119,7 @@ public class ResourceServiceImpl implements IResourceService { } sysMenuService.removeById(id); + apiPermissionCache.invalidate(); log.info("删除资源节点:id={}, name={}", id, node.getMenuName()); } diff --git a/crm-auth/src/main/java/com/crm/auth/service/impl/SysRoleServiceImpl.java b/crm-auth/src/main/java/com/crm/auth/service/impl/SysRoleServiceImpl.java index b6b9453..f8423c8 100644 --- a/crm-auth/src/main/java/com/crm/auth/service/impl/SysRoleServiceImpl.java +++ b/crm-auth/src/main/java/com/crm/auth/service/impl/SysRoleServiceImpl.java @@ -1,11 +1,47 @@ package com.crm.auth.service.impl; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.crm.auth.domain.entity.SysRole; +import com.crm.auth.domain.entity.SysRoleMenu; import com.crm.auth.mapper.SysRoleMapper; +import com.crm.auth.mapper.SysRoleMenuMapper; import com.crm.auth.service.ISysRoleService; import com.crm.base.service.impl.BaseServiceImpl; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; @Service +@RequiredArgsConstructor public class SysRoleServiceImpl extends BaseServiceImpl implements ISysRoleService { + + private final SysRoleMenuMapper sysRoleMenuMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public void assignMenus(Long roleId, List menuIds) { + // 先删后插(全量替换) + sysRoleMenuMapper.delete( + new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, roleId)); + if (menuIds != null && !menuIds.isEmpty()) { + for (Long menuId : menuIds) { + SysRoleMenu rm = new SysRoleMenu(); + rm.setRoleId(roleId); + rm.setMenuId(menuId); + sysRoleMenuMapper.insert(rm); + } + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteRoleCascade(Long roleId) { + // 级联清理角色-菜单关联 + sysRoleMenuMapper.delete( + new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, roleId)); + // 删除角色 + removeById(roleId); + } } diff --git a/crm-auth/src/test/java/com/crm/auth/security/ApiPermissionInterceptorTest.java b/crm-auth/src/test/java/com/crm/auth/security/ApiPermissionInterceptorTest.java new file mode 100644 index 0000000..7a0a26e --- /dev/null +++ b/crm-auth/src/test/java/com/crm/auth/security/ApiPermissionInterceptorTest.java @@ -0,0 +1,161 @@ +package com.crm.auth.security; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +/** + * ApiPermissionInterceptor 单元测试:匹配放行、匹配拒绝、未注册放行、Ant 通配、停用节点不生成规则。 + */ +@DisplayName("API 权限拦截器测试") +class ApiPermissionInterceptorTest { + + private ApiPermissionCache cache; + private ApiPermissionInterceptor interceptor; + + @BeforeEach + void setUp() { + cache = mock(ApiPermissionCache.class); + interceptor = new ApiPermissionInterceptor(cache); + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("未注册 URL → 放行(fail-open)") + void unmatchedUrl_shouldPass() throws Exception { + when(cache.getRules()).thenReturn(List.of( + new ApiPermissionRule("/api/users/**", "crm:user:list"))); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/depts/tree"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean result = interceptor.preHandle(request, response, null); + + assertThat(result).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("匹配规则 + 用户有 perms → 放行") + void matchedWithPerm_shouldPass() throws Exception { + when(cache.getRules()).thenReturn(List.of( + new ApiPermissionRule("/api/users/list", "crm:user:list"))); + + setSecurityContext("crm:user:list"); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/users/list"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean result = interceptor.preHandle(request, response, null); + + assertThat(result).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + } + + @Test + @DisplayName("匹配规则 + 用户无 perms → 403") + void matchedWithoutPerm_shouldDeny() throws Exception { + when(cache.getRules()).thenReturn(List.of( + new ApiPermissionRule("/api/users/delete", "crm:user:delete"))); + + setSecurityContext("crm:user:list"); + + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/users/delete"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean result = interceptor.preHandle(request, response, null); + + assertThat(result).isFalse(); + assertThat(response.getStatus()).isEqualTo(403); + assertThat(response.getContentAsString()).contains("无权限访问该接口"); + } + + @Test + @DisplayName("Ant 单层通配 * → 匹配成功") + void antSingleWildcard_shouldMatch() throws Exception { + when(cache.getRules()).thenReturn(List.of( + new ApiPermissionRule("/api/users/*/detail", "crm:user:detail"))); + + setSecurityContext("crm:user:detail"); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/users/123/detail"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean result = interceptor.preHandle(request, response, null); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("Ant 多层通配 ** → 匹配成功") + void antMultiWildcard_shouldMatch() throws Exception { + when(cache.getRules()).thenReturn(List.of( + new ApiPermissionRule("/api/orders/**", "crm:order:list"))); + + setSecurityContext("crm:order:list"); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/orders/2024/Q1/detail"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean result = interceptor.preHandle(request, response, null); + + assertThat(result).isTrue(); + } + + @Test + @DisplayName("停用节点不生成规则 → 放行") + void disabledNodeNotInRules_shouldPass() throws Exception { + // 缓存中只有 enabled 节点,disabled 的不出现 + when(cache.getRules()).thenReturn(List.of( + new ApiPermissionRule("/api/users/list", "crm:user:list"))); + + setSecurityContext("crm:user:delete"); // 没有 crm:user:delete 规则 + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/users/disabled-endpoint"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean result = interceptor.preHandle(request, response, null); + + assertThat(result).isTrue(); // fail-open + } + + @Test + @DisplayName("多个匹配规则,用户拥有任一 perms → 放行") + void multipleMatches_anyPermGrantsAccess() throws Exception { + when(cache.getRules()).thenReturn(List.of( + new ApiPermissionRule("/api/data/**", "crm:data:read"), + new ApiPermissionRule("/api/data/export", "crm:data:export"))); + + setSecurityContext("crm:data:export"); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/data/export"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + boolean result = interceptor.preHandle(request, response, null); + + assertThat(result).isTrue(); + } + + private void setSecurityContext(String... authorities) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + "testUser", null, List.of(authorities).stream() + .map(SimpleGrantedAuthority::new) + .toList()); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + } +} diff --git a/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java b/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java index 1fdfb97..8dbab4e 100644 --- a/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java +++ b/crm-auth/src/test/java/com/crm/auth/service/impl/ResourceServiceImplTest.java @@ -6,6 +6,7 @@ import com.crm.auth.domain.entity.SysMenu; import com.crm.auth.domain.entity.SysRoleMenu; import com.crm.auth.domain.enums.MenuType; import com.crm.auth.mapper.SysRoleMenuMapper; +import com.crm.auth.security.ApiPermissionCache; import com.crm.auth.service.ISysMenuService; import com.crm.base.domain.exception.BusinessErrorException; import com.crm.file.api.FileApi; @@ -42,6 +43,8 @@ class ResourceServiceImplTest { private SysRoleMenuMapper sysRoleMenuMapper; @Mock private FileApi fileApi; + @Mock + private ApiPermissionCache apiPermissionCache; @InjectMocks private ResourceServiceImpl resourceService; @@ -119,6 +122,7 @@ class ResourceServiceImplTest { assertThat(saved.getId()).isNull(); assertThat(saved.getMenuType()).isEqualTo(1); assertThat(saved.getMenuName()).isEqualTo("新目录"); + verify(apiPermissionCache).invalidate(); } @Test @@ -461,6 +465,7 @@ class ResourceServiceImplTest { resourceService.delete(100L); verify(sysMenuService).removeById(100L); + verify(apiPermissionCache).invalidate(); } @Test