Skip to content
bitzorcas
中EN

Concept

Multitenancy CurrentTenant 与持久化隔离

深入不可变租户快照、AsyncLocal 嵌套作用域、持久化执行上下文、EF Core 与 SqlSugar QueryFilter、写入归属和 Host bypass。

Last updated

租户解析只产生一个值,真正的隔离取决于这个值能否在整个异步调用链里保持一致,并最终进入查询和写入。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,构造函数不会自动抛错,安装作用域前应主动校验。

从已校验 Token Descriptor 构造模拟快照
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。

嵌套 Scope 的可验证语义
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。

TenantResolution / Job owner

ICurrentTenantAccessor

PersistenceExecutionContext

ICurrentTenant / ITenantContext

SqlSugar QueryFilter + AOP

EF Core DbContext filter

Cache / file / search callers

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。

因此当前行为是:

调用者EffectiveTenantIdORM 自动 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 隔离测试

operate-as 必须收窄 Host 数据面
[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. 审查命令

Terminal window
# 所有绕过全局过滤的调用都必须有显式 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'

上一页:租户解析链 · 下一篇:租户切换与模拟

100%

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