Skip to content
bitzorcas
中EN

Guide

Multitenancy background work and tenant-owned resources

Carry a trusted tenant boundary through jobs, consumers, exports, cache, object storage, settings, and auditing while detecting context divergence.

Last updated

HTTP middleware does not run inside Quartz, a CAP consumer, or a queue worker. A background handler must reconstruct its tenant from authoritative business ownership rather than hoping an ambient tenant is present.

1. Background trust chain

Cross-tenant queue

Load parent or owner

Validate tenant and status

Enter one-tenant scope

Database / cache / file / search

Audit effective and actor facts

A TenantId in the message can be trusted only when the producer is trusted, message integrity is protected, and the consumer can verify ownership. A stronger design carries the aggregate or parent-job identifier and resolves the actual owner from a Store.

2. Complete consumer boundary

Reconstruct the scope from message ownership
public async Task ConsumeAsync(OrderPaidMessage message, CancellationToken cancellationToken)
{
// Resolve ownership from the order source of truth, not a mutable message field.
var owner = await orderOwnership.FindAsync(message.OrderId, cancellationToken);
if (owner is null || !TenancyDefaults.IsValid(owner.TenantId))
{
throw new InvalidOperationException("Order has no trusted tenant owner.");
}
// Production processing should reject an unknown or non-serviceable tenant.
await tenantGuard.EnsureActiveAsync(owner.TenantId, cancellationToken);
// The nested scope propagates one snapshot and restores its parent after processing.
using var tenantScope = tenantAccessor.BeginScope(new CurrentTenant(owner.TenantId));
await orderApplication.ConfirmPaymentAsync(message.OrderId, cancellationToken);
}

Idempotency keys also need tenant ownership, for example order-paid:{tenantId}:{messageId}, unless MessageId is proven globally unique across the platform.

3. Existing Workflow Timer behavior

WorkflowTimerJobExecutionScope resolves TenantId from the parent workflow instance, not from the queue row. It pushes IPersistenceExecutionContextAccessor, backs up the SqlSugar ITenantEntity filter, installs the parent tenant, and restores the original filter in finally.

Current workflow-timer execution semantics
var tenantId = await tenantResolver.ResolveTimerJobTenantAsync(
job.InstanceId,
cancellationToken);
if (!TenancyDefaults.IsValid(tenantId))
{
throw new InvalidOperationException("Workflow timer has no trusted tenant context.");
}
// The current implementation pushes persistence context, not ICurrentTenantAccessor.
using var contextScope = persistenceContext.PushTenant(new CurrentTenant(tenantId));
sqlSugarClient.QueryFilter.ClearAndBackup<ITenantEntity>();
try
{
// Replace the anonymous queue-scan filter with the parent workflow tenant.
sqlSugarClient.QueryFilter.AddTableFilter<ITenantEntity>(x => x.TenantId == tenantId);
await operation(cancellationToken);
}
finally
{
sqlSugarClient.QueryFilter.Restore();
}

4. Export jobs use an explicit tenant

Export scheduling uses ExportRequest.TenantId for lookup, deduplication, persistence, and an object key shaped as:

exports/{tenantId}/{guid}_{fileName}

ScopedExportJobExecutionScope creates a fresh DI scope per job, but it does not install CurrentTenant. It copies job.TenantId into a new request. This explicit-parameter pattern can be safe only when every downstream port uses that parameter and no Builder assumes an ambient tenant.

Keep export data access explicitly tenant-owned
public async IAsyncEnumerable<InvoiceRow> ReadAsync(
ExportRequest request,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
// The request came from a claimed persisted job; the query repeats its TenantId boundary.
var invoices = store.StreamByTenantAsync(request.TenantId, cancellationToken);
await foreach (var invoice in invoices.WithCancellation(cancellationToken))
{
// No client-supplied TenantId is accepted at this data boundary.
yield return InvoiceRow.From(invoice);
}
}

5. Three cache-key patterns

ImplementationTenant sourceCurrent behavior
DocumentCacheServiceexplicit method argumentdocs:doc:{tenantId}:{documentId}
PersistentSettingsManagerexplicit argument in Global-scope partsincludes the setting key and real tenant or 0
general CacheKeyBuilder Tenant scopeICurrentUserAccessor.Current.TenantIdignores Effective CurrentTenant

The third pattern is normally correct for a regular user but retains the operator’s home tenant during tenant impersonation. A database query can switch to the target while the cache remains under the original tenant.

Cache and persistence must share the effective tenant
using var tenantScope = tenantAccessor.BeginScope(impersonatedTenant);
// Expected target is tenant-b; the current builder may still read tenant-a from CurrentUser.
var key = cacheKeyBuilder.Build(
area: "orders",
scope: CacheScope.Tenant,
"summary",
orderId);
key.Value.Should().Contain("tenant-tenant-b");
// The negative assertion prevents a home-tenant cache entry from looking valid.
key.Value.Should().NotContain("tenant-tenant-a");

The current implementation cannot satisfy this assertion. Tenant cache scope should use ICurrentTenant.EffectiveTenantId, with a documented fallback only when that context is unavailable.

6. Object-storage boundary

IFileStorage states that object keys must include tenant scope, and FileContainerOptions.IsMultiTenant defaults to true. Local and S3 adapters still accept arbitrary keys and do not prepend a tenant automatically.

Centralize construction of tenant-owned object keys
public string BuildAttachmentKey(string tenantId, string aggregateId, string fileName)
{
// Tenant ownership comes from trusted context; filenames are normalized separately.
if (!TenancyDefaults.IsValid(tenantId))
throw new InvalidOperationException("A trusted tenant is required.");
var safeName = fileNameSanitizer.Normalize(fileName);
// Endpoints accept a file identifier, never a caller-provided full object key.
return $"attachments/{tenantId}/{aggregateId}/{Guid.CreateVersion7():N}_{safeName}";
}

The Local adapter prevents path traversal outside its base directory. The S3 adapter passes the key to the SDK. Neither proves that a key belongs to the current tenant. A download endpoint must first load a tenant-owned FileAsset and then sign the server-stored key.

7. Search, reports, and audit

  • Search documents store TenantId and every ordinary query adds it as a filter.
  • Report facts and materialized summaries partition or index by TenantId.
  • Audit records EffectiveTenantId and, for impersonation, ActorTenantId, operator, and GrantId.
  • Retention and archive jobs receive an explicit TenantId and do not silently fall back to "0".

AuditRetentionJobExecutor currently uses "0" when ITenantContext is absent. Without a scheduler that enumerates tenants and installs each context, the executor processes only the global marker. This needs a scheduler-level contract test.

8. Resource acceptance table

ResourceMinimum proof
Consumersame business id in tenant-a and tenant-b never crosses; unknown owner is dead-lettered
Workflow TimerCurrentTenant, persistence context, and SqlSugar filter are identical
ExportJob Store, Builder query, object key, and download owner use one TenantId
Cachenormal and impersonated requests build and invalidate by EffectiveTenantId
Fileanother tenant’s object key cannot produce a signed URL
Search and reportuser queries always filter; global Host queries use a dedicated audited port
Retentionruns per tenant and reports deletion counts without default-tenant masking

Back: Tenant switching and impersonation · Next: Testing and operations

100%

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