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.
140 lines
12 KiB
140 lines
12 KiB
|
1 month ago
|
# 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`.
|