From 297826293ed9c8579b922a8e6726c1b739b8f3f1 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Mon, 17 Aug 2026 09:26:01 +0000 Subject: [PATCH 1/3] Add lightweight EF Core tenant guardrails sample --- .../EfCoreTenantGuardrailsMinimal.slnx | 4 + .../README.md | 197 +++++++++++++++++ .../Context/TenantExecutionContext.cs | 31 +++ .../Data/TenantDatabase.cs | 89 ++++++++ .../Data/TenantDbContext.cs | 66 ++++++ .../EfCoreTenantGuardrailsMinimal.csproj | 13 ++ .../Interceptors/TenantAuditInterceptor.cs | 123 +++++++++++ .../Models/TenantTask.cs | 18 ++ .../EfCoreTenantGuardrailsMinimal/Program.cs | 10 + .../Queries/TenantQueries.cs | 25 +++ .../Services/TenantWorkflow.cs | 99 +++++++++ ...EfCoreTenantGuardrailsMinimal.Tests.csproj | 20 ++ .../TenantGuardrailTests.cs | 199 ++++++++++++++++++ 13 files changed, 894 insertions(+) create mode 100644 ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx create mode 100644 ef-core/multitenancy-softdelete-auditing/README.md create mode 100644 ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Context/TenantExecutionContext.cs create mode 100644 ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Data/TenantDatabase.cs create mode 100644 ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Data/TenantDbContext.cs create mode 100644 ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj create mode 100644 ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Interceptors/TenantAuditInterceptor.cs create mode 100644 ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Models/TenantTask.cs create mode 100644 ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Program.cs create mode 100644 ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Queries/TenantQueries.cs create mode 100644 ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Services/TenantWorkflow.cs create mode 100644 ef-core/multitenancy-softdelete-auditing/tests/EfCoreTenantGuardrailsMinimal.Tests/EfCoreTenantGuardrailsMinimal.Tests.csproj create mode 100644 ef-core/multitenancy-softdelete-auditing/tests/EfCoreTenantGuardrailsMinimal.Tests/TenantGuardrailTests.cs diff --git a/ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx b/ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx new file mode 100644 index 0000000..b381bcf --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/ef-core/multitenancy-softdelete-auditing/README.md b/ef-core/multitenancy-softdelete-auditing/README.md new file mode 100644 index 0000000..38ce089 --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/README.md @@ -0,0 +1,197 @@ +# EF Core Tenant Isolation, Soft Delete & Audit Guardrails — Minimal Companion + +A focused .NET 10 / EF Core 10 companion to the EF Core 8-era tutorial: + +**[EF Core 8 Multi-Tenancy: Tenant Isolation, Soft Deletes, Audit Trails & Query Filters](https://www.dotnet-guide.com/tutorials/ef-core/multitenancy-softdelete-auditing/)** + +## What this sample demonstrates + +The full tutorial is deliberately broad (tenant resolution, global filters, soft delete, +audit metadata, background jobs, admin bypass, indexes, compiled queries, APIs, migrations, +testing, database-level isolation). Several of those topics (projection, compiled queries, +`ExecuteUpdate`/`ExecuteDelete`, relationship loading, N+1, `Include`) are already covered by +other companions in this repository. + +This companion answers one narrow question: + +> How can EF Core make shared-table tenant reads fail closed, keep soft-delete bypass +> tenant-safe, reject cross-tenant writes, and prove those guardrails against one relational +> database containing multiple tenants at the same time? + +Specifically it shows: + +- A validated, trusted `TenantExecutionContext` that is injected into every `DbContext`. +- A fail-closed **named `TenantFilter`** that is always active (EF Core 10 named filters). +- A **named `SoftDeleteFilter`** that can be disabled selectively without ever touching the + tenant filter. +- A composite primary key `TenantId + Id` so `alpha/1` and `beta/1` can coexist physically. +- A `SaveChangesInterceptor` (both sync and async paths) that: + - overwrites the tenant on insert from the trusted scope, + - rejects cross-tenant mutations, + - stamps immutable creation audit fields and update audit fields, + - converts `Remove()` into a narrow soft-delete update. +- Deterministic integration tests over one shared SQLite in-memory database where `alpha` + and `beta` tenants live in the same physical database. + +## Architecture + +```text +already-authorized TenantExecutionContext + | + v + EF Core 10 named TenantFilter + + + EF Core 10 named SoftDeleteFilter + | + v + TenantId + Id composite key + | + v + SaveChangesInterceptor + |-- overwrite tenant on insert + |-- reject foreign-tenant mutation + |-- stamp audit metadata + `-- convert Remove() to soft delete + | + v + IgnoreQueryFilters(["SoftDeleteFilter"]) + | + v + SQLite in-memory shared alpha/beta database + | + v + integration tests prove isolation +``` + +## Security design: fail closed + +This sample intentionally does **not** use the tutorial's filter pattern: + +```csharp +!hasTenant || entity.TenantId == tenantId +``` + +That expression is fail-open: a missing tenant context becomes a cross-tenant escape hatch. +Instead the companion requires: + +- a non-empty tenant before a `DbContext` is ever created; +- every normal `DbContext` to belong to exactly one tenant; +- a tenant-equality filter that is always active; +- **no** `SystemTenantContext` that means "all tenants"; +- **no** production helper that calls parameterless `IgnoreQueryFilters()`. + +> `TenantExecutionContext` represents an already-authenticated and authorized tenant scope. +> Normalizing a string is not authorization. + +## EF Core 8 vs EF Core 10 filter behavior + +The website tutorial targets EF Core 8, where tenant isolation and soft deletion on the same +entity must be expressed as **one combined filter expression**. This repository companion +targets EF Core 10 and uses **two named filters** so the soft-delete filter can be bypassed on +its own while the tenant filter stays active: + +```csharp +.HasQueryFilter("TenantFilter", task => task.TenantId == _tenantId) +.HasQueryFilter("SoftDeleteFilter", task => !task.IsDeleted) +``` + +Selective bypass: + +```csharp +IgnoreQueryFilters([TenantDbContext.SoftDeleteFilterName]) +``` + +## File structure + +```text +ef-core/multitenancy-softdelete-auditing/ +|-- EfCoreTenantGuardrailsMinimal.slnx +|-- README.md +|-- src/EfCoreTenantGuardrailsMinimal/ +| |-- EfCoreTenantGuardrailsMinimal.csproj +| |-- Program.cs +| |-- Context/TenantExecutionContext.cs +| |-- Data/TenantDatabase.cs +| |-- Data/TenantDbContext.cs +| |-- Interceptors/TenantAuditInterceptor.cs +| |-- Models/TenantTask.cs +| |-- Queries/TenantQueries.cs +| `-- Services/TenantWorkflow.cs +`-- tests/EfCoreTenantGuardrailsMinimal.Tests/ + |-- EfCoreTenantGuardrailsMinimal.Tests.csproj + `-- TenantGuardrailTests.cs +``` + +## Prerequisites + +- .NET 10 SDK (10.0.302 or later in the `10.0.x` band) + +## Run + +```bash +cd ef-core/multitenancy-softdelete-auditing + +dotnet restore EfCoreTenantGuardrailsMinimal.slnx +dotnet test EfCoreTenantGuardrailsMinimal.slnx --configuration Release + +dotnet run \ + --project src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj \ + --configuration Release +``` + +## Verify + +The console app prints exactly: + +```text +EF Core Tenant Isolation Lab +Visible tasks: alpha=1, beta=1 +Same local ID: alpha=Alpha plan | beta=Beta plan +Alpha including deleted: 2, foreign tenants=0 +Spoofed insert stamped tenant: alpha +Soft delete: visible alpha=1, physical alpha=3, deletedBy=alpha-admin +``` + +The test project runs 8 deterministic xUnit v3 tests covering: + +- same local IDs isolated per tenant and physically coexisting; +- named soft-delete bypass preserving the tenant filter; +- spoofed insert tenant overwrite + audit stamping; +- soft delete preserving the row and stamping deletion metadata; +- modification preserving creation fields and stamping update audit; +- cross-tenant detached update rejected with `beta/1` unchanged; +- missing/blank tenant scope rejected; +- deterministic workflow summary. + +## Important boundary (deliberate exclusions) + +This sample begins **after** authentication and tenant authorization. It does not treat a raw +HTTP header or subdomain as authorization. Kept out so the sample stays small and honest: + +- HTTP tenant headers and subdomain parsing; +- JWT / authentication and tenant membership lookup; +- platform-admin all-tenant access (requires a separate authorization and audit design); +- background jobs; +- hard delete (an explicitly authorized purge path); +- GDPR workflows and compliance claims; +- PostgreSQL RLS / SQL Server security policies (separate defense-in-depth); +- migrations, compiled queries, `ExecuteUpdate` / `ExecuteDelete`; +- Minimal APIs, repositories, Testcontainers, benchmarks. + +Audit columns (`CreatedAt`/`CreatedBy`/`UpdatedAt`/`UpdatedBy`/`DeletedAt`/`DeletedBy`) are +current-row metadata, **not** an immutable historical audit log. SQLite in-memory is a +lightweight relational test double, not production-provider equivalence. This sample makes no +compliance claim. + +## Verification table + +| Target framework | .NET 10 (net10.0) | +| --- | --- | +| EF Core package | Microsoft.EntityFrameworkCore.Sqlite 10.0.11 (only direct app package) | +| Test packages | xunit.v3 3.2.2, xunit.runner.visualstudio 3.1.5, Microsoft.NET.Test.Sdk 18.8.1 | +| External services | none (in-memory SQLite) | +| Last reviewed | 2026-08-17 | + +## License + +MIT — see the repository root [`LICENSE`](../../LICENSE). diff --git a/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Context/TenantExecutionContext.cs b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Context/TenantExecutionContext.cs new file mode 100644 index 0000000..d7e0f5f --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Context/TenantExecutionContext.cs @@ -0,0 +1,31 @@ +namespace EfCoreTenantGuardrailsMinimal.Context; + +public sealed class TenantExecutionContext +{ + public TenantExecutionContext( + string tenantId, + string actorId, + DateTimeOffset utcNow) + { + string normalizedTenant = tenantId.Trim().ToLowerInvariant(); + string normalizedActor = actorId.Trim(); + + if (normalizedTenant.Length is < 1 or > 64) + throw new ArgumentException( + "TenantId must contain between 1 and 64 characters.", + nameof(tenantId)); + + if (normalizedActor.Length is < 1 or > 120) + throw new ArgumentException( + "ActorId must contain between 1 and 120 characters.", + nameof(actorId)); + + TenantId = normalizedTenant; + ActorId = normalizedActor; + UtcNow = utcNow.ToUniversalTime(); + } + + public string TenantId { get; } + public string ActorId { get; } + public DateTimeOffset UtcNow { get; } +} diff --git a/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Data/TenantDatabase.cs b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Data/TenantDatabase.cs new file mode 100644 index 0000000..fc8691d --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Data/TenantDatabase.cs @@ -0,0 +1,89 @@ +using EfCoreTenantGuardrailsMinimal.Context; +using EfCoreTenantGuardrailsMinimal.Interceptors; +using EfCoreTenantGuardrailsMinimal.Models; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreTenantGuardrailsMinimal.Data; + +public sealed class TenantDatabase : IDisposable +{ + public static DateTimeOffset SeedCreatedAt { get; } = new(2026, 8, 14, 8, 0, 0, TimeSpan.Zero); + public static DateTimeOffset SeedDeletedAt { get; } = new(2026, 8, 14, 9, 0, 0, TimeSpan.Zero); + public const string SeedActor = "seeder"; + + private readonly SqliteConnection _connection; + + public TenantDatabase() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + SeedAsync().GetAwaiter().GetResult(); + } + + public TenantDbContext CreateContext(TenantExecutionContext executionContext) + { + DbContextOptions options = + new DbContextOptionsBuilder() + .UseSqlite(_connection) + .AddInterceptors(new TenantAuditInterceptor(executionContext)) + .Options; + + return new TenantDbContext(options, executionContext); + } + + private async Task SeedAsync() + { + await using (TenantDbContext ensure = CreateContext(SeedScope("alpha"))) + { + await ensure.Database.EnsureCreatedAsync(); + } + + await using (TenantDbContext alpha = CreateContext(SeedScope("alpha", SeedCreatedAt))) + { + alpha.Tasks.Add(SeedTask(1, "Alpha plan")); + alpha.Tasks.Add(SeedTask(2, "Alpha archive")); + await alpha.SaveChangesAsync(); + } + + await SoftDeleteRow("alpha", 2, SeedDeletedAt); + + await using (TenantDbContext beta = CreateContext(SeedScope("beta", SeedCreatedAt))) + { + beta.Tasks.Add(SeedTask(1, "Beta plan")); + beta.Tasks.Add(SeedTask(2, "Beta archive")); + await beta.SaveChangesAsync(); + } + + await SoftDeleteRow("beta", 2, SeedDeletedAt); + } + + private TenantExecutionContext SeedScope(string tenantId) => + new(tenantId, SeedActor, SeedCreatedAt); + + private TenantExecutionContext SeedScope(string tenantId, DateTimeOffset time) => + new(tenantId, SeedActor, time); + + private static TenantTask SeedTask(int id, string title) => + new() { Id = id, Title = title }; + + private async Task SoftDeleteRow(string tenantId, int id, DateTimeOffset deletedAt) + { + await using (TenantDbContext context = + CreateContext(new TenantExecutionContext(tenantId, SeedActor, deletedAt))) + { + TenantTask? row = await context.Tasks + .SingleOrDefaultAsync(t => t.Id == id); + if (row is not null) + { + context.Remove(row); + await context.SaveChangesAsync(); + } + } + } + + public void Dispose() + { + _connection.Dispose(); + } +} diff --git a/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Data/TenantDbContext.cs b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Data/TenantDbContext.cs new file mode 100644 index 0000000..46252c3 --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Data/TenantDbContext.cs @@ -0,0 +1,66 @@ +using EfCoreTenantGuardrailsMinimal.Context; +using EfCoreTenantGuardrailsMinimal.Models; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreTenantGuardrailsMinimal.Data; + +public sealed class TenantDbContext : DbContext +{ + public const string TenantFilterName = "TenantFilter"; + public const string SoftDeleteFilterName = "SoftDeleteFilter"; + + private readonly string _tenantId; + + public TenantDbContext( + DbContextOptions options, + TenantExecutionContext executionContext) + : base(options) + { + _tenantId = executionContext.TenantId; + } + + public DbSet Tasks => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(task => new { task.TenantId, task.Id }); + + entity.Property(task => task.TenantId) + .HasMaxLength(64) + .IsRequired(); + + entity.Property(task => task.Title) + .HasMaxLength(160) + .IsRequired(); + + entity.Property(task => task.CreatedBy) + .HasMaxLength(120) + .IsRequired(); + + entity.Property(task => task.UpdatedBy) + .HasMaxLength(120) + .IsRequired(); + + entity.Property(task => task.DeletedBy) + .HasMaxLength(120); + + entity + .HasQueryFilter( + TenantFilterName, + task => task.TenantId == _tenantId) + .HasQueryFilter( + SoftDeleteFilterName, + task => !task.IsDeleted); + + entity.HasIndex(task => new + { + task.TenantId, + task.IsDeleted, + task.Id + }) + .HasDatabaseName("IX_TenantTasks_Tenant_Deleted_Id"); + }); + } +} diff --git a/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj new file mode 100644 index 0000000..e1c7668 --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + enable + enable + true + + + + + + diff --git a/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Interceptors/TenantAuditInterceptor.cs b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Interceptors/TenantAuditInterceptor.cs new file mode 100644 index 0000000..87bb723 --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Interceptors/TenantAuditInterceptor.cs @@ -0,0 +1,123 @@ +using EfCoreTenantGuardrailsMinimal.Context; +using EfCoreTenantGuardrailsMinimal.Data; +using EfCoreTenantGuardrailsMinimal.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace EfCoreTenantGuardrailsMinimal.Interceptors; + +public sealed class TenantAuditInterceptor( + TenantExecutionContext executionContext) + : SaveChangesInterceptor +{ + public override InterceptionResult SavingChanges( + DbContextEventData eventData, + InterceptionResult result) + { + Apply(eventData.Context); + return result; + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + Apply(eventData.Context); + return ValueTask.FromResult(result); + } + + private void Apply(DbContext? dbContext) + { + if (dbContext is not TenantDbContext context) + return; + + context.ChangeTracker.DetectChanges(); + + foreach (EntityEntry entry in + context.ChangeTracker.Entries()) + { + switch (entry.State) + { + case EntityState.Added: + StampAdded(entry); + break; + + case EntityState.Modified: + GuardTenant(entry); + StampModified(entry); + break; + + case EntityState.Deleted: + GuardTenant(entry); + ConvertToSoftDelete(entry); + break; + } + } + } + + private void StampAdded(EntityEntry entry) + { + // Caller input never chooses the tenant partition. + entry.Entity.TenantId = executionContext.TenantId; + entry.Entity.IsDeleted = false; + entry.Entity.DeletedAt = null; + entry.Entity.DeletedBy = null; + + entry.Entity.CreatedAt = executionContext.UtcNow; + entry.Entity.CreatedBy = executionContext.ActorId; + entry.Entity.UpdatedAt = executionContext.UtcNow; + entry.Entity.UpdatedBy = executionContext.ActorId; + } + + private void GuardTenant(EntityEntry entry) + { + if (!string.Equals( + entry.Entity.TenantId, + executionContext.TenantId, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "Cross-tenant mutation is not allowed."); + } + } + + private void StampModified(EntityEntry entry) + { + entry.Property(task => task.TenantId).IsModified = false; + entry.Property(task => task.CreatedAt).IsModified = false; + entry.Property(task => task.CreatedBy).IsModified = false; + + // Normal modifications cannot impersonate the soft-delete path. + Reset(entry, task => task.IsDeleted); + Reset(entry, task => task.DeletedAt); + Reset(entry, task => task.DeletedBy); + + entry.Entity.UpdatedAt = executionContext.UtcNow; + entry.Entity.UpdatedBy = executionContext.ActorId; + } + + private static void Reset( + EntityEntry entry, + System.Linq.Expressions.Expression> property) + { + PropertyEntry value = entry.Property(property); + value.CurrentValue = value.OriginalValue; + value.IsModified = false; + } + + private void ConvertToSoftDelete(EntityEntry entry) + { + // Avoid turning the entire row into a broad UPDATE. + entry.State = EntityState.Unchanged; + + entry.Entity.IsDeleted = true; + entry.Entity.DeletedAt = executionContext.UtcNow; + entry.Entity.DeletedBy = executionContext.ActorId; + + entry.Property(task => task.IsDeleted).IsModified = true; + entry.Property(task => task.DeletedAt).IsModified = true; + entry.Property(task => task.DeletedBy).IsModified = true; + } +} diff --git a/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Models/TenantTask.cs b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Models/TenantTask.cs new file mode 100644 index 0000000..129c44a --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Models/TenantTask.cs @@ -0,0 +1,18 @@ +namespace EfCoreTenantGuardrailsMinimal.Models; + +public sealed class TenantTask +{ + public string TenantId { get; set; } = ""; + public int Id { get; set; } + public string Title { get; set; } = ""; + + public bool IsDeleted { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public string CreatedBy { get; set; } = ""; + public DateTimeOffset UpdatedAt { get; set; } + public string UpdatedBy { get; set; } = ""; + + public DateTimeOffset? DeletedAt { get; set; } + public string? DeletedBy { get; set; } +} diff --git a/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Program.cs b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Program.cs new file mode 100644 index 0000000..d70e117 --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Program.cs @@ -0,0 +1,10 @@ +using EfCoreTenantGuardrailsMinimal.Services; + +WorkflowSummary summary = await TenantWorkflow.RunAsync(); + +Console.WriteLine("EF Core Tenant Isolation Lab"); +Console.WriteLine($"Visible tasks: alpha={summary.VisibleAlpha}, beta={summary.VisibleBeta}"); +Console.WriteLine($"Same local ID: alpha={summary.SameIdAlphaTitle} | beta={summary.SameIdBetaTitle}"); +Console.WriteLine($"Alpha including deleted: {summary.AlphaIncludingDeletedCount}, foreign tenants={summary.ForeignTenantsUnderBypass}"); +Console.WriteLine($"Spoofed insert stamped tenant: {summary.StampedTenantAfterSpoof}"); +Console.WriteLine($"Soft delete: visible alpha={summary.VisibleAlphaAfterSoftDelete}, physical alpha={summary.PhysicalAlphaAfterSoftDelete}, deletedBy={summary.SoftDeleteActor}"); diff --git a/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Queries/TenantQueries.cs b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Queries/TenantQueries.cs new file mode 100644 index 0000000..65b242a --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Queries/TenantQueries.cs @@ -0,0 +1,25 @@ +using EfCoreTenantGuardrailsMinimal.Data; +using EfCoreTenantGuardrailsMinimal.Models; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreTenantGuardrailsMinimal.Queries; + +public static class TenantQueries +{ + public static Task> ListVisibleAsync( + TenantDbContext context, + CancellationToken cancellationToken = default) => + context.Tasks + .AsNoTracking() + .OrderBy(task => task.Id) + .ToListAsync(cancellationToken); + + public static Task> ListIncludingDeletedAsync( + TenantDbContext context, + CancellationToken cancellationToken = default) => + context.Tasks + .IgnoreQueryFilters([TenantDbContext.SoftDeleteFilterName]) + .AsNoTracking() + .OrderBy(task => task.Id) + .ToListAsync(cancellationToken); +} diff --git a/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Services/TenantWorkflow.cs b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Services/TenantWorkflow.cs new file mode 100644 index 0000000..3411fbc --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Services/TenantWorkflow.cs @@ -0,0 +1,99 @@ +using EfCoreTenantGuardrailsMinimal.Context; +using EfCoreTenantGuardrailsMinimal.Data; +using EfCoreTenantGuardrailsMinimal.Models; +using EfCoreTenantGuardrailsMinimal.Queries; +using Microsoft.EntityFrameworkCore; + +namespace EfCoreTenantGuardrailsMinimal.Services; + +public sealed record WorkflowSummary( + int VisibleAlpha, + int VisibleBeta, + string SameIdAlphaTitle, + string SameIdBetaTitle, + int AlphaIncludingDeletedCount, + int ForeignTenantsUnderBypass, + string StampedTenantAfterSpoof, + int VisibleAlphaAfterSoftDelete, + int PhysicalAlphaAfterSoftDelete, + string? SoftDeleteActor); + +public static class TenantWorkflow +{ + private static DateTimeOffset Fixed(int hour) => + new(2026, 8, 14, hour, 0, 0, TimeSpan.Zero); + + public static async Task RunAsync( + CancellationToken cancellationToken = default) + { + using TenantDatabase database = new(); + + TenantExecutionContext alphaView = new("alpha", "viewer", Fixed(10)); + TenantExecutionContext betaView = new("beta", "viewer", Fixed(10)); + + List visibleAlpha; + List visibleBeta; + List alphaIncludingDeleted; + + await using (TenantDbContext alpha = database.CreateContext(alphaView)) + { + visibleAlpha = await TenantQueries.ListVisibleAsync(alpha, cancellationToken); + alphaIncludingDeleted = + await TenantQueries.ListIncludingDeletedAsync(alpha, cancellationToken); + } + + await using (TenantDbContext beta = database.CreateContext(betaView)) + { + visibleBeta = await TenantQueries.ListVisibleAsync(beta, cancellationToken); + } + + string spoofedTenant; + await using (TenantDbContext alpha = + database.CreateContext(new TenantExecutionContext("alpha", "alice", Fixed(11)))) + { + TenantTask spoofed = new() { Id = 3, TenantId = "beta", Title = "Spoofed" }; + alpha.Tasks.Add(spoofed); + await alpha.SaveChangesAsync(cancellationToken); + spoofedTenant = spoofed.TenantId; + } + + await using (TenantDbContext alpha = + database.CreateContext(new TenantExecutionContext("alpha", "alpha-admin", Fixed(12)))) + { + List matches = await alpha.Tasks + .AsNoTracking() + .Where(t => t.Id == 1) + .ToListAsync(cancellationToken); + TenantTask target = matches.Single(); + alpha.Remove(target); + await alpha.SaveChangesAsync(cancellationToken); + } + + int visibleAfter; + int physicalAfter; + string? softDeleteActor; + await using (TenantDbContext alpha = database.CreateContext(alphaView)) + { + List afterVisible = + await TenantQueries.ListVisibleAsync(alpha, cancellationToken); + List afterDeleted = + await TenantQueries.ListIncludingDeletedAsync(alpha, cancellationToken); + + visibleAfter = afterVisible.Count; + physicalAfter = afterDeleted.Count; + softDeleteActor = afterDeleted.Single(t => t.Id == 1).DeletedBy; + } + + return new WorkflowSummary( + visibleAlpha.Count, + visibleBeta.Count, + visibleAlpha.Single(t => t.Id == 1).Title, + visibleBeta.Single(t => t.Id == 1).Title, + alphaIncludingDeleted.Count, + alphaIncludingDeleted.Count(t => t.TenantId != "alpha"), + spoofedTenant, + visibleAfter, + physicalAfter, + softDeleteActor); + } +} diff --git a/ef-core/multitenancy-softdelete-auditing/tests/EfCoreTenantGuardrailsMinimal.Tests/EfCoreTenantGuardrailsMinimal.Tests.csproj b/ef-core/multitenancy-softdelete-auditing/tests/EfCoreTenantGuardrailsMinimal.Tests/EfCoreTenantGuardrailsMinimal.Tests.csproj new file mode 100644 index 0000000..1b59727 --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/tests/EfCoreTenantGuardrailsMinimal.Tests/EfCoreTenantGuardrailsMinimal.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + false + true + true + + + + + + + + + + + + diff --git a/ef-core/multitenancy-softdelete-auditing/tests/EfCoreTenantGuardrailsMinimal.Tests/TenantGuardrailTests.cs b/ef-core/multitenancy-softdelete-auditing/tests/EfCoreTenantGuardrailsMinimal.Tests/TenantGuardrailTests.cs new file mode 100644 index 0000000..8342f3d --- /dev/null +++ b/ef-core/multitenancy-softdelete-auditing/tests/EfCoreTenantGuardrailsMinimal.Tests/TenantGuardrailTests.cs @@ -0,0 +1,199 @@ +using EfCoreTenantGuardrailsMinimal.Context; +using EfCoreTenantGuardrailsMinimal.Data; +using EfCoreTenantGuardrailsMinimal.Models; +using EfCoreTenantGuardrailsMinimal.Queries; +using EfCoreTenantGuardrailsMinimal.Services; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace EfCoreTenantGuardrailsMinimal.Tests; + +public sealed class TenantGuardrailTests +{ + private static TenantExecutionContext Scope(string tenantId, string actorId, int hour) => + new(tenantId, actorId, new DateTimeOffset(2026, 8, 14, hour, 0, 0, TimeSpan.Zero)); + + [Fact] + public async Task Tenant_filter_isolates_same_local_ids() + { + using TenantDatabase db = new(); + CancellationToken ct = TestContext.Current.CancellationToken; + + await using TenantDbContext alpha = db.CreateContext(Scope("alpha", "viewer", 10)); + await using TenantDbContext beta = db.CreateContext(Scope("beta", "viewer", 10)); + + List alphaRows = await TenantQueries.ListVisibleAsync(alpha, ct); + List betaRows = await TenantQueries.ListVisibleAsync(beta, ct); + + Assert.Equal("Alpha plan", alphaRows.Single(t => t.Id == 1).Title); + Assert.Equal("Beta plan", betaRows.Single(t => t.Id == 1).Title); + Assert.All(alphaRows, t => Assert.Equal("alpha", t.TenantId)); + Assert.DoesNotContain(alphaRows, t => t.TenantId == "beta"); + + // Both rows physically coexist in the same database (named-filter proof). + List all = await alpha.Tasks + .IgnoreQueryFilters( + [TenantDbContext.TenantFilterName, TenantDbContext.SoftDeleteFilterName]) + .AsNoTracking() + .Where(t => t.Id == 1) + .ToListAsync(ct); + + Assert.Contains(all, t => t.TenantId == "alpha"); + Assert.Contains(all, t => t.TenantId == "beta"); + } + + [Fact] + public async Task Named_soft_delete_bypass_preserves_tenant_filter() + { + using TenantDatabase db = new(); + CancellationToken ct = TestContext.Current.CancellationToken; + + await using TenantDbContext alpha = db.CreateContext(Scope("alpha", "viewer", 10)); + + var normal = await TenantQueries.ListVisibleAsync(alpha, ct); + Assert.Single(normal); + + var withDeleted = await TenantQueries.ListIncludingDeletedAsync(alpha, ct); + Assert.Equal(2, withDeleted.Count); + Assert.All(withDeleted, t => Assert.Equal("alpha", t.TenantId)); + Assert.Single(withDeleted, t => t.IsDeleted); + } + + [Fact] + public async Task Added_entity_overwrites_spoofed_tenant_and_stamps_audit() + { + using TenantDatabase db = new(); + CancellationToken ct = TestContext.Current.CancellationToken; + DateTimeOffset fixedTime = new(2026, 8, 14, 11, 0, 0, TimeSpan.Zero); + + await using TenantDbContext alpha = + db.CreateContext(new TenantExecutionContext("alpha", "alice", fixedTime)); + + TenantTask entity = new() + { + Id = 3, + TenantId = "beta", // spoofed caller input + Title = "Spoofed" + }; + alpha.Tasks.Add(entity); + await alpha.SaveChangesAsync(ct); + + Assert.Equal("alpha", entity.TenantId); + Assert.Equal("alice", entity.CreatedBy); + Assert.Equal("alice", entity.UpdatedBy); + Assert.Equal(fixedTime, entity.CreatedAt); + Assert.Equal(fixedTime, entity.UpdatedAt); + Assert.False(entity.IsDeleted); + } + + [Fact] + public async Task Soft_delete_preserves_row_and_stamps_deletion() + { + using TenantDatabase db = new(); + CancellationToken ct = TestContext.Current.CancellationToken; + DateTimeOffset fixedTime = new(2026, 8, 14, 12, 0, 0, TimeSpan.Zero); + + await using TenantDbContext alpha = + db.CreateContext(new TenantExecutionContext("alpha", "deleter", fixedTime)); + + List targetRows = await alpha.Tasks + .AsNoTracking() + .Where(t => t.Id == 1) + .ToListAsync(ct); + TenantTask target = targetRows.Single(); + alpha.Remove(target); + await alpha.SaveChangesAsync(ct); + + var normal = await TenantQueries.ListVisibleAsync(alpha, ct); + Assert.DoesNotContain(normal, t => t.Id == 1); + + var withDeleted = await TenantQueries.ListIncludingDeletedAsync(alpha, ct); + TenantTask deleted = withDeleted.Single(t => t.Id == 1); + Assert.True(deleted.IsDeleted); + Assert.Equal("deleter", deleted.DeletedBy); + Assert.Equal(fixedTime, deleted.DeletedAt); + Assert.Equal(2, withDeleted.Count); + } + + [Fact] + public async Task Modified_entity_preserves_creation_fields_and_stamps_update() + { + using TenantDatabase db = new(); + CancellationToken ct = TestContext.Current.CancellationToken; + DateTimeOffset updateTime = new(2026, 8, 14, 14, 0, 0, TimeSpan.Zero); + + await using TenantDbContext writer = + db.CreateContext(new TenantExecutionContext("alpha", "bob", updateTime)); + + TenantTask task = (await ((IQueryable)writer.Tasks) + .Where(t => t.Id == 1) + .ToListAsync(ct)).Single(); + task.Title = "Renamed alpha plan"; + await writer.SaveChangesAsync(ct); + + Assert.Equal(TenantDatabase.SeedCreatedAt, task.CreatedAt); + Assert.Equal(TenantDatabase.SeedActor, task.CreatedBy); + Assert.Equal(updateTime, task.UpdatedAt); + Assert.Equal("bob", task.UpdatedBy); + Assert.False(task.IsDeleted); + } + + [Fact] + public async Task Cross_tenant_detached_update_is_rejected() + { + using TenantDatabase db = new(); + CancellationToken ct = TestContext.Current.CancellationToken; + + await using TenantDbContext alpha = db.CreateContext(Scope("alpha", "bob", 14)); + + TenantTask detached = new() + { + TenantId = "beta", + Id = 1, + Title = "Hijacked" + }; + alpha.Tasks.Attach(detached); + alpha.Entry(detached).State = EntityState.Modified; + detached.Title = "Hijacked beta row"; + + var ex = await Assert.ThrowsAsync( + () => alpha.SaveChangesAsync(ct)); + Assert.Equal("Cross-tenant mutation is not allowed.", ex.Message); + + await using TenantDbContext beta = db.CreateContext(Scope("beta", "viewer", 10)); + List betaRows = await beta.Tasks + .Where(t => t.Id == 1) + .ToListAsync(ct); + TenantTask betaRow = betaRows.Single(); + Assert.Equal("Beta plan", betaRow.Title); + } + + [Fact] + public void Missing_or_blank_tenant_scope_is_rejected() + { + DateTimeOffset now = new(2026, 8, 14, 8, 0, 0, TimeSpan.Zero); + + Assert.Throws( + () => new TenantExecutionContext("", "alice", now)); + Assert.Throws( + () => new TenantExecutionContext(" ", "alice", now)); + } + + [Fact] + public async Task Workflow_returns_deterministic_summary() + { + WorkflowSummary summary = + await TenantWorkflow.RunAsync(TestContext.Current.CancellationToken); + + Assert.Equal(1, summary.VisibleAlpha); + Assert.Equal(1, summary.VisibleBeta); + Assert.Equal("Alpha plan", summary.SameIdAlphaTitle); + Assert.Equal("Beta plan", summary.SameIdBetaTitle); + Assert.Equal(2, summary.AlphaIncludingDeletedCount); + Assert.Equal(0, summary.ForeignTenantsUnderBypass); + Assert.Equal("alpha", summary.StampedTenantAfterSpoof); + Assert.Equal(1, summary.VisibleAlphaAfterSoftDelete); + Assert.Equal(3, summary.PhysicalAlphaAfterSoftDelete); + Assert.Equal("alpha-admin", summary.SoftDeleteActor); + } +} From 8860b2910691b0d0b234cf3c894d787e11979287 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Mon, 17 Aug 2026 09:26:01 +0000 Subject: [PATCH 2/3] Add tenant guardrails sample to repository README --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 007be76..9068cfc 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`dotnet-8-essentials/observability-opentelemetry`](dotnet-8-essentials/observability-opentelemetry/) | Focused .NET 10 OpenTelemetry companion demonstrating ASP.NET Core request instrumentation, custom ActivitySource spans, low-cardinality Meter metrics, structured ILogger events, automatic log-to-trace correlation, console export, and deterministic tests without external observability infrastructure | [.NET 8 Observability with OpenTelemetry: Tracing, Metrics & Structured Logging](https://www.dotnet-guide.com/tutorials/dotnet-8-essentials/observability-opentelemetry/) | | [`ef-core/advanced-modeling-performance`](ef-core/advanced-modeling-performance/) | Focused .NET 10 / EF Core 10 relational-data companion demonstrating DTO projection, no-tracking reads, a compiled hot-path query, named soft-delete filtering, ExecuteUpdate, ExecuteDelete, and explicit change-tracker caveats with deterministic SQLite tests | [EF Core Advanced Modeling & Performance: Owned Types, Converters, JSON/Temporal Tables, Compiled Queries](https://www.dotnet-guide.com/tutorials/ef-core/advanced-modeling-performance/) | | [`ef-core/modern-data-access-dotnet`](ef-core/modern-data-access-dotnet/) | Focused .NET 10 / EF Core 10 relationship-loading companion demonstrating one-to-many and many-to-many modeling, an intentional N+1 baseline, eager and filtered Include, split-query loading, explicit loading, SELECT-command counting, and deterministic SQLite verification | [EF Core 8 Fundamentals: Modern Data Access with .NET 8](https://www.dotnet-guide.com/tutorials/ef-core/modern-data-access-dotnet/) | +| [`ef-core/multitenancy-softdelete-auditing`](ef-core/multitenancy-softdelete-auditing/) | Focused .NET 10 / EF Core 10 multi-tenant data guardrail companion demonstrating fail-closed tenant filtering, named soft-delete filtering, tenant-scoped composite identity, SaveChanges interception, automatic audit metadata, selective deleted-record access, cross-tenant mutation rejection, and deterministic SQLite isolation tests | [EF Core 8 Multi-Tenancy: Tenant Isolation, Soft Deletes, Audit Trails & Query Filters](https://www.dotnet-guide.com/tutorials/ef-core/multitenancy-softdelete-auditing/) | ## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) @@ -493,7 +494,7 @@ tutorials/ | `-- EfCoreHotPathMinimal.Tests/ | |-- EfCoreHotPathMinimal.Tests.csproj | `-- EfCoreHotPathTests.cs -| `-- modern-data-access-dotnet/ +| |-- modern-data-access-dotnet/ | |-- EfCoreRelationshipsMinimal.slnx | |-- README.md | |-- src/ @@ -513,6 +514,30 @@ tutorials/ | `-- EfCoreRelationshipsMinimal.Tests/ | |-- EfCoreRelationshipsMinimal.Tests.csproj | `-- EfCoreRelationshipsTests.cs +| `-- multitenancy-softdelete-auditing/ +| |-- EfCoreTenantGuardrailsMinimal.slnx +| |-- README.md +| |-- src/ +| | `-- EfCoreTenantGuardrailsMinimal/ +| | |-- EfCoreTenantGuardrailsMinimal.csproj +| | |-- Program.cs +| | |-- Context/ +| | | `-- TenantExecutionContext.cs +| | |-- Data/ +| | | |-- TenantDatabase.cs +| | | `-- TenantDbContext.cs +| | |-- Interceptors/ +| | | `-- TenantAuditInterceptor.cs +| | |-- Models/ +| | | `-- TenantTask.cs +| | |-- Queries/ +| | | `-- TenantQueries.cs +| | `-- Services/ +| | `-- TenantWorkflow.cs +| `-- tests/ +| `-- EfCoreTenantGuardrailsMinimal.Tests/ +| |-- EfCoreTenantGuardrailsMinimal.Tests.csproj +| `-- TenantGuardrailTests.cs |-- .github/ | `-- workflows/ | `-- build-samples.yml From 325fd152cf23ff8f1661ab795ceac651a95b77e1 Mon Sep 17 00:00:00 2001 From: BALAJI BASHYAM Date: Mon, 17 Aug 2026 09:26:01 +0000 Subject: [PATCH 3/3] Test EF Core tenant isolation in GitHub Actions --- .github/workflows/build-samples.yml | 97 +++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index abb55f5..3f1de2d 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -29,6 +29,7 @@ on: - "dotnet-8-essentials/observability-opentelemetry/**" - "ef-core/advanced-modeling-performance/**" - "ef-core/modern-data-access-dotnet/**" + - "ef-core/multitenancy-softdelete-auditing/**" - ".github/workflows/build-samples.yml" pull_request: @@ -58,6 +59,7 @@ on: - "dotnet-8-essentials/observability-opentelemetry/**" - "ef-core/advanced-modeling-performance/**" - "ef-core/modern-data-access-dotnet/**" + - "ef-core/multitenancy-softdelete-auditing/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -1773,3 +1775,98 @@ jobs: echo "Generated SQLite database file found in sample tree." exit 1 fi + + test-ef-core-tenant-guardrails: + name: Test EF Core tenant guardrails sample + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx + + - name: Build + run: > + dotnet build + ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx + --configuration Release + --no-build + + - name: Verify EF Core package baseline + shell: bash + run: | + project="ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj" + + test "$(grep --count '