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
| Layer | Where | What enforces it |
|---|---|---|
| HTTP request | TenantResolutionMiddleware | 8-level ITenantResolver chain resolves tenant context |
| Data access | TenantEntityBase | SqlSugar global tenant filter on every query |
| Background work | SystemJobTenantContext | Captures 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:
| Order | Step | Source | Purpose |
|---|---|---|---|
| 1 | SystemJobStep | Hard-coded system tenant | Background jobs without HTTP context |
| 2 | RootOverrideStep | Explicit override for operators | Root admin cross-tenant operations |
| 3 | ApplicationCallerStep | CallerType.Application | Service-to-service calls |
| 4 | UserClaimStep | JWT tenant claim | Authenticated user’s tenant |
| 5 | HostSubdomainStep | Subdomain parsing | {tenant}.app.example.com routing |
| 6 | HeaderStep | X-Tenant-Id header | API gateway / proxy forwarding |
| 7 | PathStep | URL path prefix | /api/tenants/{tenantId}/... |
| 8 | SingleTenantDefaultStep | Config fallback | Single-tenant deployments |
The TenantResolutionMiddleware runs the chain and sets ITenantContext.TenantId for the request. First matching step wins — later steps are skipped.
// In TenantResolutionMiddlewarevar 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 firstvar allowed = await _authorizationService.HasPermissionAsync( userId, "audit.view-cross-tenant", cancellationToken);if (!allowed) throw new ForbiddenException("...");
// 2. Bypass filter and re-scope explicitlyreturn 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 registrationbuilder.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 timevar jobData = new JobDataMap { { "TenantId", tenantId } };await _scheduler.ScheduleJob(job, trigger);
// Restoring at execute timevar tenantId = context.JobDetail.JobDataMap.GetString("TenantId");_tenantContext.TenantId = tenantId;Tenant context propagation to OTel
The RequestAuditMiddleware enriches every Activity with business context tags:
| Tag | Source |
|---|---|
bitzorcas.tenant.id | ITenantContext.TenantId |
bitzorcas.caller.type | CurrentUser.CallerType |
bitzorcas.correlation.id | ICorrelationContext.CorrelationId |
enduser.id | CurrentUser.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; useITenantContext.TenantIdfor the resolved tenant.
Related
- Module Overview — platform modules that use tenant isolation
- Auth Flow Diagram — visual flow showing tenant resolution in the middleware pipeline
- Dependency Injection — how tenant services are wired