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
12 KiB
Spec: 动态权限拦截 + 封遗留写路径
Status: ready-for-agent
Problem Statement
管理员在权限资源树中配置了按钮级权限点(含 apiUrl),期望系统自动根据该配置拦截未授权的 API 请求。但当前实现存在两个问题:
- 无运行时动态拦截:
sys_menu中的apiUrl字段未被消费,鉴权全靠手动标注@PreAuthorize("hasAuthority('crm:xxx:yyy')")。新增端点忘加注解就漏防。 - 遗留写路径绕过防护:
/api/system/menus/save和/api/system/menus/delete直接调用 MyBatis-Plus CRUD,无任何层级校验、子节点检查或授权清理——可从第二个入口损坏权限资源树。 - Mapper 泄漏进 Controller:
assign-menus、roles/delete、users/assign-roles等端点在 Controller 层直接操作 Mapper,无事务保护,违反 locality 原则。
Solution
构建基于 apiUrl 的动态权限拦截机制,并封堵遗留写路径漏洞:
- 动态拦截器:MVC 层
HandlerInterceptor读取请求 URL,匹配sys_menu中 button 节点的apiUrlpattern,检查当前用户是否拥有对应 perms。未注册的 URL 放行(fail-open)。 - 封遗留端点:删除
/api/system/menus/save和/api/system/menus/delete,所有资源树写操作统一走ResourceController(已含层级校验、子节点检查、级联清理)。 - 收拢 Mapper 逻辑:将
assign-menus、roles/delete、users/assign-roles的 Mapper 操作收进 Service 层,加@Transactional,消除 Controller 持有 Mapper 的反模式。
User Stories
- 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@PreAuthorizeannotations. - 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/*/detailor/api/orders/**. - As a user with role "Sales", I want to be denied access to
/api/users/listif my role is not authorized for the corresponding button permission point, so that I cannot bypass UI-level restrictions by calling the API directly. - 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. - 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. - 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.
- As an admin, I want the
/api/system/menus/saveendpoint to validate parent-child type constraints (catalog→menu→button), so that I cannot create invalid tree structures. - As an admin, I want the
/api/system/menus/deleteendpoint to reject deletion of nodes with children and cascade-clean role-menu references, so that I cannot leave orphaned authorizations. - 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. - 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. - 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.
- 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.
- 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. - As a developer, I want the interceptor to coexist with existing
@PreAuthorizeannotations, so that I can gradually migrate from annotation-based to URL-based authorization. - 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 incom.crm.auth.security) — matches request URL against cached apiUrl patterns, checks user authority set. - New:
ApiPermissionCache(Caffeine-based in-memory cache incom.crm.auth.security) — stores list of{apiUrlPattern, permsCode}tuples for enabled button nodes. - Modified:
ResourceServiceImpl— callsApiPermissionCache.invalidate()on save/delete of button nodes. - Modified:
SystemController— removes/menus/saveand/menus/deleteendpoints; delegatesassign-menus,roles/delete,users/assign-roleslogic to service layer. - Modified:
ISysRoleService/SysRoleServiceImpl— addsassignMenus(roleId, menuIds)with@Transactional; movesdeleteRolecascade logic into service. - Modified:
IAuthUserService/AuthUserServiceImpl— ensuresassignRolesis transactional (may already be). - Modified: Spring MVC config — registers
ApiPermissionInterceptorto run after JWT filter, before controllers.
Interfaces Modified
ApiPermissionInterceptor.preHandle(): readsHttpServletRequest.getRequestURI(), matches against cached patterns usingAntPathMatcher, retrieves current user's authority set fromSecurityContextHolder, denies if no match found among enabled perms.ApiPermissionCache.load(): queriessys_menuformenuType=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/123but not/api/users/123/detail;/api/users/**matches both. - Authority check: The interceptor extracts the user's authority set from
SecurityContext.getAuthentication().getAuthorities()(populated byPermissionGrant.asAuthorities()inJwtAuthenticationFilter). It checks if any authority matches thepermscode 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
@PreAuthorizecontinue 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 rulesperms(varchar(100)) — the authority code checked by the interceptor
API Contracts
- Removed endpoints:
POST /api/system/menus/save→ replaced byPOST /api/resources/saveOrUpdatePOST /api/system/menus/delete→ replaced byPOST /api/resources/delete
- Unchanged endpoints (now with transactional service backing):
POST /api/system/roles/assign-menus— still acceptsroleId+menuIds(comma-separated), but logic moved toISysRoleService.assignMenus()POST /api/system/roles/delete— still acceptsroleId, but cascade cleanup moved to servicePOST /api/system/users/assign-roles— still acceptsuserId+roleIds(comma-separated), but wrapped in transaction
Specific Interactions
- Request flow:
Request → JwtAuthenticationFilter (loads PermissionGrant into SecurityContext) → ApiPermissionInterceptor (matches URL, checks authority) → Controller (if allowed) - Cache lifecycle:
- Startup: cache empty, first request triggers lazy load from
sys_menu. - Mutation:
ResourceServiceImpl.save()or.delete()callscache.invalidate(). - Next request: cache rebuilds from DB.
- Startup: cache empty, first request triggers lazy load from
- Pattern matching priority: If multiple patterns match (e.g.,
/api/users/*and/api/users/**), the most specific match wins (Spring'sAntPathMatcherhandles 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):
assignMenusis transactional (partial failure rolls back)deleteRolecascade-cleanssys_role_menu
- Regression: Existing
DataScopeIntegrationTestsuite 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/saveand/deletefor 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
@PreAuthorizeannotations. The fail-open policy ensures no accidental lockouts. - Testing seam: The highest seam is the
HandlerInterceptoritself — 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.