Skip to content

ADR-0100: ConnectSoft SaaS multitenancy (resolution and options)

Field Value
Status Accepted (program scaffolding)
Date 2026-05-01
Supersedes

Context

ConnectSoft delivers ConnectSoft.Extensions.Saas.* packages (one repo per NuGet) and SaaS templates that must support SharedDb, DatabasePerTenant, ResidencySilo, and DedicatedSingleTenant installations. We do not adopt Finbuckle.MultiTenant; external docs serve only as a feature checklist (MultiTenant overview, Per-Tenant Options, Per-Tenant Authentication, Identity + EF).

SaaS microservices use NHibernate for persistence in this program; EF Core multitenant helpers are out of scope.

Decision

1. Canonical identifiers by layer

Layer Primary tenant token Notes
JWT / OIDC Claim tid Issued by authorization server; authoritative when RequireTenantClaim is true.
HTTP (edge / gateway) Header X-Tenant-Id Untrusted until gateway authenticates caller and/or strips/overwrites based on STS. Prefer forwarding after validation.
gRPC metadata tenant-id (ASCII key) Binary-safe transport key; aligns with tracing conventions. Do not use tid as metadata key name—reserve tid for claims.
Messaging (MassTransit / NServiceBus) Header tenant-id Serialized in envelope/metadata; mirrored to tid when bridging to JWT.

2. Resolution pipeline order

The tenant resolver evaluates deterministic hooks in this default order; products may constrain via TenantResolutionStrategy options:

  1. Static configuration (Multitenancy:DedicatedSingleTenant + Multitenancy:DefaultTenantId / TenantSource=StaticConfiguration in appsettings) — binds via Microsoft.Extensions.Options. Applies when DedicatedSingleTenant is enabled; skips JWT/header spoofing vectors for appliance installs.
  2. JWT tid when RequireTenantClaim=true and DedicatedSingleTenant is false.
  3. HTTP header X-Tenant-Id, then optional host/subdomain strategy (bounded context opt-in).
  4. Transport-specific extraction (SignalR handshake claims, MassTransit consume context, NSB pipeline behaviors, Orleans call context).

Tie-break: First successful non-empty normalized tenant wins; configuration branch excluding contradictory JWT when DedicatedSingleTenant is on (prefer config; JWT mismatch → fail closed in strict mode).

3. Per-tenant options (Finbuckle “Options” parity, no coupling)

  • ITenantOptionsProvider<TOptions> (or equivalent factory) returns effective TOptions for the current ITenantContext.TenantId, merging global defaults with tenant-specific overlays from configuration or future stores.
  • Caching: In-process cache keyed by (typeof(TOptions), tenantId) with optional TTL; document cache stampede and invalidation as v1 limitation.
  • DI: Prefer named options or factory + IOptionsMonitor patterns in ConnectSoft.Extensions.Saas.AspNetCore—no Finbuckle types.

4. Gateway trust boundary (ConnectSoft.ApiGatewayTemplate, not YARP)

  • The custom gateway is the trust anchor for external clients: it validates identity, then forwards tenant using server-side resolution (from token or signed internal context), not blind client header trust.
  • Threat — header spoofing: External callers must not set tenant alone; gateway copies tenant from validated token or internal policy. Document in template docs/.

5. Identity / Authorization server (NHibernate mental model)

  • tid is issued for multi-tenant products; DedicatedSingleTenant may omit claim requirement.
  • Finbuckle Identity caveats on Find bypassing global filters translate to: never use identity APIs that load by key without tenant predicate in shared-DB mode—application code must scope TenantId consistently (NHibernate filters + query specs).

6. Observability and compliance

  • Serilog: opt-in tenant log property from ITenantContext (see ConnectSoft.Extensions.Logging.Serilog).
  • Compliance: tenant IDs are classified; default redaction/masking in log pipeline when policy demands (see ConnectSoft.Extensions.Compliance).

Consequences

  • All SaaS templates and gateway code converge on ConnectSoft.Extensions.Saas.* contracts.
  • Breaking changes to ITenantContext require SemVer major and SAAS-EXT-T02 migration checklist updates.

Threat model (skim)

Risk Mitigation
Spoofed X-Tenant-Id from Internet clients Gateway validates identity; overwrites or ignores client header; services behind zero-trust mesh accept only gateway-trusted hops.
Leaked tid in logs Serilog opt-in property + Compliance redaction classifiers for tenant IDs.
Cross-tenant read/write in SharedDb NHibernate tenant filter + session EnableFilter; no repository method without tenant predicate in strict mode.
Cross-silo writes (ResidencySilo) Block in application layer + integration tests; document forbidden operations.

Appendix — Layer-1 primitives (catalog, connections, HTTP hints, messaging hydration)

The following shipped types extend ADR §3 (“per-tenant options”) and §2 (resolution surfaces) without adopting Finbuckle:

Primitive Role
ITenantStore / ConfigurationTenantStore Lookup ITenantInfo rows from MultitenancyOptions.TenantCatalog — optional CompositeTenantStore when chaining stores manually.
ITenantConnectionResolver / MultitenancyTenantConnectionResolver Resolve tenant id → connection string via TenantConnectionStrings (DatabasePerTenant hosts combine with NHibernate/session factory routing).
ITenantOptionsSink / DefaultTenantOptionsSink Process-local IMemoryCache cache for typed per-tenant payloads — TenantOptionsSinkOptions.SlidingExpiration; no cross-process invalidation in v1 — hosts document eviction/invalidation policies.
ITenantHttpResolutionContributor HostSubdomainHttpResolutionContributor (Order 10) + TenantHeaderHttpResolutionContributor (Order 100) supply HTTP hints before TenantResolutionInput is built — JwtTenantClaimType flows into TenantResolutionInput for DefaultTenantResolver.
TenantResolutionInput Cross-transport hint record — JwtTenantClaimType aligns JWT inspection with MultitenancyOptions.JwtTenantClaimType; HTTP HttpHeaderTenantId aggregates contributor output in Saas.AspNetCore.
TenantConsumeContextFilter MassTransit inbound header → MutableTenantContext when scoped DI exposes MutableTenantContext.
TenantHeaderIncomingBehavior NServiceBus inbound MessageHeadersMutableTenantContext when IIncomingPhysicalMessageContext.Extensions exposes IServiceProvider resolving MutableTenantContext.

ADR §3 referenced ITenantOptionsProvider<TOptions> as conceptual parity — the shipped surface is ITenantOptionsSink (typed cache CRUD). Prefer ITenantOptionsSink in new code.