Skip to content
bitzorcas
EN

Concept

Multitenancy deep dive

How tenancy is the default in every layer — the 8-level ITenantResolver chain, TenantEntityBase auto-isolation, TenantResolutionMiddleware, and tenant context propagation.

Last updated

Multitenancy in BitzOrcas.Modern is a platform property, not a module. Every entity that extends TenantEntityBase is tenant-aware by default. Every query automatically filters by tenant. Every audit row records which tenant it belongs to. You opt out with explicit design, not in.

This page explains the 8-level tenant resolution chain, how data isolation works, and the rules for background job tenant context.

Three layers, one default

LayerWhereWhat enforces it
HTTP requestTenantResolutionMiddleware8-level ITenantResolver chain resolves tenant context
Data accessTenantEntityBaseSqlSugar global tenant filter on every query
Background workSystemJobTenantContextCaptures tenant at enqueue; restores before job runs

You don’t write WHERE tenant_id = @tenantId in your queries. The filter is on every tenant entity, every time.

Layer 1 — 8-level tenant resolution

The ITenantResolver chain resolves the tenant through 8 ordered steps:

OrderStepSourcePurpose
1SystemJobStepHard-coded system tenantBackground jobs without HTTP context
2RootOverrideStepExplicit override for operatorsRoot admin cross-tenant operations
3ApplicationCallerStepCallerType.ApplicationService-to-service calls
4UserClaimStepJWT tenant claimAuthenticated user’s tenant
5HostSubdomainStepSubdomain parsing{tenant}.app.example.com routing
6HeaderStepX-Tenant-Id headerAPI gateway / proxy forwarding
7PathStepURL path prefix/api/tenants/{tenantId}/...
8SingleTenantDefaultStepConfig fallbackSingle-tenant deployments

The TenantResolutionMiddleware runs the chain and sets ITenantContext.TenantId for the request. First matching step wins — later steps are skipped.

// In TenantResolutionMiddleware
var tenantId = await _tenantResolver.ResolveAsync(httpContext, cancellationToken);
if (tenantId is not null)
{
_tenantContext.TenantId = tenantId;
}

Layer 2 — Data isolation

TenantEntityBase

Every tenant-scoped entity inherits from TenantEntityBase, which extends BizEntityBase with a TenantId property:

public abstract class TenantEntityBase : BizEntityBase
{
public string TenantId { get; set; } = default!;
}

BizEntityBase provides audit fields: CreatorId, CreationTime, LastModifierId, LastModificationTime.

SqlSugar’s global filter automatically applies TenantId filtering on all TenantEntityBase-derived entities. A query like db.Queryable<NoteEntity>().ToListAsync() automatically becomes WHERE TenantId = @currentTenantId.

The entity hierarchy

EntityBase → Id, Version (optimistic concurrency)
└── BizEntityBase → + audit fields (Creator/CreationTime/Modifier/ModificationTime)
└── TenantEntityBase → + TenantId (automatic isolation)
└── TenantSoftDeleteEntityBase → + IsDeleted (soft delete)

Soft delete

TenantSoftDeleteEntityBase adds IsDeleted. The global query filter hides soft-deleted rows by default. Use IgnoreQueryFilters() for explicit trash/list-deleted queries.

Cross-tenant queries (rare, gated)

When you need to query across tenants (admin audit trails, operator reports):

// 1. Explicit permission check first
var allowed = await _authorizationService.HasPermissionAsync(
userId, "audit.view-cross-tenant", cancellationToken);
if (!allowed) throw new ForbiddenException("...");
// 2. Bypass filter and re-scope explicitly
return await db.Queryable<AuditLogEntity>()
.IgnoreFilter()
.Where(a => a.TenantId == requestedTenantId)
.ToListAsync(cancellationToken);

Gate with permission, bypass filter deliberately, re-filter explicitly — never return rows for all tenants.

Layer 3 — Background job tenant context

The BitzOrcas.JobHost runs Quartz.NET jobs on threads with no HttpContext. The tenant context from the originating request must be explicitly propagated.

SystemJobTenantContext provides the system tenant for all background jobs:

// In JobHost Core Runtime registration
builder.Services.AddSingleton<ITenantContext, SystemJobTenantContext>();

For jobs that need a specific tenant (e.g., audit retention per tenant), the tenant ID is stored as job data and restored before execution:

// Storing tenant context at enqueue time
var jobData = new JobDataMap { { "TenantId", tenantId } };
await _scheduler.ScheduleJob(job, trigger);
// Restoring at execute time
var tenantId = context.JobDetail.JobDataMap.GetString("TenantId");
_tenantContext.TenantId = tenantId;

Tenant context propagation to OTel

The RequestAuditMiddleware enriches every Activity with business context tags:

TagSource
bitzorcas.tenant.idITenantContext.TenantId
bitzorcas.caller.typeCurrentUser.CallerType
bitzorcas.correlation.idICorrelationContext.CorrelationId
enduser.idCurrentUser.UserId (human users only)

These tags appear in every trace span and log entry, making cross-tenant debugging straightforward.

What tenancy is NOT

  • Tenant isolation is not a security boundary against compromised code. A bug that calls IgnoreFilter() returns cross-tenant data. Architecture tests catch obvious cases; code review catches the rest.
  • Tenant context is not propagated to outbound HTTP calls automatically. Attach the tenant ID explicitly to outbound requests.
  • Tenant identity is not user identity. A user can belong to multiple tenants. Use ICurrentUser.GetUserId() for user identity; use ITenantContext.TenantId for the resolved tenant.