Multi-tenant data isolation support for Entity Framework Core applications
dotnet add package MultiTenancy.EntityFrameworkCoreMulti-tenant data isolation support for Entity Framework Core applications.
Provides the ambient tenant context (ICurrentTenantService) and, since 1.5.0, the EF Core half:
a global tenant query filter and a write-side stamping guard.
Have your DbContext implement ITenantScopedDbContext, then call the two helpers:
using MultiTenancy.Abstractions;
using MultiTenancy.EntityFrameworkCore;
public class AppDbContext : DbContext, ITenantScopedDbContext
{
private readonly ICurrentTenantService _currentTenantService;
public ICurrentTenantService CurrentTenantService => _currentTenantService;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// ... entity configuration ...
modelBuilder.ApplyTenantFilters(this, TenantFilterMode.FailOpen);
}
public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
ChangeTracker.StampTenancy(_currentTenantService);
return await base.SaveChangesAsync(cancellationToken);
}
}Adds a global query filter to every entity deriving from BaseTenantEntity.
mode is required and has no default — the two polarities differ by one character at the call
site and by "every tenant's rows" at runtime:
| Mode | With NO ambient tenant | Use when |
|---|---|---|
TenantFilterMode.FailOpen |
matches every row (pass-through) | migrations, background jobs, MassTransit consumers and unauthenticated webhook/reset paths must still read. Those paths get isolation from an explicit TenantId predicate, not from this filter. |
TenantFilterMode.FailClosed |
matches no rows | a forgotten tenant argument should fail loudly ("not found") rather than leak silently. Requires IgnoreQueryFilters() + e.TenantId == tenantId repository methods for anonymous paths. |
⚠️ Passthisascontext. The filter is deliberately rooted at the DbContext instance so EF Core substitutes the executing context on every query.ICurrentTenantServiceis registered Scoped, and EF caches the model once per process — so a helper that captured the service instance would pin every future request to whichever request happened to build the model. That is a permanent cross-tenant leak, and it is why this method takes a context rather than a service.
Stamps TenantId/UserId onto newly added tenant-scoped rows that still hold Guid.Empty, from
the ambient tenant. Purely additive: it never overwrites a value that is already set, never stamps
non-Added rows, and is a no-op when there is no ambient tenant.
It exists because a row saved with TenantId = Guid.Empty raises no error and violates no
constraint — it is simply invisible to the tenant that owns it, forever. Child rows created inside an
aggregate are the usual way that happens.
See the NuGet package page for full documentation.
Contributions are welcome! Please open an issue or submit a pull request.
This project is licensed under the MIT License - see the LICENSE file for details.
- Issues: GitHub Issues
- Discussions: GitHub Discussions
cd C:\desktopContents\projects\SaaS\NuGetPackages\MultiTenancy.EntityFrameworkCore
.\publish.ps1 -ApiKey XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -Bump patch # Bug fixes
.\publish.ps1 -ApiKey XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -Bump minor # New features
.\publish.ps1 -ApiKey XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX -Bump major # Breaking changes