Multi-Tenant Guide
This guide covers the canonical tenant context access pattern, resolution flow, and data isolation strategies in the Muonroi ecosystem.
Quick Start
Use ISystemExecutionContextAccessor to access tenant context anywhere in your code:
private readonly ISystemExecutionContextAccessor _contextAccessor;
ISystemExecutionContext context = _contextAccessor.Get();
string? tenantId = context.TenantId;
Guid? userId = Guid.TryParse(context.UserId, out Guid parsed) ? parsed : null;
No parameter passing is required — tenant context is propagated via AsyncLocal<T> and available throughout the request.
Tenant Resolution Flow
The tenant is resolved in this order:
graph LR
A["HTTP Request"] --> B["x-tenant-id<br/>Header"]
B --> C{Found?}
C -->|Yes| D["Use header<br/>tenant"]
C -->|No| E["Path param<br/>/:tenantId"]
E --> F{Found?}
F -->|Yes| G["Use path<br/>tenant"]
F -->|No| H["Subdomain<br/>parsing"]
H --> I{Found?}
I -->|Yes| J["Use subdomain<br/>tenant"]
I -->|No| K["JWT claim<br/>iss/sub"]
K --> L["Use claim<br/>tenant"]
L --> M["Validate vs JWT<br/>Claim"]
D --> M
G --> M
J --> M
M --> N{Match?}
N -->|No| O["401 Unauthorized"]
N -->|Yes| P["TenantContext.CurrentTenantId<br/>set via AsyncLocal"]
O --> Q["Request rejected"]
P --> R["Available throughout<br/>request lifetime"]
Resolution Rules
-
x-tenant-id Header (highest priority) Check incoming request header
x-tenant-id. -
Path Parameter If header missing, extract
{tenantId}from route (e.g.,/api/tenants/{tenantId}/rules). -
Subdomain If no header/path param, parse subdomain (e.g.,
tenant-1.myapp.com). -
JWT Claim (fallback) If all above fail, use
issorsubclaim from JWT token. -
Validation Resolved tenant must match the JWT claim value. Mismatch returns 401 Unauthorized — fail-closed security model.
AsyncLocal Context Propagation
Once resolved, tenant context is stored in AsyncLocal<TenantContext> by TenantResolutionMiddleware:
// Set once at request entry
TenantContext.CurrentTenantId = resolvedTenantId;
// Available everywhere inside the request
public class MyService
{
public void DoWork()
{
var tenantId = TenantContext.CurrentTenantId; // No parameter needed
// Use tenantId in business logic
}
}
Key Benefits:
- No method parameter pollution
- Works across synchronous and async call chains
- Automatically cleaned up at request end
Transport Boundaries
Tenant context is automatically initialized at these boundaries:
JwtMiddleware— HTTP/REST requestsGrpcServerInterceptor— gRPC callsAmqpContextConsumeFilter— message queue consumersTenantContextConsumeFilter— event bus consumersJobContextActivatorFilter— scheduled job executionQuartzContextJobListener— Quartz job scheduling
No additional setup is required at these boundaries.
Temporary Context Switching with ContextMirrorScope
When you need to temporarily switch tenant context (e.g., cross-tenant admin operations), use ContextMirrorScope:
private readonly ISystemExecutionContextAccessor _contextAccessor;
private readonly ILogScopeFactory _logScopeFactory;
public async Task AdminOperationAsync(string targetTenantId)
{
// Save current context
var originalContext = _contextAccessor.Get();
// Switch to target tenant (push)
var targetContext = new SystemExecutionContext
{
TenantId = targetTenantId,
UserId = originalContext.UserId // Admin user ID
};
using var scope = new SystemExecutionContextScope(_contextAccessor, targetContext);
using var mirror = ContextMirrorScope.Apply(targetContext, _logScopeFactory);
{
// All code here executes as targetTenantId
var data = await _repository.GetAsync(); // Uses targetTenantId filters
} // Pop: context restored to originalContext
}
Lifecycle:
- Push — Create scope with new context
- Execute — All nested code sees new tenant ID
- Pop — Automatic cleanup restores original context
Data Isolation Strategies
Muonroi supports 3 tenant isolation models (select at deployment time):
| Strategy | Isolation Level | Use Case |
|---|---|---|
| Shared Database, Filtered Schema | Medium | Multi-tenant SaaS, trusted infrastructure |
| Separate Database Schema | High | Compliance (SOC2), GDPR zones |
| Database Per Tenant | Maximum | Regulatory isolation, data residency |
All strategies enforce isolation via EF Core query filters (see Tenant Isolation Strategies).
Entity Framework Query Filters
All entities implementing ITenantScoped automatically receive a query filter:
// Applied automatically by DbContext configuration
e => e.TenantId == TenantContext.CurrentTenantId ||
TenantContext.CurrentTenantId == null
This filter is transparent — your LINQ queries never need to manually filter by tenant:
// Before (manual filter — don't do this)
var rules = await _db.Rules
.Where(r => r.TenantId == tenantId)
.ToListAsync();
// After (automatic filter — preferred)
var rules = await _db.Rules
.ToListAsync(); // Automatically filtered by CurrentTenantId
See EF Core Filters Guide for details.
Quota System
Each tenant has per-feature quotas enforced at runtime:
- 13 quota types (workflows, rules, concurrent executions, API calls, etc.)
- 4 tier presets (Free, Starter, Professional, Enterprise)
- Per-period tracking (daily, monthly, annual)
Example:
public class RuleExecutionService
{
private readonly IQuotaService _quotaService;
public async Task<Result> ExecuteRuleAsync(string tenantId, string ruleId)
{
// Check quota before execution
var allowed = await _quotaService.CheckAndConsumeAsync(
tenantId,
QuotaType.RuleExecutions,
amount: 1
);
if (!allowed)
return Result.Failure("Quota exceeded");
// Execute rule...
}
}
For detailed quota configuration and enforcement, see Multi-Tenant Quota Guide.
Legacy Mirror Support
When downstream packages still depend on TenantContext or UserContext, use ContextMirrorScope:
using var scope = new SystemExecutionContextScope(_contextAccessor,
new SystemExecutionContext(tenantId, userId));
using var mirror = ContextMirrorScope.Apply(scope.Context, _logScopeFactory);
{
// Legacy code sees TenantContext.CurrentTenantId populated
var legacyData = LegacyService.GetData();
}
This is a migration path only — new code should use ISystemExecutionContextAccessor directly.
Current Quota Endpoints
Control Plane APIs for tenant quota management:
GET /api/v1/tenants/{tenantId}/quotas— fetch current quotasPUT /api/v1/tenants/{tenantId}/quotas— update quota tiersGET /api/v1/control-plane/quotas/{tenantId}— detailed quota usage
Common Patterns
Access Current Tenant in a Service
public class RuleService(ISystemExecutionContextAccessor contextAccessor)
{
public async Task<List<Rule>> GetRulesAsync()
{
var tenantId = contextAccessor.Get().TenantId;
return await _db.Rules.ToListAsync(); // Auto-filtered
}
}
Validate Tenant Access Before Operation
public async Task DeleteTenantDataAsync(string requestedTenantId)
{
var currentTenantId = _contextAccessor.Get().TenantId;
if (requestedTenantId != currentTenantId)
throw new UnauthorizedAccessException("Tenant mismatch");
// Safe to delete
}
Admin: Execute in Different Tenant Context
public async Task AdminResetQuotaAsync(string targetTenantId)
{
var adminContext = _contextAccessor.Get();
using var scope = new SystemExecutionContextScope(
_contextAccessor,
new SystemExecutionContext { TenantId = targetTenantId }
);
using var mirror = ContextMirrorScope.Apply(scope.Context, _logScopeFactory);
{
// Execute as targetTenantId
await _quotaService.ResetAsync(targetTenantId);
}
}
Related Guides
- Tenant Isolation Strategies — Deployment isolation models (SharedSchema, SeparateSchema, SeparateDatabase)
- EF Core Query Filters — Automatic tenant filtering in Entity Framework
- Multi-Tenant Quota Guide — Quota enforcement, tier presets, consumption tracking
- Authentication & Authorization — JWT resolution and claim validation