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: ITenantResolver extracts 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 SetTenantId signature, 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)

  1. Add TenantScopeMode enum to Acsis.Dynaplex
  2. Add SystemScopeReason enum to Acsis.Dynaplex
  3. Add ISystemDbContextUser interface to Acsis.Dynaplex
  4. Add SetSystemScope() method to DynaplexDbContext
  5. Keep SetTenantId(Guid? tenantId) as deprecated (mark with [Obsolete])
  6. Add SaveChanges validation (initially as warnings, not errors)

Phase 2: Update System Operations

For each usage of IgnoreQueryFilters():

  1. Add ISystemDbContextUser to class
  2. Call SetSystemScope(reason) before IgnoreQueryFilters()
  3. Add comment explaining why system scope is needed

Classes to update:

  • IdentityReferenceDataSeeder - Seeding
  • SpatialReferenceDataSeeder - Seeding
  • AuthDataProvider - Authentication
  • PermissionExpansionService - PermissionSync
  • PermissionSyncService - PermissionSync
  • Background services that look up service users (use TenantBootstrap reason)

Phase 3: Make Breaking Changes

  1. Change SetTenantId(Guid? tenantId) to SetTenantId(Guid tenantId) (non-nullable)
  2. Make SaveChanges validation throw errors instead of warnings
  3. Remove [Obsolete] attributes
  4. Update documentation

Phase 4: Optional Analyzer

Create Roslyn analyzer:

  • Warning on IgnoreQueryFilters() without preceding SetSystemScope()
  • Suggestion to add ISystemDbContextUser marker
  • Suppression available for legitimate cases

References

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