Documentation
adrs/037-explicit-tenant-scope-operations.md
ADR 037: Explicit Tenant Scope Operations
Status: Accepted
Date: 2025-11-10
Deciders: Architecture Team
Related: ADR-009 (Database Schema per Service), ADR-036 (Database Project Separation)
Context
Dynaplex uses row-level multi-tenancy where each component's database contains tenant-scoped data. The DynaplexDbContext base class provides automatic tenant filtering using EF Core global query filters.
Current Implementation
All component DbContexts inherit from DynaplexDbContext:
public abstract class DynaplexDbContext : DbContext
{
protected Guid? TenantId;
public void SetTenantId(Guid? tenantId) => TenantId = tenantId;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ConfigureTenantFilters(this); // Applies global filters
}
}
Global Query Filter Logic:
// Filter: (context.TenantId == null) || (entity.TenantId == context.TenantId)
// When context.TenantId is null: ALL tenant data is visible
// When context.TenantId is set: Only that tenant's data is visible
Tenant Resolution:
- HTTP requests:
ITenantResolverextracts tenant ID from JWT claims AddAcsisDbContext<T>()automatically sets tenant ID on pooled context- Background services: Manually call
SetTenantId(specificGuid)
The Problem
The current API has two significant issues:
1. Implicit System Operations
System operations (migrations, authentication, permission sync) that need cross-tenant access use IgnoreQueryFilters() without explicit authorization:
// Current pattern - implicit system scope
public async Task<User?> GetUserByUsername(string username)
{
// No indication this is a system operation
return await db.Users.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Username == username);
}
Found 31 usages of IgnoreQueryFilters() across:
- Migrations/seeding (13 usages)
- Authentication (9 usages)
- Permission system (4 usages)
- Background service user lookups (6 usages)
2. Ambiguous Null Tenant
SetTenantId(Guid? tenantId) accepts null, which conflates different scenarios:
- "I'm in a system operation" (migrations, authentication)
- "I don't have HttpContext yet" (background services)
- Accidentally passing null (data leakage risk)
While no code currently calls SetTenantId(null), the nullable parameter creates risk:
- Developer might call it thinking it means "use default"
- ALL tenant data becomes visible with no error
- No audit trail of system operations
3. No SaveChanges Validation
There are no guardrails preventing:
- Saving data without setting tenant context
- Writing entities for wrong tenant
- Cross-tenant data corruption
// This currently works (but shouldn't)
var db = factory.CreateDbContext();
db.Users.Add(new User { TenantId = someGuid, ... });
db.SaveChanges(); // No validation that context tenant matches entity tenant
Legitimate System Operations
Analysis identified these legitimate cross-tenant scenarios:
A. Migrations and Seeding
- Creating initial tenants
- Creating built-in roles (SystemAdministrator, User)
- Creating admin/system users
- Seeding reference data that spans tenants
B. Authentication Flow
- Looking up users by username (before tenant is known)
- Loading user groups and roles
- Expanding permissions
- Session validation
C. Permission System
- Syncing permissions across tenants
- Loading all permissions for expansion
- Cross-tenant permission queries
D. Tenant Bootstrapping
- Looking up tenant by slug (e.g., "default")
- Initial tenant context establishment
Two Distinct Patterns
Pattern 1: Tenant-Scoped Background Services (BBU components)
// Background service processes ONE specific tenant
var tenantId = await GetDefaultTenantAsync();
db.SetTenantId(tenantId); // Normal tenant scoping
// Process data for this tenant only
Pattern 2: System-Wide Operations (Migrations, Auth)
// System operation needs ALL tenants or pre-tenant context
var allTenants = await db.Tenants.IgnoreQueryFilters().ToListAsync();
The API should make these patterns explicit and prevent misuse.
Decision
We will implement explicit tenant scope operations with the following changes:
1. Remove Nullable Tenant Parameter
// OLD: SetTenantId(Guid? tenantId)
// NEW: SetTenantId(Guid tenantId) - non-nullable
Forces explicit intent. Background services must provide a specific tenant ID.
2. Add Explicit System Scope API
public abstract class DynaplexDbContext : DbContext
{
private Guid? _tenantId;
private TenantScopeMode _scopeMode = TenantScopeMode.RequiresSetup;
private readonly ILogger<DynaplexDbContext> _logger;
// Normal tenant operations (non-nullable)
public void SetTenantId(Guid tenantId)
{
_tenantId = tenantId;
_scopeMode = TenantScopeMode.Tenant;
}
// System operations - requires marker interface
public void SetSystemScope(
SystemScopeReason reason,
[CallerFilePath] string callerPath = "",
[CallerMemberName] string callerMember = "")
{
// Validate caller implements ISystemDbContextUser
EnsureSystemScopeAuthorized();
// Audit log
_logger.LogWarning(
"System scope enabled: {Reason} from {Caller}.{Member}",
reason, Path.GetFileName(callerPath), callerMember);
_tenantId = null;
_scopeMode = TenantScopeMode.System;
}
private void EnsureSystemScopeAuthorized()
{
// Walk the call stack to find calling class
var stackTrace = new StackTrace();
var callingType = stackTrace.GetFrame(2)?.GetMethod()?.DeclaringType;
if (callingType == null || !typeof(ISystemDbContextUser).IsAssignableFrom(callingType))
{
throw new UnauthorizedAccessException(
$"SetSystemScope requires caller to implement ISystemDbContextUser. " +
$"Caller: {callingType?.FullName ?? "Unknown"}");
}
}
}
3. Scope Mode Enum
public enum TenantScopeMode
{
RequiresSetup, // Initial state - must call Set method before use
Tenant, // Normal tenant-scoped operation
System // System-wide operation (migrations, auth, etc.)
}
4. System Scope Reason Enum
public enum SystemScopeReason
{
Migration, // Database migration or schema updates
Seeding, // Reference data seeding
Authentication, // User lookup before tenant context exists
PermissionSync, // Syncing permissions across tenants
AdminOperation, // Administrative cross-tenant operations
TenantBootstrap // Looking up tenant by slug or ID
}
5. Marker Interface for Authorization
/// <summary>
/// Marker interface indicating a class is authorized to use system scope operations.
/// Classes must implement this interface to call SetSystemScope() on DbContext.
/// </summary>
public interface ISystemDbContextUser
{
// Empty marker - existence indicates authorization
}
Where to implement:
- Database seeding services
- Authentication providers
- Permission synchronization services
- Migration services
- Administrative tools
6. SaveChanges Validation
public override int SaveChanges()
{
ValidateTenantScope();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ValidateTenantScope();
return base.SaveChangesAsync(cancellationToken);
}
private void ValidateTenantScope()
{
// Must set scope before saving
if (_scopeMode == TenantScopeMode.RequiresSetup)
{
throw new InvalidOperationException(
"DbContext must have tenant scope set before saving changes. " +
"Call SetTenantId() for tenant operations or SetSystemScope() for system operations.");
}
// Tenant mode requires non-null tenant
if (_scopeMode == TenantScopeMode.Tenant && _tenantId == null)
{
throw new InvalidOperationException(
"Tenant scope mode requires a non-null tenant ID.");
}
// Validate entities match tenant (when in Tenant mode)
if (_scopeMode == TenantScopeMode.Tenant)
{
ValidateEntitiesMatchTenant();
}
// Warn on system scope saves
if (_scopeMode == TenantScopeMode.System)
{
_logger.LogWarning("Saving changes in System scope mode. " +
"Ensure this is an authorized system operation.");
}
}
private void ValidateEntitiesMatchTenant()
{
var entries = ChangeTracker.Entries()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
foreach (var entry in entries)
{
// Check if entity has TenantId property
var tenantIdProperty = entry.Property("TenantId");
if (tenantIdProperty == null) continue;
var entityTenantId = (Guid?)tenantIdProperty.CurrentValue;
// Validate entity tenant matches context tenant
if (entityTenantId != null && entityTenantId != _tenantId)
{
throw new InvalidOperationException(
$"Entity {entry.Entity.GetType().Name} has TenantId={entityTenantId} " +
$"but DbContext is scoped to TenantId={_tenantId}. " +
$"This would create data for a different tenant.");
}
// Auto-set tenant if null (convenience)
if (entityTenantId == null && entry.State == EntityState.Added)
{
tenantIdProperty.CurrentValue = _tenantId;
}
}
}
7. Updated Registration
AddAcsisDbContext registration remains unchanged - automatic tenant resolution via ITenantResolver still works:
// In AddAcsisDbContext<T>()
var context = factory.CreateDbContext();
var tenantId = tenantResolver.GetCurrentTenantId();
if (tenantId.HasValue)
{
context.SetTenantId(tenantId.Value); // Now requires non-null
}
return context;
Consequences
Positive
✅ Explicit Intent: SetSystemScope(SystemScopeReason.Authentication) is clear
✅ Authorization: Marker interface prevents accidental system scope
✅ Audit Trail: All system operations logged with caller info
✅ Validation: SaveChanges ensures scope is set and entities match tenant
✅ Safety: Can't accidentally call SetTenantId(null)
✅ Background Services: Clear pattern (they're just tenant-scoped with manual resolution)
✅ No Breaking Changes to Patterns: HTTP requests and background services work as before
✅ Type Safety: Compile-time errors if system operations don't implement marker
Negative
⚠️ Boilerplate: System operation classes must implement ISystemDbContextUser
⚠️ Migration Effort: Existing code needs updates (31 IgnoreQueryFilters usages)
⚠️ Learning Curve: Developers must understand two scope modes
⚠️ Stack Walking: EnsureSystemScopeAuthorized() uses reflection (minimal performance impact)
Neutral
- Logging Overhead: System operations generate log warnings (useful for audit)
- Validation Cost: SaveChanges validation adds minimal overhead
- Backward Compatibility: Breaking change to
SetTenantIdsignature, but no current usage of null parameter
Implementation Guide
Pattern 1: Background Services (No Change)
Background services continue using SetTenantId with specific tenant:
public class ReturnDoorProcessor : BackgroundService
{
// NO ISystemDbContextUser marker needed
protected override async Task ExecuteAsync(CancellationToken ct)
{
var tenantId = await TenantHelper.GetDefaultTenantIdAsync(...);
await using var db = await _factory.CreateDbContextAsync(ct);
db.SetTenantId(tenantId); // Normal tenant scoping
// Process data for this tenant only
}
}
Pattern 2: Migration/Seeding (Add Marker + SetSystemScope)
public class IdentityReferenceDataSeeder : ISystemDbContextUser
{
public async Task SeedAsync(IdentityDb db)
{
// Explicit system scope with reason
db.SetSystemScope(SystemScopeReason.Seeding);
// Now IgnoreQueryFilters() works
var existingRoles = await db.Roles.IgnoreQueryFilters()
.Where(r => r.Name == "SystemAdministrator")
.FirstOrDefaultAsync();
if (existingRoles == null)
{
db.Roles.Add(new Role { Name = "SystemAdministrator", ... });
await db.SaveChangesAsync(); // Validated and logged
}
}
}
Pattern 3: Authentication (Add Marker + SetSystemScope)
public class AuthDataProvider : ISystemDbContextUser
{
private readonly IdentityDb _db;
public async Task<User?> GetUserByUsername(string username)
{
// Explicit: this is a pre-tenant-context operation
_db.SetSystemScope(SystemScopeReason.Authentication);
return await _db.Users.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Username == username);
}
}
Pattern 4: HTTP API Endpoints (No Change)
Normal API endpoints continue working automatically:
public class UsersApi
{
private readonly IdentityDb _db; // Tenant auto-applied from JWT
public async Task<List<User>> GetUsers()
{
// Tenant filter automatically applied
return await _db.Users.ToListAsync();
}
}
Validation in Action
// ❌ This will THROW at SaveChanges
var db = factory.CreateDbContext();
db.Users.Add(new User { TenantId = tenantId, ... });
db.SaveChanges();
// InvalidOperationException: "DbContext must have tenant scope set"
// ✅ This works
db.SetTenantId(tenantId);
db.SaveChanges(); // Validated - entity tenant matches context tenant
// ✅ This also works (with marker interface)
db.SetSystemScope(SystemScopeReason.Migration);
db.SaveChanges(); // Logged as system operation
// ❌ This will THROW (cross-tenant write attempt)
db.SetTenantId(tenantId1);
db.Users.Add(new User { TenantId = tenantId2, ... });
db.SaveChanges();
// InvalidOperationException: "Entity has TenantId=X but context is scoped to TenantId=Y"
Migration Path
Phase 1: Add New API (Non-Breaking)
- Add
TenantScopeModeenum toAcsis.Dynaplex - Add
SystemScopeReasonenum toAcsis.Dynaplex - Add
ISystemDbContextUserinterface toAcsis.Dynaplex - Add
SetSystemScope()method toDynaplexDbContext - Keep
SetTenantId(Guid? tenantId)as deprecated (mark with[Obsolete]) - Add SaveChanges validation (initially as warnings, not errors)
Phase 2: Update System Operations
For each usage of IgnoreQueryFilters():
- Add
ISystemDbContextUserto class - Call
SetSystemScope(reason)beforeIgnoreQueryFilters() - Add comment explaining why system scope is needed
Classes to update:
IdentityReferenceDataSeeder- SeedingSpatialReferenceDataSeeder- SeedingAuthDataProvider- AuthenticationPermissionExpansionService- PermissionSyncPermissionSyncService- PermissionSync- Background services that look up service users (use
TenantBootstrapreason)
Phase 3: Make Breaking Changes
- Change
SetTenantId(Guid? tenantId)toSetTenantId(Guid tenantId)(non-nullable) - Make SaveChanges validation throw errors instead of warnings
- Remove
[Obsolete]attributes - Update documentation
Phase 4: Optional Analyzer
Create Roslyn analyzer:
- Warning on
IgnoreQueryFilters()without precedingSetSystemScope() - Suggestion to add
ISystemDbContextUsermarker - Suppression available for legitimate cases
Related ADRs
- ADR-009: Database Schema per Service - Schema isolation foundation
- ADR-036: Database Project Separation - Database architecture
- ADR-010: JWT Bearer Authentication - Tenant claim in JWT
References
- MULTI_TENANCY.md - Complete multi-tenancy guide
- Microsoft: EF Core Query Filters
- Microsoft: Multi-Tenant Data
Notes
- This ADR builds on existing multi-tenancy infrastructure (DynaplexDbContext, ITenantResolver)
- The marker interface pattern is similar to ASP.NET Core's
IAuthorizationRequirement - Stack walking for authorization is similar to
[CallerMemberName]attribute behavior - Validation adds negligible performance overhead (only on SaveChanges, not queries)
- Background services are NOT system operations - they process data for a specific tenant
- System scope should be rare - most code operates within a tenant context