Multi-tenant ASP.NET Core 4 - Applying tenant rules to all enitites

gpeipman
1,303 views

Open Source Your Knowledge, Become a Contributor

Technology knowledge has to be shared and made accessible for free. Join the movement.

Create Content

Applying tenant rules to all enitites

Global query filters introduced by Entity Framework Core 2 provide us with simple way to implement soft deletes and multi-tenancy. This example focuses on practical case: how to apply tenancy rules to big number of entities automatically?

We suppose here that all tables in database have TenantId field. On one hand it means storing more data but on the other hand we don't have to add so many joins to queries to climb up to root entity that has tenant ID. Also we can use TenantId in database relations to make sure that data in different tenants doesn't get mixed accidentally.

Base entity and example entities

Let's define base entity that holds Id and TenantId.

public abstract class BaseEntity
{
    public Guid Id { get; set; }
    public Guid TenantId { get; set; }
}

And let's define also some sample entities.

public class Person : BaseEntity 
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

public class Product : BaseEntity
{
    public string Name { get; set; }
    public string Unit { get; set; }
    public double Price { get; set; }
}

Implementing dummy tenant provider

To keep this sample minimal we will use dummy tenant provider that just turns same tenant ID every time.

public interface ITenantProvider
{
    Guid GetTenantId();
}

public class DummyTenantProvider : ITenantProvider
{
    public Guid GetTenantId()
    {
        return MultitenantDbContext.Tenant1Id;
    }
}

Applying tenant ID-s to all entities

Now things get tricky as there is no straight and simple way to apply global query filters to all entities with one shot.

We can do something like this.

builder.Entity<Person>().HasQueryFilter(e => e.TenantId == _tenantProvider.GetTenantId());
builder.Entity<Product>().HasQueryFilter(e => e.TenantId == _tenantProvider.GetTenantId());

But do we really want to do it 400 times? Or even worse - what if some developer adds 10 new entities and forgets to add global query filters for these?

Our plan is here:

  • Detect entity types - from all loaded types let's find ones that inherit from base entity.
  • Write method to build entity - we have to write generic method to call with given entity type to use database context methods.
  • Implement hack-calls to this method for every entity type - we call the method built in previous point through reflection. It looks like bad hack and it almost is but we don't have better way to do it today.

All this mystery happens in multi-tenant database context. I added comments so the code is a little bit easier to follow.

public class MultitenantDbContext : DbContext
{
    public static Guid Tenant1Id = Guid.Parse("51aab199-1482-4f0d-8ff1-5ca0e7bc525a");
    public static Guid Tenant2Id = Guid.Parse("ae4e21fa-57cb-4733-b971-fdd14c4c667e");

    public DbSet<Person> People { get; set; }
    public DbSet<Product> Products { get; set; }

    private ITenantProvider _tenantProvider;

    public MultitenantDbContext(DbContextOptions<MultitenantDbContext> options,
                                ITenantProvider tenantProvider) : base(options)
    {
        _tenantProvider = tenantProvider;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Set BaseEntity rules to all loaded entity types
        foreach (var type in GetEntityTypes())
        {
            var method = SetGlobalQueryMethod.MakeGenericMethod(type);
            method.Invoke(this, new object[] { modelBuilder });
        }
    }

    // Find loaded entity types from assemblies that application uses
    //
    private static IList<Type> _entityTypeCache;
    private static IList<Type> GetEntityTypes()
    {
        if (_entityTypeCache != null)
        {
            return _entityTypeCache.ToList();
        }

        _entityTypeCache = (from a in GetReferencingAssemblies()
                            from t in a.DefinedTypes
                            where t.BaseType == typeof(BaseEntity)
                            select t.AsType()).ToList();

        return _entityTypeCache;
    }

    private static IEnumerable<Assembly> GetReferencingAssemblies()
    {
        var assemblies = new List<Assembly>();
        var dependencies = DependencyContext.Default.RuntimeLibraries;

        foreach (var library in dependencies)
        {
            try
            {
                var assembly = Assembly.Load(new AssemblyName(library.Name));
                assemblies.Add(assembly);
            }
            catch (FileNotFoundException)
            { }
        }
        return assemblies;
    }

    // Applying BaseEntity rules to all entities that inherit from it.
    // Define MethodInfo member that is used when model is built.
    //
    static readonly MethodInfo SetGlobalQueryMethod = typeof(MultitenantDbContext).GetMethods(BindingFlags.Public | BindingFlags.Instance)
                                                            .Single(t => t.IsGenericMethod && t.Name == "SetGlobalQuery");

    // This method is called for every loaded entity type in OnModelCreating method.
    // Here type is known through generic parameter and we can use EF Core methods.
    public void SetGlobalQuery<T>(ModelBuilder builder) where T : BaseEntity
    {
        builder.Entity<T>().HasKey(e => e.Id);
        builder.Entity<T>().HasQueryFilter(e => e.TenantId == _tenantProvider.GetTenantId());
    }

    public void AddSampleData()
    {
        People.Add(new Person
        {
            Id = Guid.Parse("79865406-e01b-422f-bd09-92e116a0664a"),
            TenantId = Tenant1Id,
            FirstName = "Gunnar",
            LastName = "Peipman"
        });

        People.Add(new Person
        {
            Id = Guid.Parse("d5674750-7f6b-43b9-b91b-d27b7ac13572"),
            TenantId = Tenant2Id,
            FirstName = "John",
            LastName = "Doe"
        });

        People.Add(new Person
        {
            Id = Guid.Parse("e41446f9-c779-4ff6-b3e5-752a3dad97bb"),
            TenantId = Tenant1Id,
            FirstName = "Mary",
            LastName = "Jones"
        });

        Products.Add(new Product
        {
            Id = Guid.Parse("3721698d-3bef-42c1-b255-151ddf936508"),
            TenantId = Tenant1Id,
            Name = "Coca-Cola",
            Unit = "l",
            Price = 1.20
        });

        Products.Add(new Product
        {
            Id = Guid.Parse("ccd1f307-2364-47f4-8d4f-a4a5bafc571a"),
            TenantId = Tenant2Id,
            Name = "Fanta",
            Unit = "l",
            Price = 0.90
        });

        Products.Add(new Product
        {
            Id = Guid.Parse("d2100ad4-a345-48b4-ac4e-4238e14c6716"),
            TenantId = Tenant2Id,
            Name = "Sprite",
            Unit = "l",
            Price = 1.05
        });

        SaveChanges();
    }
}

It's complex, it's messy, it's tricky to test - but it is same time part of our safety net. We can be sure that tenant rules are applied to all entities our application may use.

Registering services

Now it's time to register services in application StartUp class so dependency injection knows how to inject types.

public void ConfigureServices(IServiceCollection services)
{
    services.AddDbContext<MultitenantDbContext>(o => o.UseInMemoryDatabase(Guid.NewGuid().ToString()));

    services.AddMvc();

    services.AddTransient<ITenantProvider, DummyTenantProvider>();
}

Demo

Now we have everything ready to run the demo. Things to notice:

  • All entity types get tenancy rules automatically
  • In tables shown below only entities with tenant provider given ID are shown
Click Run to run the demo
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using System.Linq;
using EntityQueryFilters.Models;
using Microsoft.AspNetCore.Mvc;
namespace EntityQueryFilters.Controllers
{
public class HomeController : Controller
{
private readonly MultitenantDbContext _context;
public HomeController(MultitenantDbContext context)
{
_context = context;
_context.AddSampleData();
}
public IActionResult Index()
{
var model = new IndexViewModel();
model.People = _context.People.ToList();
model.Products = _context.Products.ToList();
return View(model);
}
public IActionResult Error()
{
return View();
}
}
}
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

References

Open Source Your Knowledge: become a Contributor and help others learn. Create New Content