Defensive database context for multi-tenant ASP.NET Core applications

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.

My previous work on multi-tenancy

Here are some references to my previous posts about multi-tenant applications on ASP.NET Core.

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.



See also

3 thoughts on “Defensive database context for multi-tenant ASP.NET Core applications

Leave a Reply

Your email address will not be published. Required fields are marked *