租户解析链只做一件事:从已经装配的候选证据中选择第一个非空、非 "0" 的租户标识。它不证明租户存在,不验证雪花格式,也不自动证明调用者拥有目标租户权限。
1. 精确优先级与 HTTP 接线
| 顺序 | Step | HTTP 当前输入 | 信任与限制 |
|---|---|---|---|
| 1 | SystemJob | Middleware 不设置 IsSystemJob/SystemJobTenantId;可读取已有 Accessor Scope | 主要供非 HTTP 或外层作用域 |
| 2 | RootOperatorOverride | Host 的 X-Operate-As-Tenant 或 Development 的 X-Tenant-Debug | 只校验调用者类型/环境与非 0 |
| 3 | ApplicationCaller | 已认证 Application TenantId | 认证主体事实 |
| 4 | UserClaim | 已认证 User TenantId | 认证主体事实 |
| 5 | HostSubdomain | Request.Host.Host + HostTenantMap | 映射来自 PlatformTenant 全局表 |
| 6 | Header | X-Tenant | 只在等于已认证主体 TenantId 时返回 |
| 7 | Path | URL 第一段 | 只在等于已认证主体 TenantId 时返回 |
| 8 | SingleTenantDefault | 无输入 | 固定 1000001 |
RootOverride 早于 Application/User Claim,因此已认证 Host 可以覆盖;普通 User 或 Application 的 X-Operate-As-Tenant 在 Middleware 装配阶段被忽略。
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 当前主要是“与认证事实一致时确认”,而不是独立选择来源。
[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(不含端口)精确查字典键。
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 路径解析后:
- Middleware 先
BeginScope(new CurrentTenant(...)); - 非 Override/Debug 才调用
TenantStatusGuard; - Guard 只允许 Active 或 GracePeriod;
- Failure 写 Problem Details 并短路;
- 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;
- 非雪花格式在所有入口一致拒绝;
- 未注册默认租户在生产与测试环境的差异。
# 解析来源和注册顺序必须一起审查;预期正好八个 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