In multi-tenant applications where tenants share same data store cross-tenant operations must be avoided. There are many ways how bugs like this can happen. Be it hard to debug threading issue or some other complex situation, it is better when application components are ready for this kind of situations and better fail instead of going to glory end with illegal operations. This blog post introduces defensive database context that throws exception when data from other tenants is about to be updated.
- Global query filters in Entity Framework Core 2.0
- Implementing tenant providers on ASP.NET Core
- Implementing database per tenant strategy on ASP.NET Core
- Handling missing tenants in ASP.NET Core
- Unit testing multi-tenant database provider
- Defensive database context for multi-tenant ASP.NET Core applications
- Tenant-based dependency injection in multi-tenant ASP.NET Core applications
- Using configurable composite command in multi-tenant ASP.NET Core application
Sample database context
Suppose we have shared database for multi-tenant application and tenant ID is present for all entities. Suppose we have also data context class that applies global query filter to all entities like shown in my blog post Global query filters in Entity Framework Core 2.0. There is abstract BaseEntity class that has Id and TenantId properties and all entities extend from this class. I leave out support for soft deletes in this post.
public abstract class BaseEntity
{
public int Id { get; set; }
public Guid TenantId { get; set; }
}
public class Playlist : BaseEntity
{
public string Title { get; set; }
public IList<Song> Songs { get; set; }
}
public class Song : BaseEntity
{
public string Artist { get; set; }
public string Title { get; set; }
public string Location { get; set; }
}
Here is the simplified and this version of data context for multi-tenant application.
public class PlaylistContext : DbContext
{
private readonly Tenant _tenant;
public DbSet<Playlist> Playlists { get; set; }
public DbSet<Song> Songs { get; set; }
public PlaylistContext(DbContextOptions<PlaylistContext> options,
ITenantProvider tenantProvider)
: base(options)
{
_tenant = tenantProvider.GetTenant();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Playlist>().HasKey(e => e.Id);
modelBuilder.Entity<Song>().HasKey(e => e.Id);
base.OnModelCreating(modelBuilder);
}
// Other methods follow
}
I don’t focus here on tenant provider and related things as these are covered in previous posts and here they don’t matter much.
What can go wrong?
Important thing that comes to my mind when looking at database context above is the safety net for cross-tenant bugs. Nothing stops developers mistakenly play with enitites from different tenants. Whatever is the reason for bug but something like shown in next code fragment cannot go through.
_context.Playlists.Add(new Playlist { TenantId = Guid.NewGuid() });
_context.Playlists.Add(new Playlist { TenantId = Guid.NewGuid() });
_context.SaveChanges();
This is the case where there are two entities belonging to different tenants.
There’s one more case – one entity that belongs to another tenant.
_context.Playlists.Add(new Playlist { TenantId = Guid.NewGuid() });
_context.SaveChanges();
The data context given above is not ready to handle these situations.
Building defensive database context
Let’s start building the safety net for problems mentioned above and let’s introduce new exception class for cross-tenant operations.
public class CrossTenantUpdateException : ApplicationException
{
public IList<Guid> TenantIds { get; private set; }
public CrossTenantUpdateException(IList<Guid> tenantIds)
{
TenantIds = tenantIds;
}
}
CrossTenantUpdateException takes list of tenant IDs to constructor and makes these available through TenantIds property. It is important to have these ID-s as it makes it easier for developers to find out more about the problem.
As I want to also cover more complex application that use DDD and therefore possibly multiple data contexts I create now base class for multi-tenant data contexts that throws exception when cross-tenant update is about to happen. For this I add new ThrowIfMultipleTenants() method that checks from ChangeTracker the Id-s of all entities waiting for update and throws if there are any anomalies. This method is called before context saves changes.
public abstract class TenantsBaseDbContext : DbContext
{
private Guid _tenantId;
public TenantsBaseDbContext(DbContextOptions<PlaylistContext> options,
ITenantProvider tenantProvider) : base(options)
{
_tenantId = tenantProvider.GetTenant().Id;
}
public override int SaveChanges()
{
ThrowIfMultipleTenants();
return base.SaveChanges();
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
ThrowIfMultipleTenants();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
{
ThrowIfMultipleTenants();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken))
{
ThrowIfMultipleTenants();
return base.SaveChangesAsync(cancellationToken);
}
private void ThrowIfMultipleTenants()
{
var ids = (from e in ChangeTracker.Entries()
where e.Entity is BaseEntity
select ((BaseEntity)e.Entity).TenantId)
.Distinct()
.ToList();
if(ids.Count == 0)
{
return;
}
if(ids.Count > 1)
{
throw new CrossTenantUpdateException(ids);
}
if(ids.First() != _tenantId)
{
throw new CrossTenantUpdateException(ids);
}
}
}
It’s possible now to make all multi-tenant database contexts to extend from TenantsBaseDbContext class.
public class PlaylistContext : TenantsBaseDbProvider
{
private readonly Tenant _tenant;
public DbSet<Playlist> Playlists { get; set; }
public DbSet<Song> Songs { get; set; }
public PlaylistContext(DbContextOptions<PlaylistContext> options,
ITenantProvider tenantProvider)
: base(options, tenantProvider)
{
_tenant = tenantProvider.GetTenant();
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Playlist>().HasKey(e => e.Id);
modelBuilder.Entity<Song>().HasKey(e => e.Id);
base.OnModelCreating(modelBuilder);
}
// Other methods follow …
}
It’s possible to further optimize this code and move more checks and dirty work to base database context but as cruel person I leave it to reader this time.
Wrapping up
Threading and other complex cases in multi-tenant applications that share same data source may introduce bad and hard to find bugs where data from multiple tenants is changed but this cannot happen as code must run in context of one specific tenant. This blog post introduced the base class for database contexts that throws exception before save when entities from other tenants are about to be added or updated in current tenant context. In case of multiple multi-tenant database contexts this base class can be used with all of them and as there’s now base class then base class can be updated with more shared functionalities needed by database contexts.
View Comments (4)
Hi Gunner,
This is a great series on building multi-tenant apps in ASP.NET core. Have you considered using SQL Server RLS as part of a multi-tenant architecture? I have a post on it here: http://www.carlrippon.com/?p=766
Regards,
Carl
Hi Carl,
Thanks for interesting link. RLS seems to be very easy to apply with EF and I think this is additional safety net to consider for those who use SQL Server. I try to keep the posts here as general as possible when it comes to database as there are EF providers also for SQLite, PostgreSQL and MySQL (plus some others) and this sample here is database agnostic.
There is one limitation of the global filters which may be not obvious at first, and can lead to dangerous bugs: the filters are literally QUERY filters, meaning update/delete operations without querying first (attached entity pattern) will not respect query filters. This means you can, for instance, implicitly allow to delete an entity from any tenant, believing query filter guards against that.
To avoid making changes to different tenants the defensive context has overrides for SaveChanges() versions.