Multi-Tenancy Integration Guide (.NET)
If you want the shortest working path first, start with Verified .NET Quickstart. This guide is the fuller package-by-package setup.
Follow the steps below. All packages are independent, so install only what you need.
Step 1: Install packages
Core package (always required):
dotnet add package PrimusSaaS.MultiTenancy
ASP.NET Core middleware (recommended for web APIs):
dotnet add package PrimusSaaS.MultiTenancy.AspNetCore
EF Core write enforcement (production databases):
dotnet add package PrimusSaaS.MultiTenancy.EFCore
In-Memory membership store (development and testing only):
dotnet add package PrimusSaaS.MultiTenancy.InMemory
Step 2: Configure Program.cs
Register the core service and ASP.NET Core accessor, then add the middleware in order:
using PrimusSaaS.MultiTenancy.DependencyInjection;
using PrimusSaaS.MultiTenancy.AspNetCore.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Core: registers resolver, resolution strategies, and options
builder.Services.AddPrimusMultiTenancy(options =>
{
builder.Configuration.GetSection("MultiTenancy").Bind(options);
});
// ASP.NET Core: registers HttpContext-backed accessor
builder.Services.AddPrimusMultiTenancyAspNetCore();
builder.Services.AddAuthentication(/* your scheme */);
builder.Services.AddAuthorization();
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
// Middleware order matters:
app.UsePrimusTenantResolution(); // resolves TenantContext from the request
app.UsePrimusTenantIsolation(); // blocks authenticated requests without a tenant (401)
app.UsePrimusTenantRateLimiting(); // optional: per-tenant rate limiting
app.UsePrimusTenantEntitlements(); // optional: enforces endpoint feature requirements by tier
app.UseAuthorization();
app.MapControllers();
app.Run();
Step 3: Configure appsettings.json
{
"MultiTenancy": {
"TenantClaimType": "tid",
"TenantHeaderName": "X-Tenant-Id",
"TenantRouteKey": "tenantId",
"EnableSubdomainResolution": false,
"RequireTenantOnAuthenticatedRequests": true,
"EnableTenantRateLimiting": true,
"RateLimitMaxRequests": 100,
"RateLimitWindow": "00:01:00"
}
}
Options reference
| Option | Type | Required | Default | Possible Values | Description | Edge Cases / Error Handling |
|---|---|---|---|---|---|---|
TenantClaimType | string | No | "tid" | Any non-empty string | JWT claim name used by the claims strategy | An empty string causes the claims strategy to skip silently — no tenant is resolved and no exception is thrown. |
TenantHeaderName | string | No | "X-Tenant-Id" | Any non-empty string | HTTP header name used by the header strategy | An empty string causes the header strategy to skip silently. |
TenantRouteKey | string | No | "tenantId" | Any identifier matching a route parameter | Route parameter name used by the route strategy | If the key is absent from the route template the strategy always skips. |
EnableSubdomainResolution | bool | No | false | true, false | Enables the subdomain strategy (read from Host header) | With no subdomain present (e.g., localhost) the strategy returns no result. Do not enable this in environments that do not use subdomain-per-tenant routing. |
SubdomainDevelopmentHosts | HashSet<string> | No | localhost, localtest.me | Root hosts | Allows two-part hosts like acme.localhost in development while preserving strict behavior for non-listed roots. | |
RequireTenantOnAuthenticatedRequests | bool | No | true | true, false | When true, isolation middleware returns 401 Unauthorized for authenticated requests with no resolved tenant | Set to false for public or single-tenant APIs. When false, UsePrimusTenantIsolation() is a no-op per request. |
EnableTenantRateLimiting | bool | No | true | true, false | Enables per-tenant rate limiting in TenantRateLimitMiddleware | When false, UsePrimusTenantRateLimiting() passes all requests through. See the warning below about in-process scope. |
RateLimitMaxRequests | int | No | 100 | Any integer ≥ 1 | Maximum requests per tenant per window | Values below 1 rate-limit every request immediately. Very large values (> 10,000) are accepted but reduce protective value. |
RateLimitWindow | TimeSpan | No | 00:01:00 | Any valid TimeSpan string (hh:mm:ss) | Sliding window duration | An invalid string in appsettings.json throws FormatException at startup. Very short windows (< 1 s) may behave unexpectedly under high concurrency. |
PerTierRateLimits | Dictionary<string, TenantRateLimitPolicy> | No | Empty | Tier names (free, pro, enterprise) | Tier-level fallback rate-limits when no per-tenant override exists. | |
TenantTierMetadataKey | string | No | "tier" | Any non-empty string | Metadata key read from tenant record to resolve tier policies. | |
DefaultTenantTier | string? | No | null | Tier name or null | Fallback tier when tenant metadata does not include TenantTierMetadataKey. | |
PerTierEntitlements | Dictionary<string, HashSet<string>> | No | Empty | Tier names -> feature keys | Tier-based feature allow-list used by UsePrimusTenantEntitlements() and RequirePrimusFeature(...). |
AddPrimusMultiTenancyAspNetCore() now auto-selects DistributedTenantRateLimiter when an IDistributedCache is available.
If no distributed cache is registered, it falls back to InMemoryTenantRateLimiter (process-local counters).
For horizontally scaled deployments, register a distributed cache:
// Register distributed cache before or after AddPrimusMultiTenancyAspNetCore()
builder.Services.AddStackExchangeRedisCache(o => o.Configuration = "localhost:6379");
You can still force explicit behavior with AddPrimusMultiTenancyDistributedRateLimiting().
Disabling isolation or rate limiting
To run without tenant enforcement (for internal or single-tenant APIs), set:
{
"MultiTenancy": {
"RequireTenantOnAuthenticatedRequests": false,
"EnableTenantRateLimiting": false
}
}
Or omit UsePrimusTenantIsolation() and UsePrimusTenantRateLimiting() from the pipeline.
Step 4: EF Core write enforcement
The EFCore package provides a SaveChanges interceptor that:
- Automatically sets
TenantIdon anyITenantOwnedEntityadded without one. - Throws
InvalidOperationExceptionon cross-tenantModifiedorDeletedentities.
Register the interceptor:
using PrimusSaaS.MultiTenancy.EFCore.DependencyInjection;
builder.Services.AddPrimusMultiTenancyEfCore<AppDbContext>();
builder.Services.AddDbContext<AppDbContext>((serviceProvider, options) =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
options.UsePrimusMultiTenancy(serviceProvider); // attaches the interceptor
});
Configure your DbContext model so owned entities get the TenantId column:
using PrimusSaaS.MultiTenancy.EFCore.Extensions;
public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddPrimusMultiTenancy(); // marks TenantId as required on all ITenantOwnedEntity types
}
}
Mark your entities:
using PrimusSaaS.MultiTenancy.Abstractions;
public class Invoice : ITenantOwnedEntity
{
public Guid Id { get; set; }
public string TenantId { get; set; } = string.Empty;
public decimal Amount { get; set; }
}
Step 5: Development and testing with In-Memory store
For local development and tests, register the In-Memory membership store and seed tenant memberships after the app is built:
using PrimusSaaS.MultiTenancy.InMemory.DependencyInjection;
using PrimusSaaS.MultiTenancy.InMemory.Stores;
using PrimusSaaS.MultiTenancy.Models;
// Register the In-Memory store (no configuration parameters)
builder.Services.AddPrimusMultiTenancyInMemory();
var app = builder.Build();
// Seed test memberships after building the service provider
var store = app.Services.GetRequiredService<InMemoryTenantMembershipStore>();
store.Seed("user-123", new[]
{
new TenantMembership("user-123", "tenant-001"),
new TenantMembership("user-123", "tenant-002"),
});
The In-Memory store satisfies ITenantMembershipStore so the DefaultMembershipTenantResolutionStrategy can resolve a tenant without a JWT claim or header.
AddPrimusMultiTenancyInMemory() takes no parameters. Seeding must be done after builder.Build() by resolving InMemoryTenantMembershipStore from the service provider and calling Seed() directly.
Step 6: Tenant management endpoints (optional)
The ASP.NET Core package ships opt-in endpoints for tenant CRUD/lifecycle operations, list/search, and summary metrics backed by ITenantStore. Add them after your route mapping:
using PrimusSaaS.MultiTenancy.AspNetCore.DependencyInjection;
var app = builder.Build();
// ...middleware...
app.MapControllers();
// Mount management endpoints (optional — secure before exposing)
app.MapPrimusTenantManagementEndpoints()
.RequireAuthorization("PlatformAdmin"); // recommended: lock down this group
Available routes:
| Method | Route | Description |
|---|---|---|
GET | /primus/tenants | List tenants (search, status, skip, take) |
GET | /primus/tenants/summary | Aggregate counts by status and tier |
GET | /primus/tenants/{tenantId} | Retrieve a tenant record |
PUT | /primus/tenants/{tenantId} | Create or update a tenant record |
POST | /primus/tenants/{tenantId}/activate | Set status to Active |
POST | /primus/tenants/{tenantId}/suspend | Set status to Suspended |
POST | /primus/tenants/{tenantId}/soft-delete | Set status to Deleted |
DELETE | /primus/tenants/{tenantId} | Permanently delete a tenant record |
POST | /primus/tenants/{tenantId}/offboard | Optional retention purge + hard-delete orchestration |
Customize the route prefix by passing a pattern argument:
app.MapPrimusTenantManagementEndpoints("/admin/tenants");
Step 7: Tier feature gating (optional)
Use tier metadata for endpoint-level feature entitlements:
builder.Services.Configure<MultiTenancyOptions>(o =>
{
o.DefaultTenantTier = "free";
o.PerTierEntitlements["free"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"dashboard.view"
};
o.PerTierEntitlements["pro"] = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"dashboard.view",
"reports.export"
};
});
app.MapGet("/api/reports/export", ExportHandler)
.RequirePrimusFeature("reports.export");
When a tenant tier is not entitled, middleware returns 403 and emits TenantEntitlementDenied to ITenantAuditSink.
Reading tenant context in controllers
using PrimusSaaS.MultiTenancy.Abstractions;
[ApiController]
[Route("api/invoices")]
public class InvoicesController(ITenantContextAccessor tenantContext) : ControllerBase
{
[HttpGet]
[Authorize]
public IActionResult List()
{
var tenant = tenantContext.Current;
// tenant.TenantId — always populated
// tenant.DisplayName — optional, may be null
return Ok(new { tenantId = tenant?.TenantId, displayName = tenant?.DisplayName });
}
}
Composing with RBAC
Forward the resolved tenant identity to the RBAC module using the built-in composition helper. No RBAC package reference is required in the multi-tenancy project:
using PrimusSaaS.MultiTenancy.Abstractions;
using PrimusSaaS.MultiTenancy.Composition;
[ApiController]
[Route("api/documents")]
public class DocumentsController(
ITenantContextAccessor tenantContext,
IRbacService rbac) : ControllerBase
{
[HttpDelete("{id}")]
[Authorize]
public async Task<IActionResult> Delete(string id)
{
// ResolveTenantIdForRbac() returns null if no tenant is active
var tenantId = tenantContext.ResolveTenantIdForRbac();
var decision = await rbac.EvaluateAsync(new RbacAccessRequest(
PrincipalId: User.FindFirstValue(ClaimTypes.NameIdentifier)!,
Resource: "documents",
Action: "delete",
TenantId: tenantId));
if (!decision.IsAllowed)
return Forbid();
// ... delete logic
return NoContent();
}
}
Alternatively, resolve directly from a TenantContext? value:
var tenantId = tenantContext.Current.ResolveTenantIdForRbac();
Golden path — minimum working setup
Install both packages and add three lines to Program.cs:
builder.Services.AddPrimusMultiTenancy();
builder.Services.AddPrimusMultiTenancyAspNetCore();
// after UseAuthentication():
app.UsePrimusTenantResolution();
app.UsePrimusTenantIsolation();
Security: header strategy trust boundary
HeaderTenantResolutionStrategy (strategy 2 in the default pipeline) accepts X-Tenant-Id at face value without verifying that the caller is an active member of the specified tenant. A malicious or misconfigured client can send any tenant ID and the strategy will accept it.
Use HeaderTenantResolutionStrategy only when:
- The API is called exclusively by trusted internal services that set the header from their own verified context.
- A JWT claim strategy (strategy 1) runs first and will resolve the tenant before the header strategy is evaluated on the same request.
- You have replaced the default header strategy with
MembershipValidatedTenantResolutionStrategy(see below).
Never configure HeaderTenantResolutionStrategy as the sole resolution strategy for a public API that allows untrusted clients. Doing so enables tenant enumeration and impersonation attacks.
Membership-validated tenant selection
For APIs where users may belong to multiple tenants and are allowed to select their active workspace, use MembershipValidatedTenantResolutionStrategy. It reads a tenant selection from a claim or the configured header and validates it against the caller's active memberships before accepting it:
builder.Services.AddPrimusMultiTenancy();
builder.Services.AddPrimusMultiTenancyAspNetCore();
// Registers MembershipValidatedTenantResolutionStrategy as an additional named strategy.
// Your ITenantMembershipStore must be registered first.
builder.Services.AddPrimusMultiTenancyMembershipValidatedSelection();
With membership validation active, a request sending X-Tenant-Id: acme-corp succeeds only if the authenticated principal is an active, non-expired member of acme-corp. Requests that do not pass this check receive tenant_membership_denied in the resolution failure details.
Tenant audit events
The module emits structured TenantAuditEvent records through ITenantAuditSink for key isolation and lifecycle events. The default sink writes to ILogger<LoggerTenantAuditSink> at Information level.
Events emitted
| Event | Source |
|---|---|
TenantResolved | Resolution middleware |
TenantResolutionFailed | Resolution middleware |
TenantIsolationBlocked | TenantIsolationMiddleware |
TenantSuspendedAccess | TenantIsolationMiddleware |
TenantDeletedAccess | TenantIsolationMiddleware |
SuperAdminBypassActivated | TenantIsolationMiddleware |
RateLimitExceeded | TenantRateLimitMiddleware |
CrossTenantWriteBlocked | TenantSaveChangesInterceptor |
TenantCreated / TenantUpdated | Management endpoints |
TenantProvisioned | Management endpoints (after provisioning handler completes) |
TenantActivated / TenantSuspended / TenantSoftDeleted | Management endpoints |
Custom sink
Implement ITenantAuditSink and register it before calling AddPrimusMultiTenancy():
// Replace the default logger sink with your own (e.g., writes to Azure Monitor)
builder.Services.AddSingleton<ITenantAuditSink, MyAzureMonitorAuditSink>();
builder.Services.AddPrimusMultiTenancy();
Your implementation must not throw. If the sink can fail (e.g., network writes), wrap it in a try/catch internally.
Tenant provisioning hook
Implement ITenantProvisioningHandler to run ordered initialization steps when a tenant is created for the first time (via PUT /primus/tenants/{id}). The hook is not called on updates to existing tenants.
public sealed class MyProvisioningHandler(IStorageService storage) : ITenantProvisioningHandler
{
public async Task OnTenantCreatedAsync(TenantRecord tenant, CancellationToken ct = default)
{
await storage.CreateTenantBucketAsync(tenant.TenantId, ct);
// Seed default data, send welcome notification, etc.
}
}
// Register before AddPrimusMultiTenancy():
builder.Services.AddSingleton<ITenantProvisioningHandler, MyProvisioningHandler>();
builder.Services.AddPrimusMultiTenancy();
Implementations should be idempotent — they may be called more than once if retried after a transient failure. The default registration is a no-op (NullTenantProvisioningHandler).
Background context propagation
ITenantContextAccessor backed by HttpContextAccessor works only inside an ASP.NET Core request. For background jobs, hosted services, and message consumers, capture the context before leaving the request and restore it when the background work starts.
Step 1 — capture in the request
using PrimusSaaS.MultiTenancy.Resolution;
// Inside a request handler or request-scoped service:
var executionCtx = TenantExecutionContext.Capture(tenantContextAccessor);
backgroundQueue.Enqueue(() => ProcessAsync(executionCtx, serviceProvider));
Or when the tenant ID is known from a message header:
var executionCtx = TenantExecutionContext.ForTenant(message.TenantId);
Step 2 — restore in the background worker
using PrimusSaaS.MultiTenancy.Infrastructure;
// Inside the background job / hosted service:
using var scope = serviceProvider.CreateScope();
var filterProvider = scope.ServiceProvider.GetRequiredService<TenantQueryFilterProvider>();
using (executionCtx.Apply(filterProvider))
{
// All EF Core and ITenantContextAccessor calls within this using block
// operate with the restored tenant scope.
await DoTenantSafeWorkAsync(scope.ServiceProvider);
}
Use AmbientTenantContextAccessor (not HttpContextTenantContextAccessor) in the background scope so ITenantContextAccessor.Current reads the restored context:
// Register in the background worker's DI scope or at composition root:
services.AddSingleton<ITenantContextAccessor, AmbientTenantContextAccessor>();
EF Core: allowUnscopedReads caution
ModelBuilderExtensions.AddPrimusMultiTenancy() accepts an optional allowUnscopedReads: true parameter. Setting this to true disables the guard that throws when tenant-owned entities are queried without an active tenant context.
Setting allowUnscopedReads: true masks missing tenant filter provider initialization and can silently return rows from all tenants. This is a cross-tenant data leak risk.
Only use allowUnscopedReads: true in controlled environments (e.g., background migration scripts that must scan all rows) and never in code paths that return data to end users.
Pre-go-live checklist
Before deploying a multi-tenant API to production, verify each of the following:
| Check | Why |
|---|---|
AddPrimusMultiTenancyDistributedRateLimiting() is configured | Default in-memory limiter resets on restart and is not shared across replicas |
HeaderTenantResolutionStrategy is not the sole resolution strategy on public APIs | Header strategy accepts untrusted values without membership check |
ITenantMembershipStore is backed by a real database (not InMemory) | In-Memory store does not persist across restarts |
MapPrimusTenantManagementEndpoints() is protected by authorization | Management endpoints mutate tenant lifecycle; should require elevated claims |
ITenantAuditSink routes to a durable store (not just console logs) | Audit events are required for SOC 2 / ISO 27001 review |
ITenantProvisioningHandler is idempotent | The handler may be retried on transient failures |
allowUnscopedReads: false in AddPrimusMultiTenancy() (or omitted) | Setting true disables the cross-tenant read guard |
AddPrimusMultiTenancyDistributedMembershipCaching() is configured for scaled deployments | In-process membership cache is not shared across instances |
Rate-limit overrides are set for high-volume tenants in PerTenantRateLimits | Global defaults may reject legitimate high-throughput tenants |
RejectConflictingTenantSignals: true (default) | Fail-closed on claim/header mismatch; set false only with explicit justification |
Your JWT's tid claim becomes the TenantContext.TenantId for every request.