|
| 1 | +# EF Core Tenant Isolation, Soft Delete & Audit Guardrails — Minimal Companion |
| 2 | + |
| 3 | +A focused .NET 10 / EF Core 10 companion to the EF Core 8-era tutorial: |
| 4 | + |
| 5 | +**[EF Core 8 Multi-Tenancy: Tenant Isolation, Soft Deletes, Audit Trails & Query Filters](https://www.dotnet-guide.com/tutorials/ef-core/multitenancy-softdelete-auditing/)** |
| 6 | + |
| 7 | +## What this sample demonstrates |
| 8 | + |
| 9 | +The full tutorial is deliberately broad (tenant resolution, global filters, soft delete, |
| 10 | +audit metadata, background jobs, admin bypass, indexes, compiled queries, APIs, migrations, |
| 11 | +testing, database-level isolation). Several of those topics (projection, compiled queries, |
| 12 | +`ExecuteUpdate`/`ExecuteDelete`, relationship loading, N+1, `Include`) are already covered by |
| 13 | +other companions in this repository. |
| 14 | + |
| 15 | +This companion answers one narrow question: |
| 16 | + |
| 17 | +> How can EF Core make shared-table tenant reads fail closed, keep soft-delete bypass |
| 18 | +> tenant-safe, reject cross-tenant writes, and prove those guardrails against one relational |
| 19 | +> database containing multiple tenants at the same time? |
| 20 | +
|
| 21 | +Specifically it shows: |
| 22 | + |
| 23 | +- A validated, trusted `TenantExecutionContext` that is injected into every `DbContext`. |
| 24 | +- A fail-closed **named `TenantFilter`** that is always active (EF Core 10 named filters). |
| 25 | +- A **named `SoftDeleteFilter`** that can be disabled selectively without ever touching the |
| 26 | + tenant filter. |
| 27 | +- A composite primary key `TenantId + Id` so `alpha/1` and `beta/1` can coexist physically. |
| 28 | +- A `SaveChangesInterceptor` (both sync and async paths) that: |
| 29 | + - overwrites the tenant on insert from the trusted scope, |
| 30 | + - rejects cross-tenant mutations, |
| 31 | + - stamps immutable creation audit fields and update audit fields, |
| 32 | + - converts `Remove()` into a narrow soft-delete update. |
| 33 | +- Deterministic integration tests over one shared SQLite in-memory database where `alpha` |
| 34 | + and `beta` tenants live in the same physical database. |
| 35 | + |
| 36 | +## Architecture |
| 37 | + |
| 38 | +```text |
| 39 | +already-authorized TenantExecutionContext |
| 40 | + | |
| 41 | + v |
| 42 | + EF Core 10 named TenantFilter |
| 43 | + + |
| 44 | + EF Core 10 named SoftDeleteFilter |
| 45 | + | |
| 46 | + v |
| 47 | + TenantId + Id composite key |
| 48 | + | |
| 49 | + v |
| 50 | + SaveChangesInterceptor |
| 51 | + |-- overwrite tenant on insert |
| 52 | + |-- reject foreign-tenant mutation |
| 53 | + |-- stamp audit metadata |
| 54 | + `-- convert Remove() to soft delete |
| 55 | + | |
| 56 | + v |
| 57 | + IgnoreQueryFilters(["SoftDeleteFilter"]) |
| 58 | + | |
| 59 | + v |
| 60 | + SQLite in-memory shared alpha/beta database |
| 61 | + | |
| 62 | + v |
| 63 | + integration tests prove isolation |
| 64 | +``` |
| 65 | + |
| 66 | +## Security design: fail closed |
| 67 | + |
| 68 | +This sample intentionally does **not** use the tutorial's filter pattern: |
| 69 | + |
| 70 | +```csharp |
| 71 | +!hasTenant || entity.TenantId == tenantId |
| 72 | +``` |
| 73 | + |
| 74 | +That expression is fail-open: a missing tenant context becomes a cross-tenant escape hatch. |
| 75 | +Instead the companion requires: |
| 76 | + |
| 77 | +- a non-empty tenant before a `DbContext` is ever created; |
| 78 | +- every normal `DbContext` to belong to exactly one tenant; |
| 79 | +- a tenant-equality filter that is always active; |
| 80 | +- **no** `SystemTenantContext` that means "all tenants"; |
| 81 | +- **no** production helper that calls parameterless `IgnoreQueryFilters()`. |
| 82 | + |
| 83 | +> `TenantExecutionContext` represents an already-authenticated and authorized tenant scope. |
| 84 | +> Normalizing a string is not authorization. |
| 85 | +
|
| 86 | +## EF Core 8 vs EF Core 10 filter behavior |
| 87 | + |
| 88 | +The website tutorial targets EF Core 8, where tenant isolation and soft deletion on the same |
| 89 | +entity must be expressed as **one combined filter expression**. This repository companion |
| 90 | +targets EF Core 10 and uses **two named filters** so the soft-delete filter can be bypassed on |
| 91 | +its own while the tenant filter stays active: |
| 92 | + |
| 93 | +```csharp |
| 94 | +.HasQueryFilter("TenantFilter", task => task.TenantId == _tenantId) |
| 95 | +.HasQueryFilter("SoftDeleteFilter", task => !task.IsDeleted) |
| 96 | +``` |
| 97 | + |
| 98 | +Selective bypass: |
| 99 | + |
| 100 | +```csharp |
| 101 | +IgnoreQueryFilters([TenantDbContext.SoftDeleteFilterName]) |
| 102 | +``` |
| 103 | + |
| 104 | +## File structure |
| 105 | + |
| 106 | +```text |
| 107 | +ef-core/multitenancy-softdelete-auditing/ |
| 108 | +|-- EfCoreTenantGuardrailsMinimal.slnx |
| 109 | +|-- README.md |
| 110 | +|-- src/EfCoreTenantGuardrailsMinimal/ |
| 111 | +| |-- EfCoreTenantGuardrailsMinimal.csproj |
| 112 | +| |-- Program.cs |
| 113 | +| |-- Context/TenantExecutionContext.cs |
| 114 | +| |-- Data/TenantDatabase.cs |
| 115 | +| |-- Data/TenantDbContext.cs |
| 116 | +| |-- Interceptors/TenantAuditInterceptor.cs |
| 117 | +| |-- Models/TenantTask.cs |
| 118 | +| |-- Queries/TenantQueries.cs |
| 119 | +| `-- Services/TenantWorkflow.cs |
| 120 | +`-- tests/EfCoreTenantGuardrailsMinimal.Tests/ |
| 121 | + |-- EfCoreTenantGuardrailsMinimal.Tests.csproj |
| 122 | + `-- TenantGuardrailTests.cs |
| 123 | +``` |
| 124 | + |
| 125 | +## Prerequisites |
| 126 | + |
| 127 | +- .NET 10 SDK (10.0.302 or later in the `10.0.x` band) |
| 128 | + |
| 129 | +## Run |
| 130 | + |
| 131 | +```bash |
| 132 | +cd ef-core/multitenancy-softdelete-auditing |
| 133 | + |
| 134 | +dotnet restore EfCoreTenantGuardrailsMinimal.slnx |
| 135 | +dotnet test EfCoreTenantGuardrailsMinimal.slnx --configuration Release |
| 136 | + |
| 137 | +dotnet run \ |
| 138 | + --project src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj \ |
| 139 | + --configuration Release |
| 140 | +``` |
| 141 | + |
| 142 | +## Verify |
| 143 | + |
| 144 | +The console app prints exactly: |
| 145 | + |
| 146 | +```text |
| 147 | +EF Core Tenant Isolation Lab |
| 148 | +Visible tasks: alpha=1, beta=1 |
| 149 | +Same local ID: alpha=Alpha plan | beta=Beta plan |
| 150 | +Alpha including deleted: 2, foreign tenants=0 |
| 151 | +Spoofed insert stamped tenant: alpha |
| 152 | +Soft delete: visible alpha=1, physical alpha=3, deletedBy=alpha-admin |
| 153 | +``` |
| 154 | + |
| 155 | +The test project runs 8 deterministic xUnit v3 tests covering: |
| 156 | + |
| 157 | +- same local IDs isolated per tenant and physically coexisting; |
| 158 | +- named soft-delete bypass preserving the tenant filter; |
| 159 | +- spoofed insert tenant overwrite + audit stamping; |
| 160 | +- soft delete preserving the row and stamping deletion metadata; |
| 161 | +- modification preserving creation fields and stamping update audit; |
| 162 | +- cross-tenant detached update rejected with `beta/1` unchanged; |
| 163 | +- missing/blank tenant scope rejected; |
| 164 | +- deterministic workflow summary. |
| 165 | + |
| 166 | +## Important boundary (deliberate exclusions) |
| 167 | + |
| 168 | +This sample begins **after** authentication and tenant authorization. It does not treat a raw |
| 169 | +HTTP header or subdomain as authorization. Kept out so the sample stays small and honest: |
| 170 | + |
| 171 | +- HTTP tenant headers and subdomain parsing; |
| 172 | +- JWT / authentication and tenant membership lookup; |
| 173 | +- platform-admin all-tenant access (requires a separate authorization and audit design); |
| 174 | +- background jobs; |
| 175 | +- hard delete (an explicitly authorized purge path); |
| 176 | +- GDPR workflows and compliance claims; |
| 177 | +- PostgreSQL RLS / SQL Server security policies (separate defense-in-depth); |
| 178 | +- migrations, compiled queries, `ExecuteUpdate` / `ExecuteDelete`; |
| 179 | +- Minimal APIs, repositories, Testcontainers, benchmarks. |
| 180 | + |
| 181 | +Audit columns (`CreatedAt`/`CreatedBy`/`UpdatedAt`/`UpdatedBy`/`DeletedAt`/`DeletedBy`) are |
| 182 | +current-row metadata, **not** an immutable historical audit log. SQLite in-memory is a |
| 183 | +lightweight relational test double, not production-provider equivalence. This sample makes no |
| 184 | +compliance claim. |
| 185 | + |
| 186 | +## Verification table |
| 187 | + |
| 188 | +| Target framework | .NET 10 (net10.0) | |
| 189 | +| --- | --- | |
| 190 | +| EF Core package | Microsoft.EntityFrameworkCore.Sqlite 10.0.11 (only direct app package) | |
| 191 | +| Test packages | xunit.v3 3.2.2, xunit.runner.visualstudio 3.1.5, Microsoft.NET.Test.Sdk 18.8.1 | |
| 192 | +| External services | none (in-memory SQLite) | |
| 193 | +| Last reviewed | 2026-08-17 | |
| 194 | + |
| 195 | +## License |
| 196 | + |
| 197 | +MIT — see the repository root [`LICENSE`](../../LICENSE). |
0 commit comments