Skip to content
bitzorcas
中EN

Concept

Multitenancy CurrentTenant and persistence isolation

Learn immutable tenant snapshots, nested AsyncLocal scopes, persistence context, dual-ORM filters, write ownership, and the current Host bypass.

Last updated

Resolution produces a value. Isolation depends on carrying that value unchanged through an asynchronous operation and applying it to both reads and writes. BitzOrcas uses an immutable CurrentTenant snapshot and a controlled accessor for that propagation.

1. Interface roles

TypeMutabilityIntended consumer
CurrentTenantimmutable recordeffective tenant, office, and impersonation audit fields
ICurrentTenantAccessorcontrolled mutation pointmiddleware, jobs, and privileged adapters
ICurrentTenantread-onlyuse cases, stores, caching, and audit
ITenantContextread-only compatibility projectioncallers not yet migrated to ICurrentTenant

Business services should not inject the accessor merely to switch tenants. They normally read ICurrentTenant.Tenant.EffectiveTenantId. Only a trusted boundary should install or restore a scope.

2. Snapshot invariants

A normal snapshot has a TenantId. A formal tenant-impersonation snapshot requires all four fields together:

  • ImpersonatorUserId;
  • OriginalTenantId;
  • TenantImpersonationGrantId;
  • TenantImpersonationExpiresAt.

EffectiveTenantId selects data. During impersonation, ActorTenantId retains the operator’s original tenant. ValidateInvariants() returns a boolean; construction does not throw automatically.

Build a snapshot from a validated token descriptor
var descriptor = tokenResult.GetValueOrThrow();
var tenant = new CurrentTenant(
TenantId: descriptor.TargetTenantId,
ImpersonatorUserId: long.Parse(descriptor.OperatorUserId, CultureInfo.InvariantCulture),
OriginalTenantId: descriptor.OperatorTenantId,
TenantImpersonationGrantId: descriptor.GrantId,
TenantImpersonationExpiresAt: descriptor.ExpiresAt);
// A partial impersonation tuple must never enter the runtime scope.
if (!tenant.ValidateInvariants())
{
throw new InvalidOperationException("Invalid tenant impersonation snapshot.");
}
// Grant and expiry checks must already have succeeded before this boundary.
using var scope = tenantAccessor.BeginScope(tenant);

This is the required connection between existing contracts. The HTTP Host does not currently implement the complete descriptor-to-claim-to-snapshot path.

3. Nesting and restoration

CurrentTenantAccessor.BeginScope saves the outer snapshot, installs the inner one, and can push the same snapshot into IPersistenceExecutionContextAccessor. Disposal restores persistence first and then the tenant accessor.

Verify nested scope semantics
using (tenantAccessor.BeginScope(new CurrentTenant("tenant-a")))
{
currentTenant.Tenant.EffectiveTenantId.Should().Be("tenant-a");
// The child scope flows across awaits and must restore tenant-a afterward.
using (tenantAccessor.Change("tenant-b", "Temporary diagnostics"))
{
currentTenant.Tenant.EffectiveTenantId.Should().Be("tenant-b");
}
// Disposing the child must restore its parent, not Unset or tenant-b.
currentTenant.Tenant.EffectiveTenantId.Should().Be("tenant-a");
}
currentTenant.Tenant.IsAvailable.Should().BeFalse();

Change(null) enters the Unset state and clears office and impersonation metadata. A non-null change preserves the current office and impersonation fields. The convenience method performs no authorization.

4. Persistence execution context

PersistenceExecutionContextAccessor carries current user, current tenant, correlation, and trace facts. TenantId prefers an available CurrentTenant and otherwise falls back to CurrentUser.TenantId.

Request resolution or job owner

CurrentTenant accessor

Persistence execution context

ICurrentTenant projection

SqlSugar filter and AOP

EF Core DbContext filter

Tenant-aware resource adapters

Every host must ensure that both branches receive the same snapshot. A background scope that pushes only persistence context can make SqlSugar correct while a business service reading ICurrentTenant sees no tenant.

5. EF Core query filtering

Generated model configuration combines tenant and soft-delete expressions with logical AND:

!IsDeleted && (IsTenantFilterBypass || entity.TenantId == TenantFilterValue)

TenantFilterValue reads ICurrentTenant first, then the compatibility context, then CurrentUser. DbContext must remain scoped; a long-lived context can retain a tenant snapshot across operations.

The include-soft-deleted repository path calls IgnoreQueryFilters() and then explicitly restores TenantId == TenantFilterValue for tenant entities.

6. SqlSugar query filtering

SqlSugar registers the soft-delete filter and resolves TenantId from persistence execution context. SqlSugarTenantFilterInitializer adds an ITenantEntity filter for normal callers.

The include-soft-deleted repository path clears filters and explicitly restores a tenant predicate when the model implements ITenantEntity.

7. Query filters do not stamp writes

TenantAggregateRoot and TenantEntityBase initialize TenantId to "0". Query filters control reads; they do not assign the current tenant to a new model.

Separate trusted ownership from client input
public static class TenantErrors
{
public static readonly Error Required =
Error.Forbidden("Tenant.Required", "A trusted tenant scope is required.");
}
public async Task<Result<string>> InsertAsync(
CreateDocumentRequest request,
CancellationToken cancellationToken)
{
var tenantId = currentTenant.Tenant.EffectiveTenantId;
if (!TenancyDefaults.IsValid(tenantId))
{
return Result.Failure<string>(TenantErrors.Required);
}
var document = new DocumentAggregate(idGenerator.NewId())
{
// Never assign request.TenantId; ownership comes from the trusted runtime scope.
TenantId = tenantId,
};
// The aggregate carries trusted ownership before the repository persists it.
await documents.AddAsync(document, cancellationToken);
return Result.Success(document.Id);
}

A database constraint or write-contract test should also reject blank and default-placeholder ownership.

8. Current Host bypass

EF Core IsTenantFilterBypass and SqlSugar isHostBypass both check only whether the authenticated caller is Host. Neither tests whether operate-as installed a target tenant.

CallerEffective tenantAutomatic ORM tenant filter
User or Applicationresolved tenantenabled
Systemexplicit scope or principal tenantenabled; status guard may be bypassed
Host without overrideresolved principal/default valuebypassed
Host with operate-astarget tenantstill bypassed

Comments describe target filtering for operate-as, but executable predicates do not implement it. The manual follows executable code.

The repair acceptance test must insert tenant-a and tenant-b rows in both providers, enter Host operate-as tenant-a, and assert that an ordinary Store returns tenant-a only. Truly global Host queries should use a separate, authorized, audited port.

9. Dual-provider contract

Operate-as must narrow the Host data surface
[Theory]
[InlineData("EfCore")]
[InlineData("SqlSugar")]
public async Task Host_OperateAs_Should_Filter_To_Target_Tenant(string provider)
{
// The same business key in both tenants proves TenantId is the isolating predicate.
await SeedSameKey(provider, tenantId: "tenant-a");
await SeedSameKey(provider, tenantId: "tenant-b");
using var userScope = persistenceContext.PushUser(AuthenticatedHost("operations"));
using var tenantScope = tenantAccessor.BeginScope(new CurrentTenant("tenant-a"));
// An ordinary owner store must not inherit a platform-wide Host bypass.
var rows = await ResolveStore(provider).ListAsync(CancellationToken.None);
rows.Should().OnlyContain(x => x.TenantId == "tenant-a");
}

This test should fail against the current implementation. That is why it belongs in the GA gate.

10. Review commands

Terminal window
# Every global-filter bypass needs an explicit tenant predicate or system-level justification.
rg -n "IgnoreQueryFilters|QueryFilter\.(Clear|ClearAndBackup)|ClearFilter" \
src/Framework src/Platform -g '*.cs'
# Tenant models default to zero; review every creation path for trusted ownership assignment.
rg -n "TenantId\s*=|TenantId \{ get; set; \}" \
src/Platform src/Framework -g '*.cs'

Back: Tenant resolution chain · Next: Tenant switching and impersonation

100%

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