Skip to content
bitzorcas
中EN

Reference

Multitenancy 测试与生产运维

以双租户、双 ORM、模拟会话、后台资源和故障演练构建可执行隔离门禁,并给出生产排障路径。

Last updated

多租户缺陷通常不会让请求立即失败。它们更危险的表现是返回“看起来正确”的另一租户数据、污染缓存,或让运维身份获得全局数据面。因此验收必须证明不能越界,而不只是证明 TenantId 能解析出来。

1. 当前测试覆盖到哪里

测试集已保护的行为仍缺少的证据
TenantResolverTests八步优先级、Header/Path 防伪、Host map、默认租户格式、租户存在性、冲突拒绝
TenantResolutionMiddlewareTestsScope 安装、Guard 调用、Header 快照Override/Debug 的目标状态和完整 HTTP 数据隔离
TenantStatusGuardTestsActive/GracePeriod 放行,其他状态拒绝,System bypassOverride 与正式模拟目标都经过 Guard
CurrentTenantProjectionTests同一快照、嵌套恢复、Change 元数据正式 Token 到 Snapshot 的 HTTP 还原
TenantIsolationGuardTests已认证 Host 绕过 Filter 的当前约定operate-as 后 EF / SqlSugar 应收窄目标租户
TenantImpersonationTokenServiceTests同租户/嵌套拒绝、Grant、到期、删除、两小时上限Grant owner、Scope、Step-up、MaxSessions、目标状态
WorkflowTimerExecutionContextTests父流程 TenantId 与 SqlSugar FilterICurrentTenant 同步与跨资源隔离

“测试通过”只说明代码符合当前断言。若断言本身把 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-aX-Tenant: tenant-b忽略伪造值tenant-a仅 tenant-a
Application tenant-a/tenant-b/orders忽略伪造路径tenant-a仅 tenant-a
Host无 override按设计的 Host 上下文明确策略不能隐式获得普通 Store 全局面
Hostoperate-as tenant-btenant-b必须检查 tenant-b仅 tenant-b
Debug / Developmenttenant-btenant-b建议仍检查仅 tenant-b
System Job父记录 tenant-btenant-b明确系统策略单条处理仅 tenant-b
AnonymousHost map映射租户执行仅映射租户允许的匿名数据

当前实现对 Host operate-as、Debug 的 Guard 与 ORM 数据面不满足表中目标,这些行应先作为失败门禁保留。

4. 双 ORM 合同测试

同一合同运行 EF Core 与 SqlSugar
[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:

  1. Endpoint 校验操作权限与 Step-up;
  2. Grant owner 等于操作人租户,Target 可服务;
  3. Token Claim 能还原完整 CurrentTenant 四元组;
  4. 业务请求使用 EffectiveTenantId,审计使用 ActorTenantId;
  5. 到期或删除 Grant 后下一次请求返回 401;
  6. Scope 限定可访问模块,MaxSessions 限制并发会话;
  7. 缓存、搜索、文件和报表与数据库使用同一有效租户。
撤销后会话必须立即失效
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.sourceClaim、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. 排障决策树

否是否是缓存文件后台

发现租户数据异常

EffectiveTenantId 正确?

检查认证、解析 Step 与 Scope 生命周期

ORM SQL 含 TenantId?

检查 Host bypass / IgnoreQueryFilters / Clear Filter

只在缓存或文件复现?

检查 Key 的租户来源与失效 Tag

检查 owner 查询、object key 与预签名入口

检查父记录 owner 与 CurrentTenant/Persistence 分叉

排障时先保存 TraceId、CallerType、Effective/Actor Tenant 和 SQL/缓存 Key 的安全摘要,再隔离流量。不要通过关闭 Tenant Filter 来“确认数据是否存在”,这会把诊断动作本身变成越权访问。

9. 故障演练

发布前至少演练:

  1. Host map 缓存陈旧,域名变更后能否及时失效;
  2. 租户在请求中途被 Suspended,后续请求是否拒绝;
  3. 模拟 Grant 删除、Token 到期和时钟偏差;
  4. Consumer 重试跨越租户停用时间点;
  5. Redis 丢失或回源时是否仍保持租户边界;
  6. Provider 切换后 EF Core 与 SqlSugar 的合同测试是否等价;
  7. 全局运维查询是否有显式权限、审批、审计和限流。

10. 运行源码与测试审查

Terminal window
# 先运行多租户单元与架构测试,确认当前行为没有无意漂移。
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。

上一篇:后台任务与租户化资源 · 返回 Multitenancy

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%