You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

12 KiB

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 泄漏进 Controllerassign-menusroles/deleteusers/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-menusroles/deleteusers/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.