# 安全上下文契约 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 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);