Skip to content
bitzorcas
中EN

Concept

Multitenancy 租户解析链

逐步解释八个解析器、可信度、Host 映射、Header 与 Path 防伪、默认租户、状态守卫和冲突处理。

Last updated

租户解析链只做一件事:从已经装配的候选证据中选择第一个非空、非 "0" 的租户标识。它不证明租户存在,不验证雪花格式,也不自动证明调用者拥有目标租户权限。

1. 精确优先级与 HTTP 接线

顺序StepHTTP 当前输入信任与限制
1SystemJobMiddleware 不设置 IsSystemJob/SystemJobTenantId;可读取已有 Accessor Scope主要供非 HTTP 或外层作用域
2RootOperatorOverrideHost 的 X-Operate-As-Tenant 或 Development 的 X-Tenant-Debug只校验调用者类型/环境与非 0
3ApplicationCaller已认证 Application TenantId认证主体事实
4UserClaim已认证 User TenantId认证主体事实
5HostSubdomainRequest.Host.Host + HostTenantMap映射来自 PlatformTenant 全局表
6HeaderX-Tenant只在等于已认证主体 TenantId 时返回
7PathURL 第一段只在等于已认证主体 TenantId 时返回
8SingleTenantDefault无输入固定 1000001

RootOverride 早于 Application/User Claim,因此已认证 Host 可以覆盖;普通 User 或 Application 的 X-Operate-As-Tenant 在 Middleware 装配阶段被忽略。

无有效值无有效值无有效值无有效值无有效值无有效值无有效值首个有效值首个有效值首个有效值首个有效值首个有效值首个有效值首个有效值

Ambient / SystemJob

Root / Debug override

Application claim

User claim

Host map

X-Tenant confirmation

Path confirmation

Default 1000001

TenantId.Of

2. Resolver 算法

当前首个有效值算法
public async ValueTask<TenantId?> ResolveAsync(
TenantResolutionRequest request,
CancellationToken cancellationToken)
{
foreach (var step in steps)
{
var candidate = await step.ResolveAsync(request, cancellationToken);
// IsValid 只排除 null、空串和 "0",不访问租户 Store。
if (TenancyDefaults.IsValid(candidate))
{
return TenantId.Of(candidate!);
}
}
// 默认 Step 正常注册时不会走到这里;自定义组合仍可能返回 null。
return null;
}

TenantId.Of 当前直接包装字符串。若传入 abc,它仍是“解析有效候选”,随后 TenantStatusGuard 通常因找不到租户而拒绝;Override/Debug 又会跳过 Guard,因此格式验证应在可信边界补齐。

3. 认证 Claim 优先于 Host、Header 与 Path

User 和 Application 的已认证 TenantId 会在 Host 映射前返回。因此:

  • 用户 JWT 属于 tenant-a,即使访问 tenant-b 的域名,结果仍是 tenant-a;
  • X-Tenant: tenant-b 不会覆盖 tenant-a Claim;
  • /tenant-b/orders 也不会覆盖 tenant-a Claim;
  • Header/Path 当前主要是“与认证事实一致时确认”,而不是独立选择来源。
Header 与 Path 防伪测试
[Theory]
[InlineData("200", "999", null)]
[InlineData("200", "200", "200")]
public async Task Header_Should_Only_Confirm_Authenticated_Tenant(
string callerTenant,
string headerTenant,
string? expected)
{
// Header 是客户端提示;调用者 TenantId 来自认证后的 CurrentUser。
var request = new TenantResolutionRequest
{
AuthenticatedCaller = AuthenticatedUser(callerTenant),
Headers = new Dictionary<string, string> { ["X-Tenant"] = headerTenant },
};
var resolved = await new HeaderTenantStep().ResolveAsync(
request,
CancellationToken.None);
// 不一致返回 null,让解析链继续,而不是接受伪造目标。
resolved.Should().Be(expected);
}

Path 使用 ReadOnlySpan<char> 读取第一段,忽略开头多个 /,不使用 Split、正则或 LINQ tokenization。架构测试固定了这条热路径约束。

4. Host 映射

HostTenantMapProvider 从全局 PlatformTenant.Subdomain 构建不区分大小写的字典,以固定 10 分钟 TTL 缓存到 tenancy:host-tenant-map。Middleware 使用 Request.Host.Host(不含端口)精确查字典键。

Host 映射的有效数据形状
var request = new TenantResolutionRequest
{
Host = "acme.app.example",
HostTenantMap = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
// PlatformTenant.Subdomain 必须与 Request.Host.Host 的实际值一致。
["acme.app.example"] = "2000001",
},
};
var resolved = await new HostSubdomainTenantStep().ResolveAsync(
request,
CancellationToken.None);
// 精确 Host 键命中后返回平台租户表保存的 TenantId。
resolved.Should().Be("2000001");

创建或修改租户子域名后必须调用 Provider 的 InvalidateAsync。TTL 当前硬编码,不能从配置覆盖。还应在写侧验证 Host 唯一性、规范化和保留域名;PlatformTenant 当前只检查非空时长度不超过 128。

5. Root 与 Debug 覆盖

5.1 X-Operate-As-Tenant

只有已认证 Host 调用者可写 RootOperatorOverrideTenantId。User、Application、System、Delegated 和匿名调用者的相同 Header 都被忽略。

5.2 X-Tenant-Debug

只在 IHostEnvironment.IsDevelopment() 为真时生效,Production 与 Staging 忽略。它与 Root Header 共用同一个 Request 字段,Root Header 优先。

6. SingleTenant 默认回退

解析链无证据时固定返回 1000001,并不读取 TenancyMode 或配置。生产 TenantStore 若没有该租户,Guard 会拒绝;InMemoryTenantStore 为向后兼容会放行未注册默认租户。

SingleTenant 的含义仍是:所有 tenant-scoped 数据保存 TenantId=1000001,缓存、文件、审计和搜索继续分区。它不是关闭租户字段或 QueryFilter。

7. 状态守卫时机

正常 Application/User/HostMap/Header/Path/Default 路径解析后:

  1. Middleware 先 BeginScope(new CurrentTenant(...));
  2. 非 Override/Debug 才调用 TenantStatusGuard;
  3. Guard 只允许 Active 或 GracePeriod;
  4. Failure 写 Problem Details 并短路;
  5. finally Dispose 作用域。

System 调用者在 Guard 内直接放行。这要求系统工作先从可信 owner 建立租户作用域,而不是把消息内任意 TenantId 当作系统特权。

8. 冲突与缺失的正确处理

当前 Resolver 是“首个有效值”,不会同时比较 Claim、Host、Header 和 Path 的冲突。Claim 优先可以阻止提示覆盖,但域名 tenant-b + Claim tenant-a 不会主动产生冲突错误,而是继续使用 tenant-a。

对于要求域名强绑定的 SaaS Host,建议在解析结果后增加一致性策略:认证主体、Host 映射和显式路由只要同时存在且不一致就拒绝,并记录脱敏安全审计。该策略目前不是源码已交付行为。

9. 测试与全局扫尾

仓库已有 TenantResolverTests 覆盖优先级、Header/Path 防伪、Host 映射和默认回退;TenantResolutionMiddlewareTests 覆盖作用域安装、Guard 短路和 Header 快照。

仍应补:

  • Root/Debug 目标不存在或停用时拒绝;
  • Claim 与 Host 冲突的产品语义;
  • Host Map 缓存失效与重复 Host;
  • 非雪花格式在所有入口一致拒绝;
  • 未注册默认租户在生产与测试环境的差异。
Terminal window
# 解析来源和注册顺序必须一起审查;预期正好八个 Step。
rg -n "new .*Tenant.*Step|class .*Tenant.*Step" \
src/Hosts/BitzOrcas.Api/Composition src/Framework/BitzOrcas.Application/Tenancy -g '*.cs'
# 当前 Override/Debug 会跳过 Guard;该条件变化时必须更新测试和手册。
rg -n "rootOperatorOverrideTenantId is null && debugTenantId is null" \
src/Hosts/BitzOrcas.Api/Middleware/TenantResolutionMiddleware.cs

上一页:Multitenancy 概览 · 下一篇:上下文与持久化

100%

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