Multitenancy is not a TenantId column added to every table. It is a continuous trust chain: a boundary selects an effective tenant, a guard decides whether that tenant may be served, one context carries the decision into persistence and auditing, and every background or resource adapter must preserve the same ownership.
1. Where the capability lives
| Source area | Main types | Responsibility |
|---|---|---|
| Domain | TenantId, TenancyDefaults, ITenantEntity | Stable identifiers and tenant-owned model contracts |
| Application | TenantResolver, eight resolution steps, ITenantGuard | Transport-neutral resolution and serviceability protocol |
| Application Abstractions | CurrentTenant and its read/accessor interfaces | Immutable snapshot and nested asynchronous scopes |
| API Host | tenant resolution and impersonation middleware | Maps HTTP evidence into the runtime scope |
| Infrastructure | persistence execution context and ORM query filters | Applies the effective tenant to reads and audit data |
| Identity | PlatformTenant, status guard, host map, grants, token service | Tenant lifecycle and privileged-operation facts |
There is no single Platform/Multitenancy project. Reviewing only one layer misses the security behavior.
2. HTTP execution path
Resolution runs after authentication so it can use a trusted CurrentUser. Impersonation validation runs after resolution so it can inspect the installed tenant snapshot. Authorization and the business endpoint run later.
3. The eight-step resolver
Dependency registration defines the priority:
SystemJobTenantResolutionStep: an explicit system-job value or ambient scope;RootOperatorOverrideTenantStep: a Host override or development debug value;ApplicationCallerTenantStep: an authenticated application tenant;UserClaimTenantStep: an authenticated user tenant;HostSubdomainTenantStep: exact host-to-platform-tenant mapping;HeaderTenantStep:X-Tenantonly when it confirms the authenticated tenant;PathTenantStep: first path segment only when it confirms that tenant;SingleTenantDefaultTenantStep: fixed fallback1000001.
The resolver returns the first value accepted by TenancyDefaults.IsValid. Today, valid means non-empty and not "0". Neither this method nor TenantId.Of proves identifier format, existence, or lifecycle status; the guard handles serviceability later.
Read Tenant resolution chain for precedence, spoofing resistance, host-map caching, and fallback semantics.
4. One tenant fact track
ICurrentTenantAccessor uses AsyncLocal<CurrentTenant>. A nested BeginScope restores its parent when disposed. Business code consumes the read-only ICurrentTenant interface; the legacy ITenantContext is a projection over the same accessor, not independent state.
var tenantId = await ownership.ResolveTenantAsync(message.AggregateId, cancellationToken);if (!TenancyDefaults.IsValid(tenantId)){ throw new InvalidOperationException("The message has no trusted tenant owner.");}
// BeginScope propagates one immutable snapshot and restores the outer scope on dispose.using var tenantScope = tenantAccessor.BeginScope(new CurrentTenant(tenantId));
// Repositories, tenant-aware resources, and audit code should now use this effective tenant.await handler.ProcessAsync(message, cancellationToken);BeginScope does no I/O, authorization, or status validation. The boundary must establish ownership before entering it.
5. Persistence isolation
Generated EF Core metadata combines tenant and soft-delete predicates. SqlSugar installs an ITenantEntity filter in its scope. Repository paths that include soft-deleted rows clear global filters and then restore an explicit tenant predicate.
| Provider | Default read | Include-soft-deleted path |
|---|---|---|
| EF Core | bypass or TenantId == TenantFilterValue | IgnoreQueryFilters, then explicit tenant predicate |
| SqlSugar | tenant filter plus soft delete | clears filters, then explicit tenant predicate |
Query filters do not stamp TenantId on new aggregates. Creation must assign ownership from a trusted context, never from a client field.
Read CurrentTenant and persistence.
6. Host override is not formal impersonation
X-Operate-As-Tenant is accepted only for an authenticated CallerType.Host. X-Tenant-Debug works only in Development. Both currently install an ordinary CurrentTenant and skip TenantStatusGuard.
The formal model is different: it has a TenantImpersonationGrant, a token capped at two hours, an operator tenant, GrantId, and independent expiry. Middleware checks expiry and active grant state on each impersonated request.
Both EF Core and SqlSugar currently bypass tenant filtering for every authenticated Host caller. Changing the EffectiveTenantId with operate-as does not narrow that data surface. This is a GA security boundary that requires dual-provider integration tests.
Read Tenant switching and impersonation.
7. Tenant status
The production TenantStatusGuard loads the tenant through ITenantStore. Only Active and GracePeriod are serviceable. Unknown, Suspended, Expired, PendingProvisioning, and Deactivated tenants are denied. The in-memory store preserves a compatibility exception for an unregistered default tenant, and System callers bypass the guard.
Root override and Debug paths also skip the guard in the HTTP middleware. It is therefore inaccurate to claim that every resolved tenant is status-checked.
8. Jobs and tenant-owned resources
- Jobs and consumers must resolve ownership from a message parent or authoritative record before opening a scope.
- Workflow timers resolve the parent workflow tenant and replace the SqlSugar tenant filter for each job.
- Cache keys, file object keys, search documents, reports, and audit data need an explicit effective-tenant contract.
- The general
CacheKeyBuildercurrently derives Tenant scope fromCurrentUser.TenantId, notCurrentTenant.EffectiveTenantId. - System queues may scan across tenants, but each business operation must return to a single-tenant scope.
Read Background work and tenant-owned resources.
9. Chapter map
| Goal | Page |
|---|---|
| Learn precedence, confirmation sources, host maps, and fallback | Tenant resolution chain |
| Apply immutable context and verify dual-ORM filtering | CurrentTenant and persistence |
| Design privileged switching, grants, tokens, and audit | Switching and impersonation |
| Build jobs, cache, files, exports, search, and reports | Background work and resources |
| Establish isolation tests and production runbooks | Testing and operations |
10. Source review commands
# Review resolution, host middleware, tenant context, and both ORM providers together.rg -n "TenantResolver|TenantResolutionMiddleware|CurrentTenant|TenantFilter" \ src/Framework src/Hosts/BitzOrcas.Api src/Platform/Identity -g '*.cs'
# The current expected result shows Host bypass depending only on CallerType.Host.rg -n "IsTenantFilterBypass|isHostBypass|CallerType.Host" \ src/Framework/BitzOrcas.Infrastructure.EfCore \ src/Framework/BitzOrcas.Infrastructure.SqlSugar -g '*.cs'
# The current result has no issuing endpoint or complete claim reconstruction.rg -n "TenantImpersonationTokenDescriptor|TenantImpersonationGrantId" \ src/Hosts src/Platform/Identity -g '*.cs'11. Minimum GA evidence
- Every resolution source has a documented trust level and conflict rule.
- Host operate-as returns only the target tenant through both ORM providers.
- Override, Debug, and formal impersonation targets are checked for existence and serviceability.
- Formal impersonation closes issuance, claims, reconstruction, expiry, revocation, scope, and audit.
- Jobs, cache, files, search, reports, and settings pass same-key A/B isolation tests.
- Every global-filter bypass and system-wide operation is covered by architecture or contract tests.