Skip to content

Commit 3ef3076

Browse files
Merge pull request #24 from dotnet-guide-com/feature/ef-core-tenant-guardrails-sample
Add lightweight EF Core tenant guardrails sample
2 parents 0b795a4 + 325fd15 commit 3ef3076

15 files changed

Lines changed: 1017 additions & 1 deletion

File tree

‎.github/workflows/build-samples.yml‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ on:
2929
- "dotnet-8-essentials/observability-opentelemetry/**"
3030
- "ef-core/advanced-modeling-performance/**"
3131
- "ef-core/modern-data-access-dotnet/**"
32+
- "ef-core/multitenancy-softdelete-auditing/**"
3233
- ".github/workflows/build-samples.yml"
3334

3435
pull_request:
@@ -58,6 +59,7 @@ on:
5859
- "dotnet-8-essentials/observability-opentelemetry/**"
5960
- "ef-core/advanced-modeling-performance/**"
6061
- "ef-core/modern-data-access-dotnet/**"
62+
- "ef-core/multitenancy-softdelete-auditing/**"
6163
- ".github/workflows/build-samples.yml"
6264

6365
workflow_dispatch:
@@ -1773,3 +1775,98 @@ jobs:
17731775
echo "Generated SQLite database file found in sample tree."
17741776
exit 1
17751777
fi
1778+
1779+
test-ef-core-tenant-guardrails:
1780+
name: Test EF Core tenant guardrails sample
1781+
runs-on: ubuntu-latest
1782+
1783+
permissions:
1784+
contents: read
1785+
1786+
steps:
1787+
- name: Check out repository
1788+
uses: actions/checkout@v5
1789+
1790+
- name: Install .NET 10 SDK
1791+
uses: actions/setup-dotnet@v5
1792+
with:
1793+
dotnet-version: "10.0.x"
1794+
1795+
- name: Restore
1796+
run: >
1797+
dotnet restore
1798+
ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx
1799+
1800+
- name: Build
1801+
run: >
1802+
dotnet build
1803+
ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx
1804+
--configuration Release
1805+
--no-restore
1806+
1807+
- name: Test
1808+
run: >
1809+
dotnet test
1810+
ef-core/multitenancy-softdelete-auditing/EfCoreTenantGuardrailsMinimal.slnx
1811+
--configuration Release
1812+
--no-build
1813+
1814+
- name: Verify EF Core package baseline
1815+
shell: bash
1816+
run: |
1817+
project="ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj"
1818+
1819+
test "$(grep --count '<PackageReference' "${project}")" = "1"
1820+
grep --fixed-strings 'Include="Microsoft.EntityFrameworkCore.Sqlite"' "${project}"
1821+
grep --fixed-strings 'Version="10.0.11"' "${project}"
1822+
1823+
- name: Verify named filter guardrails
1824+
shell: bash
1825+
run: |
1826+
context="ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Data/TenantDbContext.cs"
1827+
queries="ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/Queries/TenantQueries.cs"
1828+
1829+
grep --fixed-strings 'TenantFilterName' "${context}"
1830+
grep --fixed-strings 'SoftDeleteFilterName' "${context}"
1831+
grep --fixed-strings 'IgnoreQueryFilters' "${queries}"
1832+
1833+
if grep --fixed-strings 'IgnoreQueryFilters()' "${queries}"; then
1834+
echo "Blanket query-filter bypass found in production helper."
1835+
exit 1
1836+
fi
1837+
1838+
- name: Verify exact tenant-isolation output
1839+
shell: bash
1840+
run: |
1841+
output="$(
1842+
dotnet run \
1843+
--project ef-core/multitenancy-softdelete-auditing/src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj \
1844+
--configuration Release \
1845+
--no-build
1846+
)"
1847+
1848+
expected="$(
1849+
printf '%s\n' \
1850+
'EF Core Tenant Isolation Lab' \
1851+
'Visible tasks: alpha=1, beta=1' \
1852+
'Same local ID: alpha=Alpha plan | beta=Beta plan' \
1853+
'Alpha including deleted: 2, foreign tenants=0' \
1854+
'Spoofed insert stamped tenant: alpha' \
1855+
'Soft delete: visible alpha=1, physical alpha=3, deletedBy=alpha-admin'
1856+
)"
1857+
1858+
printf '%s\n' "${output}"
1859+
test "${output}" = "${expected}"
1860+
1861+
- name: Verify no generated database artifacts
1862+
shell: bash
1863+
run: |
1864+
if find \
1865+
ef-core/multitenancy-softdelete-auditing \
1866+
-type f \
1867+
\( -name '*.db' -o -name '*.sqlite' -o -name '*.sqlite3' \) \
1868+
-print -quit | grep --quiet .; then
1869+
echo "Generated SQLite database file found."
1870+
exit 1
1871+
fi
1872+

‎README.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ Each sample folder contains a focused implementation of one tutorial topic. The
3434
| [`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/) |
3535
| [`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/) |
3636
| [`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/) |
37+
| [`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/) |
3738

3839
## Companion articles
3940
- [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/)
@@ -493,7 +494,7 @@ tutorials/
493494
| `-- EfCoreHotPathMinimal.Tests/
494495
| |-- EfCoreHotPathMinimal.Tests.csproj
495496
| `-- EfCoreHotPathTests.cs
496-
| `-- modern-data-access-dotnet/
497+
| |-- modern-data-access-dotnet/
497498
| |-- EfCoreRelationshipsMinimal.slnx
498499
| |-- README.md
499500
| |-- src/
@@ -513,6 +514,30 @@ tutorials/
513514
| `-- EfCoreRelationshipsMinimal.Tests/
514515
| |-- EfCoreRelationshipsMinimal.Tests.csproj
515516
| `-- EfCoreRelationshipsTests.cs
517+
| `-- multitenancy-softdelete-auditing/
518+
| |-- EfCoreTenantGuardrailsMinimal.slnx
519+
| |-- README.md
520+
| |-- src/
521+
| | `-- EfCoreTenantGuardrailsMinimal/
522+
| | |-- EfCoreTenantGuardrailsMinimal.csproj
523+
| | |-- Program.cs
524+
| | |-- Context/
525+
| | | `-- TenantExecutionContext.cs
526+
| | |-- Data/
527+
| | | |-- TenantDatabase.cs
528+
| | | `-- TenantDbContext.cs
529+
| | |-- Interceptors/
530+
| | | `-- TenantAuditInterceptor.cs
531+
| | |-- Models/
532+
| | | `-- TenantTask.cs
533+
| | |-- Queries/
534+
| | | `-- TenantQueries.cs
535+
| | `-- Services/
536+
| | `-- TenantWorkflow.cs
537+
| `-- tests/
538+
| `-- EfCoreTenantGuardrailsMinimal.Tests/
539+
| |-- EfCoreTenantGuardrailsMinimal.Tests.csproj
540+
| `-- TenantGuardrailTests.cs
516541
|-- .github/
517542
| `-- workflows/
518543
| `-- build-samples.yml
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
<Solution>
2+
<Project Path="src/EfCoreTenantGuardrailsMinimal/EfCoreTenantGuardrailsMinimal.csproj" />
3+
<Project Path="tests/EfCoreTenantGuardrailsMinimal.Tests/EfCoreTenantGuardrailsMinimal.Tests.csproj" />
4+
</Solution>
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# EF Core Tenant Isolation, Soft Delete &amp; Audit Guardrails &mdash; 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 &amp; 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 &mdash; see the repository root [`LICENSE`](../../LICENSE).
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
namespace EfCoreTenantGuardrailsMinimal.Context;
2+
3+
public sealed class TenantExecutionContext
4+
{
5+
public TenantExecutionContext(
6+
string tenantId,
7+
string actorId,
8+
DateTimeOffset utcNow)
9+
{
10+
string normalizedTenant = tenantId.Trim().ToLowerInvariant();
11+
string normalizedActor = actorId.Trim();
12+
13+
if (normalizedTenant.Length is < 1 or > 64)
14+
throw new ArgumentException(
15+
"TenantId must contain between 1 and 64 characters.",
16+
nameof(tenantId));
17+
18+
if (normalizedActor.Length is < 1 or > 120)
19+
throw new ArgumentException(
20+
"ActorId must contain between 1 and 120 characters.",
21+
nameof(actorId));
22+
23+
TenantId = normalizedTenant;
24+
ActorId = normalizedActor;
25+
UtcNow = utcNow.ToUniversalTime();
26+
}
27+
28+
public string TenantId { get; }
29+
public string ActorId { get; }
30+
public DateTimeOffset UtcNow { get; }
31+
}

0 commit comments

Comments
 (0)