How to implement multitenancy in ASP.NET Core with EF Core (2024)

Multitenancy is a software architecture that allows a single instance of a software application to serve multiple customers, called tenants. Each tenant's data is isolated and remains invisible to other tenants for security reasons. This architecture is commonly used in Software as a Service (SaaS) applications, where multiple organizations or users share the same application infrastructure while keeping their data secure and separate.

Introduction to multitenancy

Multitenancy offers several advantages, including:

  • Cost efficiency: shared infrastructure reduces the overall cost of hosting, infrastructure and maintenance.
  • Simplified deployment: centralized updates simplify maintenance and support.
  • Scalability: it allows easy scaling of resources for individual tenants based on their needs.
  • Isolation and security: each tenant's data is isolated, ensuring security and privacy.

There are several approaches to separate data for each tenant in multi-tenant applications:

  • Database-per-Tenant: each tenant has its own database. This model offers strong data isolation but may increase costs with many tenants.
  • Schema-per-Tenant: a single database with separate schemas for each tenant. It provides a balance between isolation and resource sharing.
  • Table-per-Tenant: a single database and schema, with tenant-specific tables. This model is efficient but may complicate data management.
  • Discriminator column: a single database, schema, and tables, with a column indicating the tenant. This is the simplest but least isolated model.

Discriminator column is one of the most popular approaches to implementing multitenancy. It is cheap in terms of development, deployment and management compared to other options. With modern technologies like ASP.NET Core and EF Core, you can implement discriminator columns in your application without negatively affecting performance and security.

In this blog post I will show you how to implement multitenancy with discriminator column. Let's explore an application that needs to be multi-tenant.

Multitenant application example

Today we will implement multitenancy for the "Books" application that has the following entities:

  • Books
  • Authors
  • Users
  • Tenants

The Tenant entity holds information about each customer:

public class Tenant : IAuditableEntity
{
public required Guid Id { get; set; }
public required string Name { get; set; }

public DateTime CreatedAtUtc { get; set; }
public DateTime? UpdatedAtUtc { get; set; }
}

Every Book, Author and User entities in our database belong to a specific tenant. You need to create a ITenantEntity interface that should be inherited by all entities that need to be multi-tenant:

public interface ITenantEntity
{
public Guid? TenantId { get; set; }
}

Let's explore, for example, User and Book entities:

public class User : IAuditableEntity, ITenantEntity
{
public Guid Id { get; set; }
public required string Email { get; set; }

public DateTime CreatedAtUtc { get; set; }
public DateTime? UpdatedAtUtc { get; set; }

public Guid? TenantId { get; set; }
}

public class Book : IAuditableEntity, ITenantEntity
{
public required Guid Id { get; set; }
public required string Title { get; set; }
public required int Year { get; set; }
public Guid AuthorId { get; set; }
public Author Author { get; set; } = null!;

public DateTime CreatedAtUtc { get; set; }
public DateTime? UpdatedAtUtc { get; set; }

public Guid? TenantId { get; set; }
}

Every entity has a foreign key relationship with Tenant entity, for example, User:

public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ToTable("users");

builder.HasKey(x => x.Id);
builder.HasIndex(x => x.Email);

builder.Property(x => x.Id).ValueGeneratedOnAdd();
builder.Property(x => x.Email).IsRequired();
builder.Property(x => x.CreatedAtUtc).IsRequired();
builder.Property(x => x.UpdatedAtUtc);

builder.HasOne<Tenant>()
.WithMany()
.HasForeignKey(e => e.TenantId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
}
}

Depending on your application needs, you may have this foreign key or have a plain TenantId column without a reference.

How to implement multitenancy in EF Core

To implement multitenancy, we need to do the following:

  1. After a user logs in, we need to add a tenant identifier in the user claims.
  2. From every request from the frontend (or another application) whether we need to retrieve or modify data, we need to get the user tenant identifier from the user claims.
  3. Whenever a user requests data from the backend - we can use EF Core Global Query Filters to automatically filter only those records from the database that a user has access to.
  4. Whenever a user creates, updates or deletes data - we can automatically assign a "TenantId" column in the Change Tracker with a user tenant identifier.

By utilizing EF Core capabilities (Global Query Filters and Change Tracker) we no longer need to write custom filters in each query or provide a tenant id in every create, update or delete action. EF Core helps us to implement multitenancy once that will be applied to all entities that need to be multi-tenant.

This approach secures your code from the critical bugs where you may forget to add a check for a tenant in one of the queries and expose another customer's data.

Implementing multitenancy for read operations in EF Core

We can implement multitenancy in EF Core DbContext that will automatically be applied to all entities that inherit from ITenantEntity.

Let's define a TenantProvider that will retrieve user and tenant identifiers from the current HttpRequest:

public interface ITenantProvider
{
TenantInfo GetCurrentTenantInfo();
}

public class TenantProvider : ITenantProvider
{
private readonly TenantInfo _tenantInfo;

public TenantProvider(IHttpContextAccessor accessor)
{
var userIdValue = accessor.HttpContext?.User.FindFirstValue("user-id");
var tenantIdValue = accessor.HttpContext?.User.FindFirstValue("tenant-id");

Guid? userId = Guid.TryParse(userIdValue, out var guid) ? guid : null;
Guid? tenantId = Guid.TryParse(tenantIdValue, out guid) ? guid : null;

_tenantInfo = new TenantInfo(userId, tenantId);
}

public TenantInfo GetCurrentTenantInfo() => _tenantInfo;
}

Here we retrieve the "user-id" and "tenant-id" from the ClaimsPrinciple.

You need to register the provider and IHttpContextAccessor in the DI:

builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, TenantProvider>();

We need to inject ITenantProvider into DbContext and create a public property for it:

public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, ITenantProvider tenantProvider) : DbContext(options)
{
public ITenantProvider TenantProvider => tenantProvider;

public DbSet<Author> Authors { get; set; } = default!;
public DbSet<Book> Books { get; set; } = default!;
public DbSet<User> Users { get; set; } = default!;
public DbSet<Tenant> Tenants { get; set; } = default!;
}

In the OnModelCreating method you can specify Global Query Filters for all our tenant entities:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>()
.HasQueryFilter(x => x.TenantId.Equals(TenantProvider.GetCurrentTenantInfo().TenantId));

modelBuilder.Entity<Author>()
.HasQueryFilter(x => x.TenantId.Equals(TenantProvider.GetCurrentTenantInfo().TenantId));

modelBuilder.Entity<Book>()
.HasQueryFilter(x => x.TenantId.Equals(TenantProvider.GetCurrentTenantInfo().TenantId));

base.OnModelCreating(modelBuilder);

modelBuilder.HasDefaultSchema("devtips_multitenancy");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(BookConfiguration).Assembly);
}

It is important to specify HasQueryFilter before calling base.OnModelCreating(modelBuilder);.

Whenever you add a new entity in your project, simply add a new query filter and all read operations for this entity will be filtered accordingly by a user's tenant. This ensures that a user will only have access to their own data.

"It is crucial to work with TenantProvider with a public property, otherwise EF Core Global Query Filters won't be applied correctly per each request.

Implementing multitenancy for write operations in EF Core

We can override SaveChangesAsync method in EF Core DbContext to implement multitenancy for write operations:

public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = new())
{
var tenantInfo = TenantProvider.GetCurrentTenantInfo();

var modifiedTenantEntries = ChangeTracker.Entries<ITenantEntity>()
.Where(x => x.State is EntityState.Added or EntityState.Modified);

foreach (var entry in modifiedTenantEntries)
{
entry.Entity.TenantId = tenantInfo.TenantId
?? throw new InvalidOperationException($"Tenant id is required but was not provided for entity '{entry.Entity.GetType()}' with state '{entry.State}'");
}

return await base.SaveChangesAsync(cancellationToken);
}

We iterate over a list of tenant entities and automatically set the TenantId property. If a tenant identifier is not available for any reason - an exception is thrown and the operation is aborted. A tenant identifier is required to be set whenever we create, update or delete an entity.

Now let's explore multitenancy in action.

Adding tenant identifier to user claims on a login

When a user logs in, we need to use an IgnoreQueryFilters method to be able to search for a user in every tenant:

public class LoginUserEndpoint : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapPost("/api/users/login", Handle);
}

private static async Task<IResult> Handle(
[FromBody] LoginUserRequest request,
ApplicationDbContext context,
IOptions<AuthConfiguration> jwtSettingsOptions,
CancellationToken cancellationToken)
{
var user = await context.Users
.IgnoreQueryFilters()
.FirstOrDefaultAsync(u => u.Email == request.Email, cancellationToken);

if (user is null)
{
return Results.NotFound("User not found");
}

var token = GenerateJwtToken(user, jwtSettingsOptions.Value);
return Results.Ok(new { Token = token });
}
}

This method disables all query filters applied to a User entity. After a user is found, you need to add a "tenant-id" claim:

private static string GenerateJwtToken(User user, AuthConfiguration auth)
{
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(auth.Key));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);

var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Email),
new Claim("use-id", user.Id.ToString()),
new Claim("tenant-id", user.TenantId?.ToString() ?? string.Empty)
};

var token = new JwtSecurityToken(
issuer: auth.Issuer,
audience: auth.Audience,
claims: claims,
expires: DateTime.Now.AddMinutes(30),
signingCredentials: credentials
);

return new JwtSecurityTokenHandler().WriteToken(token);
}

Now that the user is logged in, we can start calling other endpoints with a JWT token.

Implementing API endpoints for a multitenant entity

Let's explore a Create and Get By Id endpoints for a book entity:

public sealed record CreateBookRequest(string Title, int Year, Guid AuthorId);

public class CreateBookEndpoint : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapPost("/api/books", Handle);
}

private static async Task<IResult> Handle(
[FromBody] CreateBookRequest request,
ApplicationDbContext context,
CancellationToken cancellationToken)
{
var author = await context.Authors.FindAsync([request.AuthorId], cancellationToken);
if (author is null)
{
return Results.BadRequest("Author not found");
}

var book = new Book
{
Id = Guid.NewGuid(),
Title = request.Title,
Year = request.Year,
AuthorId = request.AuthorId,
Author = author
};

context.Books.Add(book);
await context.SaveChangesAsync(cancellationToken);

var response = new BookResponse(book.Id, book.Title, book.Year, book.AuthorId);
return Results.Created($"/api/books/{book.Id}", response);
}
}

public class GetBookByIdEndpoint : ICarterModule
{
public void AddRoutes(IEndpointRouteBuilder app)
{
app.MapGet("/api/books/{id}", Handle);
}

private static async Task<IResult> Handle(
[FromRoute] Guid id,
ApplicationDbContext context,
CancellationToken cancellationToken)
{
var book = await context.Books
.Include(b => b.Author)
.FirstOrDefaultAsync(b => b.Id == id, cancellationToken);

if (book is null)
{
return Results.NotFound();
}

var response = new BookResponse(book.Id, book.Title, book.Year, book.AuthorId);
return Results.Ok(response);
}
}

As you may have expected, with code doesn't know anything about Tenants. And it is what we were striving for — to create a secure implementation that will now allow customers to interact with another customer's data. Without a need to worry about tenants in each database call.

When creating a book, we check if an Author exists in the database by a provided AuthorId in the request. And EF Core makes sure that we can't add a Book using an Author from another tenant. Amazing!

Using tenant Id header

In some applications a user can have access to multiple tenants, for example, a "super-admin" user that can manage entities for different tenants. In such a case we need to send "X-TenantId" header in each request from the frontend (or another application) whenever we need to retrieve or modify data.

If you have such a case, you can modify your TenantProvider as follows:

public class TenantProvider : ITenantProvider
{
private readonly TenantInfo _tenantInfo;

public TenantProvider(IHttpContextAccessor accessor)
{
var userIdValue = accessor.HttpContext?.User.FindFirstValue("user-id");

Guid? userId = Guid.TryParse(userIdValue, out var guid) ? guid : null;
Guid? headerTenantId = null;

if (accessor.HttpContext?.Request.Headers.TryGetValue("X-TenantId", out var headerGuid) is true)
{
headerTenantId = Guid.Parse(headerGuid.ToString());
}

_tenantInfo = new TenantInfo(userId, headerTenantId);
}

public TenantInfo GetCurrentTenantInfo() => _tenantInfo;
}

In some applications, it is preferable to always use "X-TenantId" header instead of putting it inside a JWT token. This can also be a case when you use external auth providers that have no idea about tenants.

There are different approaches to solving this problem. I will show you one possible solution with a middleware that checks if a user has access to "X-TenantId". If a user doesn't have access to the requested tenant, a 403 Forbidden response is returned.

public class TenantCheckerMiddleware : IMiddleware
{
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
var tenantClaimValue = context.User.Claims.FirstOrDefault(x => x.Type.Equals("tenant-id"))?.Value;
if (tenantClaimValue is null)
{
await next(context);
return;
}

if (!context.Request.Headers.TryGetValue("X-TenantId", out var headerGuid))
{
await next(context);
return;
}

if (tenantClaimValue.Contains(headerGuid.ToString(), StringComparison.Ordinal))
{
await next(context);
return;
}

var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status403Forbidden,
Title = "Bad Request",
Detail = "X-TenantId header contains a tenant id that a user doesn't have access to"
};

context.Response.StatusCode = problemDetails.Status.Value;
context.Response.ContentType = "application/problem+json";

await context.Response.WriteAsJsonAsync(problemDetails);
}
}

In this example, I made a basic check if "X-TenantId" header matches the "tenant-id" from the JWT token. In your case, you might want to check in a database, or even better in a cache.

Conditional Global Query Filters

Unfortunately, EF Core doesn't support Global Query Filters with conditions, though it is a highly requested feature.

For example, you may want to implement the following query filter that will only be applied when a user has a limited access to tenants. While a "super-admin" user can view all the tenants.

if (!TenantProvider.GetCurrentTenantInfo().IsSuperAdmin)
{
builder.Entity<User>()
.HasQueryFilter(x => x.TenantId.Equals(TenantProvider.GetCurrentTenantInfo().TenantId));
}

If you try to test this application, your filters won't trigger even for a regular user. All because DbContext OnModelCreating method is only called once per lifetime of the application — when the first DbContext instance is created.

If you absolutely need to use such Global Query Filters that are applied conditionally depending on each request, you can create a DynamicModelCacheKeyFactory:

public class DynamicModelCacheKeyFactory : IModelCacheKeyFactory
{
public object Create(DbContext context, bool designTime)
=> context is ApplicationDbContext dynamicContext
? (context.GetType(), dynamicContext.TenantProvider.GetCurrentTenantInfo(), designTime)
: context.GetType();

public object Create(DbContext context)
=> Create(context, false);
}

You need to replace a standard IModelCacheKeyFactory with a dynamic one when registering a DbContext:

builder.Services.AddDbContext<ApplicationDbContext>((provider, options) =>
{
var interceptor = provider.GetRequiredService<AuditableInterceptor>();

options.EnableSensitiveDataLogging()
.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsHistoryTable("__MyMigrationsHistory", "devtips_multitenancy");
})
.AddInterceptors(interceptor)
.UseSnakeCaseNamingConvention();

options.ReplaceService<IModelCacheKeyFactory, DynamicModelCacheKeyFactory>();
});

This DynamicModelCacheKeyFactory tells EF Core not to cache a created model (mapping with query filters) and call OnModelCreating method for every instance of DbContext. This ensures that conditional Global Query Filters work, but this approach severely damages the performance of each database call within a DbContext. So use this carefully and benchmark your requests.

Hope you find this blog post useful. Happy coding!

You can download source code for this blog post for free

Download source code

How to implement multitenancy in ASP.NET Core with EF Core (2024)

References

Top Articles
The Park City Daily News from Bowling Green, Kentucky
The Courier-Journal from Louisville, Kentucky
Obituaries in South Bend, IN | South Bend Tribune
Maria Lima Lietz - Hamburger SV
Sixth Circuit Denies Qualified Immunity for State University Officials Who Allegedly Violated Professor's First Amendment Rights
Humidity Yesterday At My Location
Craigslist Richmond Ba
When Does Dtlr Close
Jack Daniels Pop Tarts
Lsn Nashville Tn
Which Statement About These Two Restaurant Meals Is Correct
C.J. Stroud und Bryce Young: Zwei völlig unterschiedliche Geschichten
Amazing Lash Bay Colony
Wdef Schedule
Jordan Torres Leaked
Rachel Zoe first outing hours after announcing shock marriage split
Kyle Gibson Stats Vs Blue Jays 5 Games | StatMuse
Kira Kener 2022
Hannah Palmer Listal
Bfri Forum
Pair sentenced for May 2023 murder of Roger Driesel
Erj Phone Number
Marisa Jacques Bio
The History Of Fujoshi, Male Shippers, And How Its Changed
Mgmresorts.okta.com Login Page
Po Box 790447 St Louis Mo 63179
The Lives of Others - This American Life
Parent Portal Support | Hamilton-Wentworth District School Board
Itsfunnydude11 Wisconsin Volleyball Team : Itsfunnydude11 Twitter, Itsfunnydude11 Reddit – Know About It ! - Opensquares
Lo que necesitas saber antes de desrizarte el cabello
SimpliSafe Home Security Review: Still a Top DIY Choice
Emily Dealy Obituary
Craigslist In Visalia California
Dr Ayad Alsaadi
1875 Grams To Pounds And Ounces
Porter House Ink Photos
Pathfinder 2E Beginner Box Pdf Trove
Rush Copley Swim Lessons
Tyson Foods W2 Online
The Complete Guide to Flagstaff, Arizona
Ekaterina Lisina Wiki
Wush Ear Cleaner Commercial Actor
Easy Pickled Coleslaw (with Canning Video)
Nike.kronos/Wfm/Login
Skip The Games Mil
Jefferson County Ky Pva
Luminous Mysteries - Rosary Meditations
Art Labeling Activity The Big Picture Of Nutrient Catabolism — I Hate CBT's
Schedule An Oil Change At Walmart
Wayfair Outlet Dayton Ohio
Obsidian Guard's Skullsplitter
Randstad Westside
Latest Posts
Article information

Author: Saturnina Altenwerth DVM

Last Updated:

Views: 6156

Rating: 4.3 / 5 (64 voted)

Reviews: 87% of readers found this page helpful

Author information

Name: Saturnina Altenwerth DVM

Birthday: 1992-08-21

Address: Apt. 237 662 Haag Mills, East Verenaport, MO 57071-5493

Phone: +331850833384

Job: District Real-Estate Architect

Hobby: Skateboarding, Taxidermy, Air sports, Painting, Knife making, Letterboxing, Inline skating

Introduction: My name is Saturnina Altenwerth DVM, I am a witty, perfect, combative, beautiful, determined, fancy, determined person who loves writing and wants to share my knowledge and understanding with you.