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:
- Static configuration (
Multitenancy:DedicatedSingleTenant+Multitenancy:DefaultTenantId/TenantSource=StaticConfigurationinappsettings) — binds viaMicrosoft.Extensions.Options. Applies when DedicatedSingleTenant is enabled; skips JWT/header spoofing vectors for appliance installs. - JWT
tidwhenRequireTenantClaim=trueand DedicatedSingleTenant is false. - HTTP header
X-Tenant-Id, then optional host/subdomain strategy (bounded context opt-in). - 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 effectiveTOptionsfor the currentITenantContext.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 +
IOptionsMonitorpatterns inConnectSoft.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)¶
tidis issued for multi-tenant products; DedicatedSingleTenant may omit claim requirement.- Finbuckle Identity caveats on
Findbypassing 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(seeConnectSoft.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
ITenantContextrequire 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 MessageHeaders → MutableTenantContext 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.