- Add comprehensive docs/ directory with standardized structure - Include architecture specification and contract documents - Add prompt templates for development workflows - Establish M1 deliverables as frozen specifications Co-authored-by: Qwen-Coder <[email protected]>
5.8 KiB
安全上下文契约 v1.0
1. 上下文字段定义
必填字段
| 字段 | 类型 | 来源 | 说明 |
|---|---|---|---|
| customerId | String | 网关 Header: X-Customer-Id | 租户标识 |
| orgId | String | 网关 Header: X-Org-Id | 组织标识 |
| userId | String | 网关 Header: X-User-Id | 操作人标识 |
| roles | List | 网关 Header: X-User-Roles | 角色列表(逗号分隔) |
可选字段
| 字段 | 类型 | 来源 | 说明 |
|---|---|---|---|
| traceId | String | Header: X-Trace-Id | 链路 ID(无则服务自动生成 UUID) |
| requestId | String | Header: X-Request-Id | 网关生成的请求唯一 ID(排障用) |
| clientId | String | Header: X-Client-Id | 来源系统标识(管理端/大屏/移动端) |
2. 上下文注入流程
网关(JWT 验证 → 解析 claims → 注入 Header) → TraceIdFilter(生成或接收 traceId/requestId,写入 MDC) → SecurityContextFilter(解析 Header → 构造 TenantContext 对象 → 写入 ThreadLocal) → Controller → UseCase → Port → Adapter
3. 宪法级规则
规则 1:可信来源——禁"来源",不禁"显式参数"
核心原则:customerId/orgId/userId 的【值】只能来源于网关注入的 Header, 不能来源于请求 DTO 中客户端传入的字段。
具体条款: (a) Controller/DTO 层: - 请求 DTO 中【禁止】定义 customerId/orgId/userId 字段用于业务查询 - 如果 DTO 中存在这些字段(如管理后台的"代操作"场景), 【必须】在 Controller 中校验"DTO 传入值 == TenantContext 中的值",否则拒绝
(b) Application 层(UseCase): - 【默认】从 TenantContext.current() 获取 customerId - 【允许】将 TenantScope(只读对象,包含 customerId/orgId)作为 显式参数传入 UseCase.execute(command, tenantScope),便于单元测试 - 【禁止】从 Command/Query 对象中读取 customerId 作为查询条件
(c) Port / Adapter 层: - Out Port 接口【允许】将 customerId 作为显式方法参数 例如:OrderPersistencePort.findByIdAndCustomerId(OrderId id, String customerId) - 这样做的目的是让 Port 接口具有明确的契约,提高可测试性
规则 2:多租户数据隔离
- 所有数据查询【必须】携带 customerId 作为过滤条件
- 实现方式(二选一,在项目初始化时决定): (a) 显式 WHERE:每个查询方法都带 customerId 参数 (b) Hibernate @Filter:全局自动注入(需要在配置中启用)
- 写入操作:create_user_id/update_user_id 从安全上下文自动填充
规则 3:上下文在线程间的传播
(a) 同步请求链(Controller → UseCase → Repo): ThreadLocal + MDC 正常工作,无需额外处理
(b) 异步场景(@Async / CompletableFuture / 线程池 / 调度任务): 【必须】使用 TaskDecorator 或 ContextWrapper 显式传播 TenantContext 【禁止】在异步代码中直接调用 TenantContext.current()(除非已确认上下文已传播)
(c) Virtual Threads 注意事项: Java 21 Virtual Threads 默认继承 ThreadLocal,但在大量并发下应 评估是否迁移到 ScopedValue(JEP 481,Preview)。 模板默认使用 ThreadLocal,预留迁移接口。
规则 4:SecurityContext 类定义
位置:${name}-application/src/.../port/in/TenantContext.java
public final class TenantContext { private static final ThreadLocal HOLDER = new ThreadLocal<>();
private final String customerId;
private final String orgId;
private final String userId;
private final List<String> roles;
// static current(), set(), clear()
// 构造器、getter(不可变)
}
位置:${name}-application/src/.../port/in/TenantScope.java(只读快照,用于传参)
public record TenantScope(String customerId, String orgId) { public static TenantScope fromContext() { var ctx = TenantContext.current(); return new TenantScope(ctx.getCustomerId(), ctx.getOrgId()); } }
✅ 可自动检查项
| 规则 | 检查方式 |
|---|---|
| Request DTO 中禁止出现 customerId 字段 | ArchUnit: classes in ..api.dto.. 的 fields 不含名为 customerId/orgId/userId 的字段(管理后台例外需 @SuppressWarning 注解标注) |
| Command/Query 中禁止出现 customerId 字段 | ArchUnit: classes in ..port.in.. 的 fields 不含名为 customerId/orgId 的字段 |
| Out Port 接口允许 customerId 参数 | 不做限制(这是允许的) |
| 异步代码不直接调用 TenantContext.current() | 人工评审 + 编码规范约束(ArchUnit 难以静态检测) |
❌ 典型违规示例
违规 1:从 DTO 透传 customerId
// 错误!DTO 的 customerId 不可信 public record CreateOrderRequest( String customerId, // 客户端传入 → 不可信 List items ) {}
@PostMapping("/orders") public ApiResponse create(@RequestBody CreateOrderRequest req) { createOrderUseCase.execute(new CreateOrderCommand( req.customerId(), // 直接把 DTO 的值当查询条件 → 违规! req.items() )); }
// 正确:从安全上下文获取 @PostMapping("/orders") public ApiResponse create(@RequestBody CreateOrderRequest req) { var scope = TenantScope.fromContext(); createOrderUseCase.execute(new CreateOrderCommand(req.items()), scope); }
违规 2:异步任务中直接使用 TenantContext
// 错误!异步线程中 ThreadLocal 可能为空 @Async public void sendNotification(OrderId orderId) { var customerId = TenantContext.current().getCustomerId(); // 可能 NPE! // ... }
// 正确:在发起方捕获上下文,显式传入 var scope = TenantScope.fromContext(); asyncNotificationService.sendNotification(orderId, scope);