Skip to content

Creating a multi tenant app

Jon P Smith edited this page Apr 2, 2022 · 10 revisions

AuthP's Multi-Tenant feature manages the Multi-Tenant structure and DataKeys for you to implement a multi-tenant application. This document lists what the AuthP library provides and then defines what you need to set up your multi-tenant DbContext.

What AuthP Multi-Tenant feature provides

Once you have configure the multi-tenant AuthP features (see Multi-Tenant configuration) the following features are available:

From this place you are ready to set up your multi-tenant application DbContext

NOTE: Typically you won't implement the ITenantChangeService until you have set up your application DbContext, but you need this service before you code can work with AuthP's Tenant DataKeys.

How to build a multi-tenant application DbContext

This section lists the things you need to do to create an application DbContext that can filter the EF Core classes/tables so that only data that has the same DataKey as the logged in user is available to the user.

Before listing the steps there are two example multi-tenant applications in the AuthP repo which can be run. These provide an excellent code base to inspect and learn from.

  • Example3 implements a single level multi-tenant application. You can find the tenant data application entity classes, EF Core DbContext and implemented ITenantChangeService service in the Example3.InvoiceCode project.
  • Example4 implements a hierarchical multi-tenant application. You can find the tenant data application entity classes, EF Core DbContext and implemented ITenantChangeService service in the Example4.ShopCode project.
  • Example6 implements a single level multi-tenant application using a hybrid database arrangement. You can find the tenant data application entity classes, EF Core DbContext and implemented ITenantChangeService service in the Example6.SingleLevelShardingproject.

1. Inject the IGetDataKeyFromUser service into your application DbContext

You need to provide a second parameter to the constructor in your application DbContext, as shown in the example code below.

public class YourDbContext : DbContext, IDataKeyFilter
{
    public string DataKey { get; }

    public YourDbContext (DbContextOptions<YourDbContext> options, 
        IGetDataKeyFromUser dataKeyFilter)
        : base(options)
    {
        // The DataKey is null when: no one is logged in, its a background service, 
        // or user hasn't got an assigned tenant.
        // In these cases its best to set the data key that doesn't match any possible DataKey 
         DataKey = dataKeyFilter?.DataKey ?? "stop any user without a DataKey to access the data"; 
    }

    // rest of code left out.
}

The multiple-database arrangement (i.e. the AddSharding member is added to the TenantType in the options) you need both the DataKey and a connection string. See the ShardingSingleDbContext for how this is done.

2. Add EF Core global query filters to each entity

You need to configure a global query filter on all the entities that hold tenant's DataKey. You could do this by calling HasQueryFilter Fluent API method in your configuration, but I recommend you automate this.

  • If its a single-level multi-tenant system with one database the user's DataKey much exactly match the DataKey in the multi-tenant entities. (have a look at the code inside the OnModelCreating method of the InvoicesDbContext used in Example3's single level multi-tenant system.
  • If its a single-level multi-tenant with AsSharding system the user's DataKey much exactly match the DataKey in the multi-tenant entities, or if the tenant has its own database it has a special string that turns of the query filter. (have a look at the code inside the OnModelCreating method of the ShardingSingleDbContext used in Example6's single level multi-tenant system with sharding and its call to the AddSingleTenantShardingQueryFilter.
  • If its a hierarchical multi-tenant system (with or without sharding) the DataKey in the multi-tenant entities must StartWith the user's DataKey. (Have a look at code inside the OnModelCreating method of the RetailDbContext used in Example4's hierarchical multi-tenant system.)

3. Add an interface to all entities that hold tenant data

Assuming you are using the automated configuration, each entities that hold tenant data should have an interface to say they have a DataKey. AuthP provides two types of interfaces (but you can create your own)

  • IDataKeyFilterReadWrite, which assumes you are setting the DataKey by overriding the DbContext's SaveChanges / SaveChangesAsync when a new entity is add to the database (see section 3.1 below). Example3 uses this approach.
  • IDataKeyFilterReadOnly, which assumes the DataKey value is taken from a local tenant class in your application database. This local tenant will be updated by the implemented ITenantChangeService service and new entities take the DataKey from the local tenant class. Example4 uses this approach.

3. Setting the DataKey in a new tenant data entity

The two main options are:

3.1 The SaveChanges / SaveChangesAsync methods adds the DataKey

This is the normal approach and requires you to override the DbContext's SaveChanges / SaveChangesAsync and a method updates the DataKey for every newly added entity classes, or use EF Core's SaveChanges interceptor. The example code shown comes from Example3'

public class InvoicesDbContext : DbContext, IDataKeyFilterReadOnly
{
    public string DataKey { get; } //setting of the DataKey has been left out

    public override int SaveChanges(bool acceptAllChangesOnSuccess)
    {
        this.MarkWithDataKeyIfNeeded(DataKey);
        return base.SaveChanges(acceptAllChangesOnSuccess);
    }

    public override async Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess,
        CancellationToken cancellationToken = default(CancellationToken))
    {
        this.MarkWithDataKeyIfNeeded(DataKey);
        return await base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
    }
    /// other code left out

Here is an example the MarkWithDataKeyIfNeeded extension method taken from Example3.

public static void MarkWithDataKeyIfNeeded(this DbContext context, string accessKey)
{
    foreach (var entityEntry in context.ChangeTracker.Entries()
        .Where(e => e.State == EntityState.Added))
    {
        // This is a newly added entity, so see if it has a DataKey and the DataKey is null,
        // then we will apply the DataKey provided by the user. 
        var hasDataKey = entityEntry.Entity as IDataKeyFilterReadWrite;
        if (hasDataKey != null && hasDataKey.DataKey == null)
            // If the entity has a DataKey it will only update it if its null
            // This allow for the code to define the DataKey on creation
            hasDataKey.DataKey = accessKey;
    }
}

3.2 Taking a DataKey value from a local tenant class

This isn't a normal approach, but I added it as a possible approach in case its useful. However I don't think it would work with a tenant that had lots of entity classes that aren't linked to each other.

In the Example4 application there is a RetailOutlet entity class, which is created (via the ITenantChangeService service) when a new AuthP tenant is created. Then the other classes, ShopStock and ShopSale have links back to the RetailOutlet entity class.

So, when some stock is sold the code looks like this

var stockToBuy = service.ReadSingle<ShopStock>(dto.ShopStockId);
var status = ShopSale.CreateSellAndUpdateStock(dto.NumBought, stockToBuy, null);
...context.Add(status.Result);
...context.SaveChanges();

And the ShopSale constructor looks like this (notice the last line of code where the DataKey is set from the stock DataKey).

 private ShopSale(int numSoldReturned, string returnReason, ShopStock foundStock)
{
    if (numSoldReturned == 0) throw new ArgumentException("cannot be zero", nameof(numSoldReturned));
    if (numSoldReturned < 0 && returnReason == null) 
        throw new ArgumentException("cannot be null if its a return", nameof(returnReason));

    NumSoldReturned = numSoldReturned;
    ReturnReason = returnReason;
    StockItem = foundStock;
    DataKey = foundStock.DataKey;
}

4. Many-databases needs a Connection string

If you added the AddSharding to the setting of the TenantType, then you need to supply both the DataKey and a connection string. This done via the a service within the AuthP that matched the IGetShardingDataFromUser. The connection string is applied the tenant DbContext constructor using EF Core's SetConnectionString method - see ShardingSingleDbContext for an example of this.

Additional resources

Articles / Videos

Concepts

Setup

Usage

Admin

SupportCode

Clone this wiki locally