多租户缺陷通常不会让请求立即失败。它们更危险的表现是返回“看起来正确”的另一租户数据、污染缓存,或让运维身份获得全局数据面。因此验收必须证明不能越界,而不只是证明 TenantId 能解析出来。
1. 当前测试覆盖到哪里
| 测试集 | 已保护的行为 | 仍缺少的证据 |
|---|---|---|
TenantResolverTests | 八步优先级、Header/Path 防伪、Host map、默认租户 | 格式、租户存在性、冲突拒绝 |
TenantResolutionMiddlewareTests | Scope 安装、Guard 调用、Header 快照 | Override/Debug 的目标状态和完整 HTTP 数据隔离 |
TenantStatusGuardTests | Active/GracePeriod 放行,其他状态拒绝,System bypass | Override 与正式模拟目标都经过 Guard |
CurrentTenantProjectionTests | 同一快照、嵌套恢复、Change 元数据 | 正式 Token 到 Snapshot 的 HTTP 还原 |
TenantIsolationGuardTests | 已认证 Host 绕过 Filter 的当前约定 | operate-as 后 EF / SqlSugar 应收窄目标租户 |
TenantImpersonationTokenServiceTests | 同租户/嵌套拒绝、Grant、到期、删除、两小时上限 | Grant owner、Scope、Step-up、MaxSessions、目标状态 |
WorkflowTimerExecutionContextTests | 父流程 TenantId 与 SqlSugar Filter | ICurrentTenant 同步与跨资源隔离 |
“测试通过”只说明代码符合当前断言。若断言本身把 Host 全局绕过固化为期望,它不是 GA 安全证据。
2. 最小隔离夹具
每个持久化 Provider 都应创建两个租户,并在两边使用相同业务键。不同 Id 会让错误查询碰巧只命中一边,掩盖 Tenant Filter 缺失。
public async Task SeedIsolationFixtureAsync(IOrderStore store){ // 两边故意使用相同 OrderNumber,TenantId 才是唯一隔离条件。 await store.InsertSystemAsync(new Order("order-a", "tenant-a", "SO-001")); await store.InsertSystemAsync(new Order("order-b", "tenant-b", "SO-001"));
// 另建软删除数据,覆盖 IncludeSoftDeleted 对 QueryFilter 的影响。 var deleted = new Order("order-a-deleted", "tenant-a", "SO-DELETED"); deleted.Delete(actorId: 1001); await store.InsertSystemAsync(deleted);}随后至少验证 tenant-a 查不到 tenant-b、包含软删除仍不跨租户、更新和删除 Predicate 带 TenantId、同一 DbContext/SqlSugarScope 不会跨 Scope 复用错误快照。
3. 请求入口矩阵
| Caller | 输入 | 期望解析 | 期望 Guard | 期望数据面 |
|---|---|---|---|---|
| User tenant-a | 无显式租户 | Claim tenant-a | 执行 | 仅 tenant-a |
| User tenant-a | X-Tenant: tenant-b | 忽略伪造值 | tenant-a | 仅 tenant-a |
| Application tenant-a | /tenant-b/orders | 忽略伪造路径 | tenant-a | 仅 tenant-a |
| Host | 无 override | 按设计的 Host 上下文 | 明确策略 | 不能隐式获得普通 Store 全局面 |
| Host | operate-as tenant-b | tenant-b | 必须检查 tenant-b | 仅 tenant-b |
| Debug / Development | tenant-b | tenant-b | 建议仍检查 | 仅 tenant-b |
| System Job | 父记录 tenant-b | tenant-b | 明确系统策略 | 单条处理仅 tenant-b |
| Anonymous | Host map | 映射租户 | 执行 | 仅映射租户允许的匿名数据 |
当前实现对 Host operate-as、Debug 的 Guard 与 ORM 数据面不满足表中目标,这些行应先作为失败门禁保留。
4. 双 ORM 合同测试
[Theory][MemberData(nameof(PersistenceProviders))]public async Task Normal_store_must_never_cross_effective_tenant(string provider){ // 每个 Provider 独立建库并加载同键 A/B 数据,避免共享状态影响结果。 await using var fixture = await TenantDatabaseFixture.CreateAsync(provider); await fixture.SeedSameBusinessKeysAsync();
using var userScope = fixture.PushUser(AuthenticatedUser("tenant-a")); using var tenantScope = fixture.PushTenant(new CurrentTenant("tenant-a"));
// 普通列表和显式包含软删除必须保持相同租户边界。 var active = await fixture.Orders.ListAsync(includeSoftDeleted: false); var all = await fixture.Orders.ListAsync(includeSoftDeleted: true);
active.Should().OnlyContain(x => x.TenantId == "tenant-a"); all.Should().OnlyContain(x => x.TenantId == "tenant-a");}另建 Host operate-as 合同。它在当前实现下应失败,直到两个 Provider 不再因 CallerType.Host 无条件绕过。
5. 模拟会话安全合同
正式租户模拟的测试必须覆盖完整会话,而不是只测 IssueAsync:
- Endpoint 校验操作权限与 Step-up;
- Grant owner 等于操作人租户,Target 可服务;
- Token Claim 能还原完整 CurrentTenant 四元组;
- 业务请求使用 EffectiveTenantId,审计使用 ActorTenantId;
- 到期或删除 Grant 后下一次请求返回 401;
- Scope 限定可访问模块,MaxSessions 限制并发会话;
- 缓存、搜索、文件和报表与数据库使用同一有效租户。
var token = await impersonation.IssueAsync( operatorTenantId: "operations", operatorUserId: "1001", targetTenantId: "tenant-b", cancellationToken);
// 首次请求应命中 tenant-b,并记录 GrantId 与原租户。var first = await api.GetOrdersAsync(token.Value!.AccessToken, cancellationToken);first.Rows.Should().OnlyContain(x => x.TenantId == "tenant-b");
await grants.DeleteAsync(token.Value.GrantId, cancellationToken);
// Middleware 每次校验 Grant;删除后旧 Token 不得继续访问。var revoked = await api.GetOrdersAsync(token.Value.AccessToken, cancellationToken);revoked.StatusCode.Should().Be(HttpStatusCode.Unauthorized);revoked.ErrorCode.Should().Be("TenantImpersonation.Revoked");示例描述的是目标合同;当前仓库尚无可调用的签发 Endpoint 和完整 Claim 还原链。
6. 缓存、文件和后台任务测试
隔离测试应使用同一个 DocumentId、FileName、Setting Key 和消息业务键:
- 缓存:tenant-a 的
document-1绝不能被 tenant-b 命中;模拟后 Key 切到目标租户; - 文件:服务端保存的 object key 必须有 tenant 段,其他租户不能得到预签名 URL;
- 设置:租户覆盖优先于全局值,更新后失效所有依赖该回退值的缓存;
- Consumer:错误 TenantId 与父记录 owner 冲突时拒绝并进入死信;
- Workflow Timer:回调内 CurrentTenant、Persistence Context 和 Filter 一致;
- Audit Retention:逐租户运行,缺少 Scope 不得静默清理
"0"。
7. 生产可观测性
每个请求或 Job 的结构化日志至少包含:
| 字段 | 用途 |
|---|---|
tenant.effective_id | 数据范围 |
tenant.actor_id | 操作人原租户;普通请求等于 Effective |
tenant.source | Claim、HostMap、JobOwner、OperateAs、Debug |
tenant.grant_id | 正式模拟会话的追责和撤销 |
caller.type、caller.id | 区分 User / Application / Host / System |
correlation_id、trace_id | 跨 API、消息和 Job 串联 |
不要记录完整 JWT、Secret 或包含敏感信息的原始 Header。对 X-Operate-As-Tenant、Debug、Guard bypass 和全局查询建立独立安全事件与告警。
8. 排障决策树
排障时先保存 TraceId、CallerType、Effective/Actor Tenant 和 SQL/缓存 Key 的安全摘要,再隔离流量。不要通过关闭 Tenant Filter 来“确认数据是否存在”,这会把诊断动作本身变成越权访问。
9. 故障演练
发布前至少演练:
- Host map 缓存陈旧,域名变更后能否及时失效;
- 租户在请求中途被 Suspended,后续请求是否拒绝;
- 模拟 Grant 删除、Token 到期和时钟偏差;
- Consumer 重试跨越租户停用时间点;
- Redis 丢失或回源时是否仍保持租户边界;
- Provider 切换后 EF Core 与 SqlSugar 的合同测试是否等价;
- 全局运维查询是否有显式权限、审批、审计和限流。
10. 运行源码与测试审查
# 先运行多租户单元与架构测试,确认当前行为没有无意漂移。dotnet test tests/BitzOrcas.Unit.Tests/BitzOrcas.Unit.Tests.csproj \ --filter "FullyQualifiedName~Tenant|FullyQualifiedName~Tenancy"
# 搜索所有可能绕过租户过滤的入口;预期每处都有显式边界说明或合同测试。rg -n "IgnoreQueryFilters|ClearAndBackup|IsTenantFilterBypass|isHostBypass" \ src/Framework src/Platform src/Hosts -g '*.cs'
# 搜索无作用域时回退默认租户的代码;预期逐项判断是否适用于生产后台任务。rg -n 'TenantId.*\?\?.*"0"|DefaultTenantId|PushTenant|BeginScope' \ src/Framework src/Platform src/Hosts -g '*.cs'11. GA 门禁
- 所有入口矩阵都有成功与拒绝用例;
- EF Core、SqlSugar、缓存、文件、搜索、报表和消息均通过 A/B 同键隔离;
- Host operate-as 只看到目标租户,真正全局查询使用独立受审计端口;
- 正式模拟具备签发、还原、Scope、Step-up、并发限制、撤销与审计闭环;
- JobHost 使用统一 CurrentTenant 轨道,不存在 ORM 正确而业务上下文错误;
- 运维 Runbook 能从 TraceId 定位租户来源、数据面、缓存 Key 和模拟 Grant。