ADR-011 — Domain/subdomain tenant resolution (opt-in) + host-vs-token cross-check
Date: 2026-06-22
Status: Accepted
Extends: ADR-009 (row-level, claim-based) and ADR-010 (1→N membership + active-tenant selection).
Pairs with: DevPack ADR-007 (the universal plumbing — contributors, options, glue middleware).
Reference: patterns ported from ITLab.WorkGuardian (ABP-style), adapted to the canon.
Context
The canon resolves the tenant from the tenant claim (ADR-009). The roadmap's Phase 2 makes resolution from the request host (acme.app.example.com → "acme") a first-class, config-activatable path — needed so a token issued for tenant A cannot be replayed against tenant B's host, and as the groundwork for pre-auth per-tenant authentication (Phase 3).
The DevPack 10.6.0 ships the universal pieces (DevPack ADR-007): DomainTenantResolveContributor, MultiTenancyOptions, AddDomainTenantResolver, and TenantResolutionMiddleware (UseDevPackTenantResolution). This ADR records what the template materializes on top.
Decision
1. Tenant carries a domain (still NOT IMultiTenant)
Tenant gains DomainName + NormalizedDomainName (canonical lower-invariant, via Tenant.NormalizeDomain). The domain is the subdomain label a host pattern extracts (acme), not a full URL. Create(name) defaults the domain to the normalized lower-cased name so a freshly created tenant is immediately resolvable; SetDomain(...) overrides it when the label differs from the name. TenantConfiguration maps both columns and adds a unique filtered index on NormalizedDomainName ([IsDeleted] = 0), mirroring the name index. Tenant stays host/global (not IMultiTenant) — it is the scope, not a scoped row.
2. Migration with backfill before the unique index
AddTenantDomainName adds the two NOT NULL columns (default ""), then backfills existing tenants (DomainName/NormalizedDomainName = LOWER(NormalizedName)) before creating the unique index — otherwise multiple pre-existing tenants would collide on the empty-string default. The seeded default tenant therefore resolves by its name slug out of the box.
3. TenantAccessValidator.GetTenantIdByDomainNameAsync
Implemented over the Tenant aggregate: IgnoreQueryFilters() + NormalizedDomainName == NormalizeDomain(domain) && IsActive && !IsDeleted. No cache — a domain→id IMemoryCache (30 min) is a valid optimization but needs invalidation on domain rename, so correctness-first keeps it a direct lookup. ValidateAccessAsync (membership, ADR-010) is unchanged.
4. Opt-in, default off
MultiTenancyOptions.EnableDomainValidation ships false in appsettings.json. Claim-based resolution remains the canon default; the dotnet new probe (no subdomains) and existing claim-only consumers are unaffected. DomainFormats ship as placeholders ({0}.localhost, {0}.app.example.com); ExcludedPaths lists the anonymous/pre-tenant surface (/health, /.well-known/jwks.json, /openapi, /scalar, /v1/auth).
5. Middleware order
app.UseDevPackTenantResolution() is wired after UseDevPackIdentityContext() (so the token tenant is on ICurrentUser) and before UseAuthorization(). Wrong order silently defeats the cross-check (token tenant reads as 0).
6. Login picks the host's tenant (domain-first selection)
ActiveTenantSelector (ADR-010) gains a domain-first branch: when EnableDomainValidation is on and the login host resolves to a tenant the user belongs to, that tenant becomes active — so signing in on acme.app.example.com lands you in acme. Falls through to the existing branches (keep current, else smallest membership id) when disabled or the host does not resolve.
Alternatives considered
- Add
DomainNametoCreateTenantCommand: deferred — deriving the domain from the name (override viaSetDomain) keeps the create API + SPA contract untouched while satisfying the required column. Promote to an explicit field if/when tenant self-service needs distinct slugs. - Cache domain→id: deferred (see Decision 3) — correctness over a micro-optimization with an invalidation tail.
- Glue middleware in the template: rejected — it is universal; it lives in the DevPack (DevPack ADR-007). The template only implements
ITenantAccessValidatorand wires config. - Default
EnableDomainValidation = true: rejected — would break the claim-only canon path and the probe.
Consequences
- Non-breaking for the claim-based path: with domain validation off, the middleware is a no-op and nothing changes.
- When on, a host-vs-token mismatch is
401, an unknown host is404, and a non-member is403. - Tests:
DomainResolutionTests(host resolves + match → 200; cross-check mismatch → 401; excluded path → 200) +DomainResolutionDisabledTests(opt-in default keeps claim-based) +TenantTests(domain default /SetDomain). The DevPack unit tests cover the middleware branch matrix. - InMemory caveat: the InMemory provider does not enforce the filtered unique index; uniqueness is exercised against SQL Server, not in integration tests.
References
- ADR-009, ADR-010, DevPack ADR-007.
Domain/Tenants/Tenant.cs,Persistence/Configurations/TenantConfiguration.cs,Persistence/Migrations/*_AddTenantDomainName.cs.Infrastructure/Identity/TenantAccessValidator.cs,Infrastructure/Identity/ActiveTenantSelector.cs,Infrastructure/DependencyInjection.cs,Api/Program.cs,Api/appsettings.json.