租户解析只产生一个值,真正的隔离取决于这个值能否在整个异步调用链里保持一致,并最终进入查询和写入。BitzOrcas 用 CurrentTenant 快照和单一 AsyncLocal 轨道完成传播。
1. 三个接口的分工
| 类型 | 可变性 | 消费者 |
|---|---|---|
CurrentTenant | 不可变 Record 快照 | 保存有效租户、Office 与模拟审计字段 |
ICurrentTenantAccessor | 受控可变缝隙 | Middleware、Job、正式模拟安装 / 恢复 Scope |
ICurrentTenant | 只读 | 业务用例、Store、缓存、审计 |
ITenantContext | 只读兼容投影 | 尚未迁移的旧 QueryFilter 与缓存调用方 |
业务代码不应注入 Accessor 只是为了随意切租户。它通常只读 ICurrentTenant.Tenant.EffectiveTenantId;只有请求边界、后台任务执行器或经过授权的运维适配器可以开 Scope。
2. CurrentTenant 不变量
普通快照至少有 TenantId;正式模拟快照要求以下四个字段全有或全无:
ImpersonatorUserId;OriginalTenantId;TenantImpersonationGrantId;TenantImpersonationExpiresAt。
EffectiveTenantId 用于数据范围,ActorTenantId 在模拟时返回操作人归属租户。ValidateInvariants() 只返回 bool,构造函数不会自动抛错,安装作用域前应主动校验。
var descriptor = tokenResult.GetValueOrThrow();var tenant = new CurrentTenant( TenantId: descriptor.TargetTenantId, ImpersonatorUserId: long.Parse(descriptor.OperatorUserId, CultureInfo.InvariantCulture), OriginalTenantId: descriptor.OperatorTenantId, TenantImpersonationGrantId: descriptor.GrantId, TenantImpersonationExpiresAt: descriptor.ExpiresAt);
// 四元组缺任一字段都不能进入运行时作用域。if (!tenant.ValidateInvariants()){ throw new InvalidOperationException("Invalid tenant impersonation snapshot.");}
// BeginScope 不重复授权;调用前必须已经完成 Grant 与到期校验。using var scope = tenantAccessor.BeginScope(tenant);这是连接现有契约所需的完整示例;当前 HTTP Host 尚未实现这段 Descriptor → Claim → CurrentTenant 还原闭环。
3. 嵌套与恢复
CurrentTenantAccessor.BeginScope 保存外层快照、推入新快照,同时可通过 IPersistenceExecutionContextAccessor.PushTenant 同步持久化上下文。Dispose 先恢复持久化 Scope,再恢复 CurrentTenant。
using (tenantAccessor.BeginScope(new CurrentTenant("tenant-a"))){ currentTenant.Tenant.EffectiveTenantId.Should().Be("tenant-a");
// 子作用域继承异步流;释放后必须恢复 tenant-a,而不是 Unset。 using (tenantAccessor.Change("tenant-b", "Temporary diagnostics")) { currentTenant.Tenant.EffectiveTenantId.Should().Be("tenant-b"); }
// 子作用域释放必须回到父租户,不能回到 Unset 或继续停留在 tenant-b。 currentTenant.Tenant.EffectiveTenantId.Should().Be("tenant-a");}
currentTenant.Tenant.IsAvailable.Should().BeFalse();Change(null) 会进入 Unset,并清除 Office 和模拟元数据;非空 Change 会保留当前快照的 Office 与模拟字段。这种便捷方法同样不做授权校验。
4. 持久化执行上下文
PersistenceExecutionContextAccessor 保存 CurrentUser、CurrentTenant 与 Correlation/Trace。TenantId 优先使用可用 CurrentTenant,否则回退 CurrentUser.TenantId。审计 Actor 使用 EffectiveUserId;用户级模拟还保留 ImpersonatorId。
5. EF Core 过滤器
生成式模型构建器把租户过滤器与软删除过滤器用 AND 组合,避免后一个 HasQueryFilter 覆盖前一个:
!IsDeleted && (IsTenantFilterBypass || entity.TenantId == TenantFilterValue)TenantFilterValue 优先读 ICurrentTenant.EffectiveTenantId,再读兼容 TenantContext,最后回退 CurrentUser。DbContext 必须 Scoped;长生命周期 DbContext 会把错误租户带入后续调用。
Repository 的“包含软删除”路径会 IgnoreQueryFilters(),随后为 ITenantEntity 手工加回 TenantId == TenantFilterValue,避免为了看已删除行而跨租户。
6. SqlSugar 过滤器
SqlSugar Scope 初始化时注册软删除 Filter,并从 PersistenceExecutionContext.TenantId 解析租户。普通调用者通过 SqlSugarTenantFilterInitializer 添加 ITenantEntity Filter。
Repository 清除过滤器以包含软删除时,也会检查模型是否实现 ITenantEntity,并手工加回当前租户谓词。
7. QueryFilter 不会自动填充写入 TenantId
TenantAggregateRoot 和 TenantEntityBase 默认 TenantId 都是 "0"。全局 QueryFilter 控制读取,不会自动把当前租户写入新对象。Store 或聚合工厂必须显式从可信 ICurrentTenant / CurrentUser 赋值。
public static class TenantErrors{ public static readonly Error Required = Error.Forbidden("Tenant.Required", "A trusted tenant scope is required.");}
public async Task<Result<string>> InsertAsync( CreateDocumentRequest request, CancellationToken cancellationToken){ var tenantId = currentTenant.Tenant.EffectiveTenantId; if (!TenancyDefaults.IsValid(tenantId)) { return Result.Failure<string>(TenantErrors.Required); }
var document = new DocumentAggregate(idGenerator.NewId()) { // 永远不要使用 request.TenantId;写归属只来自当前可信作用域。 TenantId = tenantId, };
// Repository 保存前,聚合已经携带受信的租户归属。 await documents.AddAsync(document, cancellationToken); return Result.Success(document.Id);}数据库还应对 TenantId 非空、非默认占位建立约束或写入契约测试,避免 "0" 行成为跨租户孤儿。
8. Host bypass 的当前行为
EF Core 的 IsTenantFilterBypass 只判断“已认证 Host”;SqlSugar 的 isHostBypass 使用同一条件。两者都不会检查是否已经 X-Operate-As-Tenant。
因此当前行为是:
| 调用者 | EffectiveTenantId | ORM 自动 Tenant Filter |
|---|---|---|
| User / Application | 解析租户 | 启用 |
| System | 显式 Scope 或主体租户 | 启用;但 StatusGuard 可绕过 |
| Host,无覆盖 | 主体/默认解析结果 | 绕过,全租户可见 |
| Host,operate-as | 目标租户 | 仍绕过,全租户可见 |
源码注释声称 operate-as 会恢复目标租户过滤,但谓词实现并未包含该条件。文档以可执行代码为准。
推荐的修复验收不是“EffectiveTenantId 已改变”,而是同时对 EF Core 与 SqlSugar 插入 tenant-a / tenant-b 数据,断言 Host operate-as tenant-a 的普通 Store 只能返回 tenant-a。是否保留真正的 Host 全局查询应由显式系统端口或审计过的 bypass scope 表达。
9. 双 ORM 隔离测试
[Theory][InlineData("EfCore")][InlineData("SqlSugar")]public async Task Host_OperateAs_Should_Filter_To_Target_Tenant(string provider){ // 两个租户使用相同业务键,防止测试只靠 Id 偶然隔离。 await SeedSameKey(provider, tenantId: "tenant-a"); await SeedSameKey(provider, tenantId: "tenant-b");
var host = AuthenticatedHost(homeTenant: "operations"); using var userScope = persistenceContext.PushUser(host); using var tenantScope = tenantAccessor.BeginScope(new CurrentTenant("tenant-a"));
// 普通 owner Store 不应因 Host 身份绕过目标租户边界。 var rows = await ResolveStore(provider).ListAsync(CancellationToken.None);
rows.Should().OnlyContain(x => x.TenantId == "tenant-a");}当前实现会让该测试失败,这正是它应作为 GA 门禁而不是文档演示成功的原因。
10. 审查命令
# 所有绕过全局过滤的调用都必须有显式 Tenant Predicate 或系统级理由。rg -n "IgnoreQueryFilters|QueryFilter\.(Clear|ClearAndBackup)|ClearFilter" \ src/Framework src/Platform -g '*.cs'
# 租户模型默认值为 0;逐个审查创建路径是否从可信上下文赋值。rg -n "TenantId\s*=|TenantId \{ get; set; \}" \ src/Platform src/Framework -g '*.cs'