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
| Type | Mutability | Intended consumer |
|---|---|---|
CurrentTenant | immutable record | effective tenant, office, and impersonation audit fields |
ICurrentTenantAccessor | controlled mutation point | middleware, jobs, and privileged adapters |
ICurrentTenant | read-only | use cases, stores, caching, and audit |
ITenantContext | read-only compatibility projection | callers 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.
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.
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.
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.
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.
| Caller | Effective tenant | Automatic ORM tenant filter |
|---|---|---|
| User or Application | resolved tenant | enabled |
| System | explicit scope or principal tenant | enabled; status guard may be bypassed |
| Host without override | resolved principal/default value | bypassed |
| Host with operate-as | target tenant | still 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
[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
# 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