Building web applications for financial services requires a keen focus on security and scalability. One key challenge is managing and protecting client data while serving multiple clients from a single application instance. Multi-tenancy offers a solution by allowing a single application to serve different clients, or "tenants," thereby keeping their data isolated and secure.
Implementing a multi-tenant model in ASP.NET Core, with ASP.NET Core Identity managing user authentication and authorization, requires specific steps that we will discuss below.
Multi-Tenancy: An Overview
Multi-tenancy is commonly seen in cloud computing and SaaS (Software as a Service) applications, enabling efficient resource utilization, simplified application management, and reduced operational costs. Multi-tenancy's complexity and implementation requirements vary depending on the application's needs.
Types of Multi-Tenancy
- Single-Tenant Architecture: Each tenant has a dedicated instance of the software and underlying infrastructure. While not multi-tenancy in the strict sense, it's often discussed in contrast to actual multi-tenant architectures.
- Separate Database: Tenants are separated into different databases with a single application instance. This keeps data separate and requires a new database environment for each new tenant.
- Shared Database, Separate Schemas: Tenants share the same database but have their own schemas and tables. This keeps data separate but within the same database environment.
- Shared Database, Shared Schema: The most complex form of multi-tenancy, where all tenants share the same database and schema. Tenant-specific identifiers differentiate data within the tables but require careful implementation to ensure data isolation.
Benefits of Multi-Tenancy
- Cost Efficiency: Sharing resources among tenants helps reduce hardware and software costs.
- Ease of Maintenance: Updates and patches must be applied to only one application instance, simplifying maintenance.
- Scalability: Scaling a single application instance is more manageable than maintaining multiple instances for each tenant.
- Resource Utilization: Multi-tenancy can result in more efficient resource use, as idle capacity can be reallocated.
Drawbacks of Multi-Tenancy
- Complexity: Implementing a multi-tenant architecture, particularly ensuring data isolation and security, can be complex.
- Performance Risks: Resource contention among tenants can impact performance.
- Security Concerns: Maintaining data isolation when using shared resources requires strict security measures to prevent potential data leaks and meet regulatory requirements.
- Customization Limitations: Providing deep customization options to tenants poses more challenges in a shared environment.
Practical example
Now, let's dive into a practical example from InWestMan, a software application for tracking personal investments that requires isolating sensitive client data to be accessible only to users who created them. In the example, we will add multitenancy support to ensure clients can access and manipulate only portfolios created by them. We will use the last multitenancy type, "Shared Database, Shared Schema," using ASP.NET Core Identity.
1. First, in our Infrastructure project, we switch to using ASP.NET Core Identity's DbContext
, which includes all required identity models, by changing the inheritance of our AppDbContext
from the basic Entity Framework's DbContext
to IdentityDbContext
.
public class AppDbContext : IdentityDbContext
2. Next, in our WebAPI project, we need to register the Identity service in the Program.cs
file.
services.AddIdentity<IdentityUser, IdentityRole>()
.AddEntityFrameworkStores<AppDbContext>();
3. Subsequently, in our Domain project, we add a TenantId
property to Aggregate Roots, which, according to our domain model, are sensitive, for example, a Portfolio
entity that stores the client's Positions
.
public abstract class BaseEntityWithMultitenancy<TId>
{
public List<BaseDomainEvent> Events = [];
public string TenantId { get; set; } = string.Empty;
public TId Id { get; set; } = default!;
}
public class Portfolio : BaseEntityWithMultitenancy<Guid>, IAggregateRoot
{
public string Name { get; set; } = string.Empty;
public ICollection<Position> Positions { get; set; } = new List<Position>();
// other logic and sensitive fields...
}
4. Furthermore, in the Application project, we need to create MultiTenancyService
that will keep a current tenant in the scope of the WebAPI call.
public interface IMultiTenancyService
{
string CurrentTenant { get; }
void SetCurrentTenant(string tenant);
}
public class MultiTenancyService : IMultiTenancyService
{
private string _tenant = string.Empty;
public string CurrentTenant
{
get => _tenant;
private set
{
if (_tenant != value)
{
var oldTenant = _tenant;
_tenant = value;
}
}
}
public void SetCurrentTenant(string tenant)
{
if (string.IsNullOrWhiteSpace(tenant))
throw new ArgumentException("CurrentTenant cannot be null or whitespace.", nameof(tenant));
CurrentTenant = tenant;
}
}
5. It is crucial to remember to register the new MultiTenancyService
in the service collection in the Program.cs
file in the WebAPI project.
services.AddScoped(typeof(IMultiTenancyService), typeof(MultiTenancyService));
6. Now, let's return to our Application project. I utilize the MediatR's AddPortfolioCommand
to add a new Portfolio
to the database; therefore, I need to update my AddPortfolioHandler
to include the client's tenant when saving the created entity. To achieve this, I pass an IMultiTenancyService
instance to my AddPortfolioHandler
class and explicitly assign the current TenantId
to the newly created Portfolio
object before saving.
public class AddPortfolioCommand : IRequest<Result<Guid>>
{
public string Name { get; set; }
public CurrencyCode CurrencyCode { get; set; }
}
public class AddPortfolioHandler : IRequestHandler<AddPortfolioCommand, Result<Guid>>
{
private readonly IMultiTenancyService _multiTenancyService;
private readonly IRepository<Portfolio> _repository;
public AddPortfolioHandler(IRepository<Portfolio> repository,
IMultiTenancyService multiTenancyService)
{
_repository = repository;
_multiTenancyService = multiTenancyService;
}
public async Task<Result<Guid>> Handle(AddPortfolioCommand request, CancellationToken cancellationToken)
{
try
{
var portfolio = new Portfolio(request.Name, request.CurrencyCode);
portfolio.TenantId = _multiTenancyService.CurrentTenant;
await _repository.AddAsync(portfolio, cancellationToken);
return Result.Ok(portfolio.Id);
}
catch (ArgumentException ex)
{
return Result.Fail<Guid>(ex.Message);
}
}
}
7. Returning to our Infrastructure project, we must ensure that the user will be provided only with data associated with her and only her tenancy. To achieve this, we add a global filter in AppDbContext
for Portfolio
entity into the overridden method OnModelCreating()
. Additionally, we specify a new index based on the TenantId
field to improve read query performance.
public class AppDbContext : IdentityDbContext
{
private readonly IMultiTenancyService _multiTenancyService;
public AppDbContext(DbContextOptions<AppDbContext> options,
IMultiTenancyService multiTenancyService)
: base(options)
{
_multiTenancyService = multiTenancyService;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Portfolio>()
.HasQueryFilter(b => b.TenantId == _multiTenancyService.CurrentTenant)
.HasIndex(b => b.TenantId)
.IsUnique(false);
}
}
8. Next, we switch to the WebAPI project, where it's necessary to read a tenancy claim from HTTP cookies and set the TenantId
in MultiTenancyService
using a specially created MultiTenancy
middleware.
public class MultiTenancy
{
private readonly RequestDelegate _next;
public MultiTenancy(RequestDelegate next)
{
_next = next;
}
public async Task Invoke(HttpContext httpContext, IMultiTenancyService multiTenancyService)
{
var tenantId = DetermineTenant(httpContext);
multiTenancyService.SetCurrentTenant(tenantId);
await _next(httpContext);
}
private string DetermineTenant(HttpContext context)
{
var user = context.User;
if (user.HasClaim(c => c.Type == "tenant"))
{
return user.FindFirst("tenant")?.Value ?? string.Empty;
}
return "default-tenant";
}
}
9. Subsequently, we must incorporate our MultiTenancy
middleware in the Program.cs
file. It should be added after the authentication process, as we will read a TenantId
from the authenticated user's HttpContext.
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<Middlewares.MultiTenancy>();
app.MapControllers();
10. Finally, we must ensure the TenantId
is generated and stored as a Claim
in the user's profile during the client registration process in the Authentication
controller, and then returned to HTTP cookies upon successful authentication.
[HttpPost("Register")]
public async Task<IActionResult> Register([FromBody] UserDto model)
{
if (ModelState.IsValid)
{
var user = new IdentityUser { UserName = model.Name, Email = model.Email };
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
var tenantId = Guid.NewGuid().ToString();
var tenantClaim = new Claim("tenant", tenantId);
await _userManager.AddClaimAsync(user, tenantClaim);
return Ok(new { message = "User registered successfully" });
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
return BadRequest(ModelState);
}
Conclusion
Implementing multi-tenancy in an ASP.NET Core WebAPI is a strategic approach to managing and protecting client data across multiple tenants within a single application instance. We explored the essential steps to set up a multi-tenant architecture using ASP.NET Core Identity and Entity Framework Core, ensuring data isolation and security while maintaining performance and scalability.
We've demonstrated how multi-tenancy can be achieved through a shared database and shared schema approach, leveraging ASP.NET Core Identity for user management and custom MultiTenancy middleware for tenant identification. Following the practical example, you can adapt these concepts for your projects, ensuring each client's data remains secure and isolated, even in a shared environment.
Multi-tenancy offers numerous benefits, including cost efficiency, ease of maintenance, and scalability, making it an attractive option for SaaS applications and services. However, it's crucial to be mindful of the potential complexities and security implications, planning your implementation carefully to avoid performance issues and ensure robust data protection.
Whether you're building a new application or enhancing an existing one, I hope the principles and practices outlined in this guide will help you navigate multi-tenancy challenges, enabling you to deliver a more flexible, secure, and scalable solution to your users.
Stay tuned for more insights and detailed analyses, and feel free to share your thoughts or questions in the comments below!
SharpAssembly on Dev.to
SharpAssembly on Medium
SharpAssembly on Telegram
Cover credits: DALL·E generated
Top comments (0)