Pular para o conteúdo principal
Versão: main (dev) 🚧

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 DomainName to CreateTenantCommand: deferred — deriving the domain from the name (override via SetDomain) 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 ITenantAccessValidator and 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 is 404, and a non-member is 403.
  • 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.