Multi tenancy using EF Core in Blazor server applications

Posted by furiousweebee on Thu, 23 Dec 2021 09:23:30 +0100

catalogue

Plant life cycle

A method

Put things in context

Dependent life cycle

transient event

Performance description

Many business applications are designed to work with multiple customers. Protecting data security is important so that customer data will not be leaked and seen by other customers and potential competitors. These applications are classified as "multi tenant" because each customer is treated as a tenant of the application and has its own dataset.

This article provides examples and solutions "as is". These are not "best practices", but "work practices" for you to consider.

There are many ways to implement multi tenancy in an application. A common method (sometimes necessary) is to save the data of each customer in a separate database. The schema is the same, but the data is customer specific. Another method is to partition the data in the existing database by customer.

EF Core supports both methods.

For methods that use multiple databases, switching to the correct database is as simple as providing the correct connection string. If the data is stored in a single database, Global query filter It makes sense to ensure that developers do not accidentally write code that can access other customer data. About how to use SQL Server Row level security See the wonderful article on protecting a single multi tenant database“ It takes only 3 steps to protect data in a single multi tenant database".

Plant life cycle

Using Entity Framework Core in Blazor applications The recommended mode is registration DbContextFactory And then call it to create a new instance of DbContext for each operation. By default, the factory is a singleton, so there is only one copy for all users of the application. This is usually good because although the factory is shared, a single group of user dbcontext instances are not. However, for multi tenancy, the connection string of each user may be different. Since factory cached configurations have the same lifecycle, this means that all users must share the same configuration.

This problem does not occur in the Blazor WebAssembly application because the scope of the singleton is limited to users. On the other hand, Blazor Server applications present unique challenges. Although the application is a Web application, it "remains active" through real-time communication using SignalR. Each user creates a session that continues after the initial request. A new factory should be provided for each user to allow new settings. The lifecycle of this particular factory is called Scoped and a new instance is created for each user session.

A method

To demonstrate multi tenancy in the Blazor Server application, I built:

  JeremyLikness/BlazorEFCoreMultitenant

The database contains tables for storing method names and parameters populated by reflection. The data model of the parameter is:

public class DataParameter
{
    public DataParameter() { }

    public DataParameter(ParameterInfo parameter)
    {
        Name = parameter.Name;
        Type = parameter.ParameterType.FullName;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string Type { get; set; }
    public DataMethod Method { get; set; }
}

Parameters are owned by the method that defines them. The method class is as follows:

public class DataMethod
{
    public DataMethod() { }

    public DataMethod(MethodInfo method)
    {
        Name = method.Name;
        ReturnType = method.ReturnType.FullName;
        ParentType = method.DeclaringType.FullName;
        
        Parameters = method.GetParameters()
            .Select(p => new DataParameter(p))
            .ToList();

        foreach (var parameter in Parameters)
        {
            parameter.Method = this;
        }
    }

    public int Id { get; set; }

    public string Name { get; set; }

    public string ReturnType { get; set; }

    public string ParentType { get; set; }

    public IList<DataParameter> Parameters { get; set; } =
        new List<DataParameter>();
}

For this demonstration, the ParentType property is a tenant. The application comes with a preloaded SQLite database. There is an option in the application to regenerate the database. You can then navigate to the examples and see how they work.

Put things in context

A simple TenantProvider class handles setting the user's current tenant. It provides a callback to notify the code when the tenant changes. The implementation (callback omitted for clarity) is as follows:

public class TenantProvider
{
    private string tenant = TypeProvider.GetTypes().First().FullName;

    public void SetTenant(string tenant)
    {
        this.tenant = tenant;
        // notify changes
    }

    public string GetTenant() => tenant;

    public string GetTenantShortName() => tenant.Split('.')[^1];
}

DbContext can then manage multiple tenants. This method depends on your database policy. If you store all tenants in a single database, you may use query filters. TenantProvider will be passed to the construct through dependency injection and used to resolve and store the tenant identifier.

private readonly string tenant = string.Empty;

public SingleDbContext(
    DbContextOptions<SingleDbContext> options,
    TenantProvider tenantProvider)
    : base(options) 
{
    tenant = tenantProvider.GetTenant();
}

The OnModelCreating method is overridden to specify the query filter:

modelBuilder.Entity<DataMethod>()
   .HasQueryFilter(dm => dm.ParentType == tenant);

This ensures that each query is filtered to the tenant for each request. There is no need to filter in the application code because the global filter is automatically applied.

Dependent life cycle

The tenant provider and DbContextFactory are configured as follows when the application starts:

services.AddScoped<TenantProvider>();

services.AddDbContextFactory<SingleDbContext>(
    opts => opts.UseSqlite("Data Source=alltenants.sqlite"),
    ServiceLifetime.Scoped);

Please note that, Service life cycle Configured as servicelifetime Scoped . This enables it to rely on the tenant provider.

Dependencies must always flow to singletons. This means that a Scoped service can rely on another Scoped service or a Singleton service, but a Singleton service can only rely on other Singleton services: transient = > Scoped = > Singleton.

This version of MultipleDbContext is implemented by passing different connection strings for each tenant. This can be configured at startup by parsing the service provider and using it to build the connection string:

services.AddDbContextFactory<MultipleDbContext>((sp, opts) =>
{
    var tenantProvider = sp.GetRequiredService<TenantProvider>();
    opts.UseSqlite($"Data Source={tenantProvider.GetTenantShortName()}.sqlite");
}, ServiceLifetime.Scoped);

This applies to most scenarios, but what happens when users can "dynamically" change their tenants?

transient event

In previous database configurations, options were cached at the Scoped level. This means that if the user changes the tenant, the options will not be re evaluated, so the tenant changes will not be reflected in the query.

A simple solution to this is to set the lifecycle to Transient. This ensures that the tenant and the connection string are re evaluated each time DbContext is requested. Users can switch tenants at will. The following table can help you select the life cycle that best suits your plant.

 

Single database

Multiple databases

Users stay in a single tenant

Scoped

Scoped

Users can switch tenants

Scoped

Transient

If your database does not take user - scoped dependencies, the default Singleton still makes sense.

Performance description

An effective question when changing the scope is: "how much impact will this have on performance?" The answer is "almost none." A single user session is unlikely to require hundreds or thousands of DbContext instances during a given session. The GitHub repository contains a benchmark project that shows the Transient scope factory from requesting the factory to creating an available DbContext. This means more than 300000 requests per second.

As usual, I am open to feedback and suggestions. I hope this example provides a template that can be used to help solve the multi tenant requirements of your Blazor business application.

Reminder: the sample application is located at:

JeremyLikness/BlazorEFCoreMultitenant

https://www.codeproject.com/Articles/5308540/Multi-tenancy-with-EF-Core-in-Blazor-Server-Apps

Topics: ASP.NET Blazor