Skip to content
bitzorcas
中EN

Concept

Multitenancy tenant resolution chain

Examine the eight resolution steps, source trust, spoofing resistance, host-map caching, default fallback, and conflict behavior.

Last updated

Tenant resolution answers one narrow question: which tenant snapshot should this execution use? It does not prove that the caller is allowed to act, that the tenant exists, or that the tenant may currently be served.

1. Request evidence assembled by the Host

TenantResolutionMiddleware converts HTTP-specific facts into TenantResolutionContext:

  • authenticated caller type, tenant, user, application, and office;
  • Request.Host.Host without the port;
  • request path and headers;
  • the cached host-to-tenant map;
  • Host operate-as and Development-only debug overrides.

The framework resolver does not reference HttpContext. This separation keeps priority logic testable and reusable for non-HTTP hosts.

HTTP request

Host adapter

TenantResolutionContext

TenantResolver

First valid TenantId

CurrentTenant scope

Tenant status guard

2. Exact precedence

PriorityStepEvidenceTrust rule
1SystemJobexplicit job field, then ambient contextcaller must be a trusted system boundary
2RootOperatorOverrideHost override or Development debugHost type or environment gate
3ApplicationCallerauthenticated application tenantauthentication fact
4UserClaimauthenticated user tenantauthentication fact
5HostSubdomainexact host-map matchplatform tenant registry
6HeaderX-Tenantconfirmation only
7Pathfirst non-empty segmentconfirmation only
8SingleTenantDefault1000001compatibility fallback

TenantResolver evaluates registered steps in order and stops at the first value for which TenancyDefaults.IsValid returns true.

3. User and application evidence

Application callers are evaluated before users. This matters when an authentication scheme can expose both identities: the application identity wins by design. A normal user request takes its tenant from the authenticated user claim.

Header and path are confirmation sources
var context = new TenantResolutionContext
{
// Authentication established tenant-a; transport hints may only confirm it.
AuthenticatedCaller = AuthenticatedUser(tenantId: "tenant-a"),
Headers = new Dictionary<string, string>
{
["X-Tenant"] = "tenant-b",
},
Path = "/tenant-b/orders",
};
var resolved = await resolver.ResolveAsync(context, cancellationToken);
// Both forged hints are ignored; the authenticated tenant remains authoritative.
resolved.Value.Should().Be("tenant-a");

The header and path steps return a value only when it equals the authenticated caller tenant. Anonymous requests cannot select a tenant through either source.

4. Path parsing is allocation-conscious

PathTenantStep examines the first non-empty segment with Span operations. It does not use Split, regular expressions, or LINQ. This is a hot-path implementation constraint protected by architecture tests.

The first segment remains a confirmation hint, not a general route value:

PathAuthenticated tenantStep result
/tenant-a/orderstenant-atenant-a
/tenant-b/orderstenant-ano result
///tenant-a/orderstenant-atenant-a
/tenant-aanonymousno result

5. Host-to-tenant map

HostTenantMapProvider loads all global PlatformTenant rows with a non-empty Subdomain. It builds a case-insensitive dictionary and caches it under tenancy:host-tenant-map for ten minutes.

Matching uses the exact Request.Host.Host value. Port is omitted, but no suffix, wildcard, or public-suffix normalization is performed.

Invalidate the host map after a tenant domain change
public async Task<Result> ChangeSubdomainAsync(
ChangeTenantSubdomain command,
CancellationToken cancellationToken)
{
var tenant = await tenantStore.GetAggregateAsync(command.TenantId, cancellationToken);
tenant.ChangeSubdomain(command.Subdomain);
// Save the source of truth before invalidating its derived host map.
await tenantStore.SaveAsync(tenant, cancellationToken);
// All instances must observe invalidation when the cache is distributed.
await hostTenantMapProvider.InvalidateAsync(cancellationToken);
return Result.Success();
}

The provider exposes invalidation, but correctness still depends on every subdomain mutation calling it. In a multi-instance deployment, the backing cache and invalidation must be shared or broadcast.

6. Privileged overrides

X-Operate-As-Tenant is captured only for an authenticated Host caller. X-Tenant-Debug is captured only when the host environment is Development. Both feed the same priority-two step.

The middleware currently skips TenantStatusGuard whenever either value is present. The step validates only with TenancyDefaults.IsValid, so an arbitrary non-empty, non-"0" value can become the effective tenant.

7. Fallback behavior

If no earlier step returns a valid value, the resolver returns the fixed default 1000001. This supports single-tenant compatibility, but it can also turn missing authentication or host mapping into a valid tenant silently.

For a strict multi-tenant deployment, choose and enforce one policy:

  • fail resolution when no trusted source succeeds;
  • enable the default only in an explicit single-tenant profile;
  • or prove through configuration validation that the default tenant exists and is serviceable.

8. Conflicts and ambiguity

The current algorithm is priority-based, not conflict-rejecting. A trusted user claim for tenant-a and a host map for tenant-b resolves to tenant-a because the user step runs first. That may be desirable for shared API hosts, but custom-domain products may instead require the two facts to agree.

A stricter host-and-identity consistency policy
var claimedTenant = currentUser.Current?.TenantId;
var mappedTenant = hostTenantMap.GetValueOrDefault(request.Host.Host);
// Authenticated traffic on a tenant-specific domain must agree with that domain.
if (TenancyDefaults.IsValid(claimedTenant)
&& TenancyDefaults.IsValid(mappedTenant)
&& !StringComparer.Ordinal.Equals(claimedTenant, mappedTenant))
{
// Reject the request before any tenant-owned repository can run.
return Results.Problem(
statusCode: StatusCodes.Status403Forbidden,
title: "Tenant context conflict");
}

This is a product policy example, not behavior currently implemented by TenantResolver.

9. Status guard behavior

After installing the snapshot, the middleware calls ITenantGuard.EnsureActiveAsync unless the request is a Root override or Debug request. The Identity implementation:

  • permits Active and GracePeriod;
  • denies unknown and every other lifecycle state;
  • permits System callers without store lookup;
  • preserves an in-memory compatibility exception for unregistered 1000001.

Do not confuse TenantTransitionGuard with TenantStatusGuard. The former validates lifecycle writes; the latter validates whether a tenant can receive traffic.

10. Resolution tests that matter

Tests should cover more than step order:

  1. forged header and path cannot replace an authenticated tenant;
  2. Host map is exact and case-insensitive;
  3. subdomain mutation invalidates all application instances;
  4. unknown, suspended, and deactivated override targets are denied;
  5. Development debug is absent from non-Development hosts;
  6. no trusted source fails in strict multi-tenant mode;
  7. ambiguous host and identity facts follow the selected product policy.

11. Source review

Terminal window
# Registration order is part of runtime behavior.
rg -n "AddScoped<ITenantResolutionStep" \
src/Hosts/BitzOrcas.Api/Composition/CoreRuntimeRegistration.cs
# Review every source and the first-valid resolver rule.
rg -n "class .*TenantResolutionStep|TenancyDefaults.IsValid|ResolveAsync" \
src/Framework/BitzOrcas.Application -g '*.cs'
# Review privileged header capture and status-guard bypass together.
rg -n "X-Operate-As-Tenant|X-Tenant-Debug|EnsureActiveAsync" \
src/Hosts/BitzOrcas.Api/Middleware/TenantResolutionMiddleware.cs

Back: Multitenancy · Next: CurrentTenant and persistence

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%