HTTP Middleware 不会进入 Quartz、CAP Consumer 或队列 Worker。后台处理的第一条规则不是“尝试读取当前租户”,而是从消息所指向的可信业务事实重新建立租户作用域。
1. 后台处理的信任链
消息体里的 TenantId 只能在生产者已受信、消息完整性受保护且 Consumer 能校验 owner 时使用。更稳妥的做法是携带聚合或父任务 Id,再由 Store 解析它真正的租户归属。
2. Consumer 的完整骨架
public async Task ConsumeAsync(OrderPaidMessage message, CancellationToken cancellationToken){ // TenantId 不从消息的可修改字段直接采信,而是从订单事实源解析。 var owner = await orderOwnership.FindAsync(message.OrderId, cancellationToken); if (owner is null || !TenancyDefaults.IsValid(owner.TenantId)) { throw new InvalidOperationException("Order has no trusted tenant owner."); }
// 生产处理还应调用 ITenantGuard,拒绝已停用或不存在的租户。 await tenantGuard.EnsureActiveAsync(owner.TenantId, cancellationToken);
// Scope 同时传播给 CurrentTenant 与持久化执行上下文,释放后恢复外层。 using var tenantScope = tenantAccessor.BeginScope(new CurrentTenant(owner.TenantId)); await orderApplication.ConfirmPaymentAsync(message.OrderId, cancellationToken);}幂等键也必须带租户:order-paid:{tenantId}:{messageId}。只使用 MessageId 的前提是它在全平台全局唯一且由可信系统生成;业务键通常不满足这个假设。
3. Workflow Timer 的现有实现
WorkflowTimerJobExecutionScope 不信任队列表本身的 TenantId,而是通过 InstanceId 读取父流程实例。它推入 IPersistenceExecutionContextAccessor,随后备份当前 SqlSugar 的 ITenantEntity Filter、安装父实例 TenantId,并在 finally 中恢复。
var tenantId = await tenantResolver.ResolveTimerJobTenantAsync( job.InstanceId, cancellationToken);if (!TenancyDefaults.IsValid(tenantId)){ throw new InvalidOperationException("Workflow timer has no trusted tenant context.");}
// 当前实现只推入持久化上下文,并没有注入 ICurrentTenantAccessor。using var contextScope = persistenceContext.PushTenant(new CurrentTenant(tenantId));sqlSugarClient.QueryFilter.ClearAndBackup<ITenantEntity>();try{ // 队列扫描建立的匿名 Filter 被替换成父流程租户 Filter。 sqlSugarClient.QueryFilter.AddTableFilter<ITenantEntity>(x => x.TenantId == tenantId); await operation(cancellationToken);}finally{ sqlSugarClient.QueryFilter.Restore();}4. 导出任务使用显式 TenantId
导出调度以 ExportRequest.TenantId 查询、防重、保存任务并生成对象键:
exports/{tenantId}/{guid}_{fileName}ScopedExportJobExecutionScope 为每条任务建立独立 DI Scope,却不会安装 CurrentTenant;它把 job.TenantId 重新放入 ExportRequest。这种“所有端口显式传 TenantId”的模式可以安全,但不能与依赖 Ambient CurrentTenant 的 Builder 混用。
public async IAsyncEnumerable<InvoiceRow> ReadAsync( ExportRequest request, [EnumeratorCancellation] CancellationToken cancellationToken){ // ExportRequest 来自已认领的持久化 Job;查询仍显式限定同一 TenantId。 var invoices = store.StreamByTenantAsync(request.TenantId, cancellationToken); await foreach (var invoice in invoices.WithCancellation(cancellationToken)) { // 输出不接受前端另传 TenantId,避免任务记录与查询范围分叉。 yield return InvoiceRow.From(invoice); }}5. 缓存键:三种实现并存
| 实现 | 租户来源 | 当前语义 |
|---|---|---|
DocumentCacheService | 方法参数 tenantId | docs:doc:{tenantId}:{documentId},显式且可审查 |
PersistentSettingsManager | 方法参数,经 Global Scope parts | Key 明确含 tenant 与真实 TenantId / 0 |
CacheKeyBuilder 的 Tenant Scope | ICurrentUserAccessor.Current.TenantId | 不读取 Effective CurrentTenant |
第三种在普通用户请求里通常正确,但在租户模拟时会保留操作人的原租户。若数据库已切到目标租户,缓存仍落到原租户,可能产生错读、污染或失效不到位。
using var tenantScope = tenantAccessor.BeginScope(impersonatedTenant);
// 期望目标租户 tenant-b;当前 CacheKeyBuilder 仍可能从 CurrentUser 得到 tenant-a。var key = cacheKeyBuilder.Build( area: "orders", scope: CacheScope.Tenant, "summary", orderId);
key.Value.Should().Contain("tenant-tenant-b");// 同一断言同时防止把原租户缓存误当成目标租户缓存。key.Value.Should().NotContain("tenant-tenant-a");当前实现不能通过这项断言。修复方向是让 Tenant Scope 使用 ICurrentTenant.EffectiveTenantId,仅在租户上下文不可用时再按明确契约回退 CurrentUser。
6. 对象存储的契约与实现边界
IFileStorage 的注释要求 object key 包含 tenant scope,FileContainerOptions.IsMultiTenant 默认也是 true;但底层 Local 与 S3 Adapter 接受任意字符串,并不自动加租户前缀。租户边界由调用方构建 Key 的正确性承担。
public string BuildAttachmentKey(string tenantId, string aggregateId, string fileName){ // 租户必须来自可信上下文;文件名只保留安全字符,不能允许路径穿越。 if (!TenancyDefaults.IsValid(tenantId)) throw new InvalidOperationException("A trusted tenant is required.");
var safeName = fileNameSanitizer.Normalize(fileName); // 固定容器前缀与 TenantId,禁止 Endpoint 接收完整 objectKey。 return $"attachments/{tenantId}/{aggregateId}/{Guid.CreateVersion7():N}_{safeName}";}Local Adapter 会阻止路径越出基目录;S3 Adapter 直接把 Key 交给 SDK。两者都不能验证 Key 是否属于当前租户。下载 Endpoint 必须先按 TenantId 查到 FileAsset owner,再使用服务端保存的 object key 生成预签名 URL。
7. 搜索、报表与审计
- 搜索索引文档必须保存 TenantId,所有普通 Query 都把它作为 Filter;
- 报表事实表和预聚合表必须按 TenantId 分区或建复合索引;
- 审计写入应记录 EffectiveTenantId;正式模拟还要记录 ActorTenantId、ImpersonatorUserId 与 GrantId;
- 保留清理和归档任务必须显式接收 TenantId,不能在无 Scope 时静默用
"0"。
当前 AuditRetentionJobExecutor 在缺少 ITenantContext 时回退 "0"。如果调度器没有逐租户安装上下文,它只会清理全局标识的数据。这需要调度级租户枚举与逐租户契约测试,而不是仅验证 Executor 没有抛错。
8. 资源隔离验收表
| 资源 | 最低验证 |
|---|---|
| Consumer | 同业务 Id 的 tenant-a / tenant-b 消息不会串处理,未知 owner 进入死信 |
| Workflow Timer | CurrentTenant、Persistence Context、SqlSugar Filter 三者相等 |
| Export | Job Store、Builder Query、Object Key、Download owner 使用同一 TenantId |
| Cache | 普通、用户模拟、租户模拟均以 Effective Tenant 建键与失效 |
| File | 不能凭其他租户 object key 生成下载 URL;Key 必有租户段 |
| Search / Report | 所有用户查询都有 Tenant Filter;Host 全局查询走单独审计端口 |
| Retention | 每租户独立执行并记录删除数;失败不会被默认租户掩盖 |