DEV Community

Cover image for Step-by-step Guide to Implementing Multi-Tenancy in WebAPI Using ASP.NET Core Identity
Kostiantyn Bilous for SharpAssembly

Posted on

Step-by-step Guide to Implementing Multi-Tenancy in WebAPI Using ASP.NET Core Identity

Original article on Medium

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

  1. 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.
  2. 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.
  3. 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.
  4. 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
Enter fullscreen mode Exit fullscreen mode

2. Next, in our WebAPI project, we need to register the Identity service in the Program.cs file.

services.AddIdentity<IdentityUser, IdentityRole>()
            .AddEntityFrameworkStores<AppDbContext>();
Enter fullscreen mode Exit fullscreen mode

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!;
}
Enter fullscreen mode Exit fullscreen mode
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...
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode
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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode
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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);    
    }
}
Enter fullscreen mode Exit fullscreen mode

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";
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Original article on Medium

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)