How to implement one database multitenancy using Clean Architecture template

How to implement one database multitenancy using Clean Architecture template

This is my first blog post and being honest the very first blog in my life, and I want to start it by sharing with you how I implemented one database multitenancy using Clean Architecture template. (Personally because the project demanded, I implemented multitenancy in a one-tenant-per-user way, but the same approach could work if you wanted to have multiple-tenants-per-user, we will discuss that later).

To begin with, because I wanted my Tenant entity (and some others), to use other type than int for primary key (in this case Guid), I converted the BaseEntity and it’s child classes to support generic type parameter for the Id property.

public abstract class BaseEntity<T> : BaseEntity
{
    public T Id { get; set; } = default!;
}

public abstract class BaseEntity
{
    private readonly List<BaseEvent> _domainEvents = new();

    [NotMapped]
    public IReadOnlyCollection<BaseEvent> DomainEvents => _domainEvents.AsReadOnly();

    public void AddDomainEvent(BaseEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void RemoveDomainEvent(BaseEvent domainEvent)
    {
        _domainEvents.Remove(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

public abstract class BaseAuditableEntity<T> : BaseEntity<T>
{
    public DateTimeOffset Created { get; set; }

    public string? CreatedBy { get; set; }

    public DateTimeOffset LastModified { get; set; }

    public string? LastModifiedBy { get; set; }
}

Next, I declared the entities:

  • Tenant
  • TenantUserInvite
public class Tenant : BaseAuditableEntity<Guid>
{
    public string? OwnerId { get; set; }
}

public class TenantUserInvite : BaseAuditableEntity<Guid>
{
    public Guid TenantId { get; set; }
    public Tenant Tenant { get; set; } = default!;
    public string Email { get; set; } = string.Empty;
    public bool IsCompleted { get; set; }
}

Added them into the Application’s DbContext like that:

public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IApplicationDbContext
{
    private readonly IUser _user;
    private readonly ICurrentTenant _currentTenant;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, IUser user, ICurrentTenant currentTenant) : base(options)
    {
        _user = user;
        _currentTenant = currentTenant;
    }

    public DbSet<Tenant> Tenants => Set<Tenant>();
    public DbSet<TenantUserInvite> TenantUserInvites => Set<TenantUserInvite>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);

        builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
    }
}

Also, configured the Tenant entity using IEntityTypeConfiguration:

public class TenantEntityConfiguration : IEntityTypeConfiguration<Tenant>
{
    public void Configure(EntityTypeBuilder<Tenant> builder)
    {
        builder.HasOne<ApplicationUser>()
            .WithOne(ap => ap.Tenant)
            .HasForeignKey<Tenant>(te => te.OwnerId)
            .OnDelete(DeleteBehavior.NoAction);
    }
}

ApplicationUser:

public class ApplicationUser : IdentityUser
{
    public Guid? TenantId { get; set; }
    public Tenant? Tenant { get; set; } = default!;
}

IUser in ApplicationDbContext is the default implementation from the template, ICurrentTenant is a service that works alike the IUser, it gets the TenantId from the HttpContext User Claims like that:

public interface ICurrentTenant
{
    /// <summary>
    /// Current tenant id, if any.
    /// </summary>
    string? Id { get; }
}

public class CurrentTenant : ICurrentTenant
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CurrentTenant(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
    }

    public string? Id
    {
        get => _httpContextAccessor.HttpContext?.User?.FindFirstValue("TenantId");
    }
}

The claim itself was added by creating a new UserClaimsPrincipalFactory based on the one Identity Core gives us:

public class UserClaimsFactory : UserClaimsPrincipalFactory<ApplicationUser>
{
    public UserClaimsFactory(UserManager<ApplicationUser> userManager, IOptions<IdentityOptions> optionsAccessor) : base(userManager, optionsAccessor)
    {
    }

    protected override async Task<ClaimsIdentity> GenerateClaimsAsync(ApplicationUser user)
    {
        var id = await base.GenerateClaimsAsync(user);
        id.AddClaim(new Claim("TenantId", user.TenantId.ToString() ?? ""));
        return id;
    }
}

Next, TenantUserInvite is used to perform needed checks upon user registration. In my implementation of multitenancy the tenant is assigned on user registration. The registration request includes a nullable property TenantId, if it is null, we are creating a new tenant, if not we check in our database for an invitation into that tenant.

As you probably know the Clean Architecture template, uses MapIdentityApi, which cannot be overriden, but can be copied from the extensions class of Identity Core repository.

Created a file in Infrastructure/Identity/IdentityApiEndpointRouteBuilderExtensions.cs and copied the content of the file I mentioned above.

There I modified the “/register” endpoint:

// NOTE: We cannot inject UserManager<TUser> directly because the TUser generic parameter is currently unsupported by RDG.
// https://github.com/dotnet/aspnetcore/issues/47338
routeGroup.MapPost("/register", async Task<Results<Ok, ValidationProblem>>
    ([FromBody] CustomRegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) =>
{
    var userManager = sp.GetRequiredService<UserManager<TUser>>();

    if (!userManager.SupportsUserEmail)
    {
        throw new NotSupportedException($"{nameof(MapCustomIdentityApi)} requires a user store with email support.");
    }

    var userStore = sp.GetRequiredService<IUserStore<TUser>>();
    var emailStore = (IUserEmailStore<TUser>)userStore;
    var email = registration.Email;

    if (string.IsNullOrEmpty(email) || !_emailAddressAttribute.IsValid(email))
    {
        return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email)));
    }

    var user = new TUser();
    await userStore.SetUserNameAsync(user, email, CancellationToken.None);
    await emailStore.SetEmailAsync(user, email, CancellationToken.None);

    var tenantService = sp.GetRequiredService<ITenantService>();
    var appUser = user as ApplicationUser;
    bool isInvitedIntoTenant = false;
    string tenantId = registration.TenantId ?? string.Empty;

    if (appUser is null)
    {
        throw new NotSupportedException("User must be of type ApplicationUser to support tenant registration.");
    }

    if (!string.IsNullOrEmpty(tenantId))
    {
        if (!await tenantService.UserIsInvitedToTenant(registration.Email, tenantId))
        {
            return CreateValidationProblem("UserNotInvitedToTenant", "User is not invited to tenant.");
        }
        appUser.TenantId = new Guid(tenantId);
        isInvitedIntoTenant = true;
    }

    var result = await userManager.CreateAsync(user, registration.Password);

    if (!result.Succeeded)
    {
        return CreateValidationProblem(result);
    }

    if (isInvitedIntoTenant)
    {
        await tenantService.CompleteUserInvite(registration.Email, tenantId);
    }
    else
    {
        appUser.TenantId = await tenantService.CreateNewTenant(appUser.Id);
        await userManager.UpdateAsync(user);
    }

    await SendConfirmationEmailAsync(user, userManager, context, email);
    return TypedResults.Ok();
});

// With the CustomRegisterRequest class be:

/// <summary>
/// The request type for the "/register" endpoint added by <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi"/>.
/// </summary>
public sealed class CustomRegisterRequest
{
    /// <summary>
    /// The user's email address which acts as a user name.
    /// </summary>
    public required string Email { get; init; }

    /// <summary>
    /// The user's password.
    /// </summary>
    public required string Password { get; init; }

    /// <summary>
    /// TenantId, if specified, we are searching for an invite based on that and the new user's email address.
    /// </summary>
    public string? TenantId { get; set; }
}

Using the ITenantService, I checked and performed the needed operations. Here is the interface and the implementation of it:

/// <summary>
/// Handle tenants.
/// </summary>
public interface ITenantService
{
    /// <summary>
    /// Checks if a user is invited to a tenant based on their email and tenant id.
    /// </summary>
    /// <param name="email"></param>
    /// <param name="tenantId"></param>
    /// <returns></returns>
    Task<bool> UserIsInvitedToTenant(string email, string tenantId, CancellationToken cancellation = default);

    /// <summary>
    /// Completes a user invite for a given email and tenant id. This marks the invite as completed.
    /// </summary>
    /// <param name="email"></param>
    /// <param name="tenantId"></param>
    /// <returns></returns>
    Task CompleteUserInvite(string email, string tenantId, CancellationToken cancellation = default);

    /// <summary>
    /// Creates a new tenant and returns its unique identifier.
    /// </summary>
    /// <remarks>This method performs the necessary operations to initialize a new tenant in the system. The
    /// returned <see cref="Guid"/> can be used to reference the tenant in subsequent operations.</remarks>
    /// <returns>A <see cref="Guid"/> representing the unique identifier of the newly created tenant.</returns>
    Task<Guid> CreateNewTenant(string userId, CancellationToken cancellation = default);

    /// <summary>
    /// Checks if a user belongs to another tenant based on their email and a tenant id to compare against.
    /// </summary>
    /// <param name="email"></param>
    /// <param name="compareTenantId"></param>
    /// <returns></returns>
    Task<bool> UserBelongsToOtherTenant(string email, Guid compareTenantId, CancellationToken cancellation = default);

    /// <summary>
    /// Checks if user is the owner of the tenant.
    /// </summary>
    /// <param name="userId"></param>
    /// <param name="tenantId"></param>
    /// <returns></returns>
    Task<bool> UserIsTenantOwner(string userId, Guid tenantId, CancellationToken cancellation = default);

    /// <summary>
    /// Removes a user from a tenant. This operation is typically used to disassociate a user from a tenant, effectively revoking their access and permissions within that tenant context.
    /// </summary>
    /// <param name="userId"></param>
    /// <param name="tenantId"></param>
    /// <param name="cancellation"></param>
    /// <returns></returns>
    Task RemoveUserFromTenant(string userId, Guid tenantId, CancellationToken cancellation = default);
}

public class TenantService : ITenantService
{
    private readonly IApplicationDbContext _dbContext;
    private readonly UserManager<ApplicationUser> _userManager;

    public TenantService(IApplicationDbContext dbContext, UserManager<ApplicationUser> userManager)
    {
        _dbContext = dbContext;
        _userManager = userManager;
    }

    public async Task CompleteUserInvite(string email, string tenantId, CancellationToken cancellation = default)
    {
        TenantUserInvite? userInvite = await _dbContext.TenantUserInvites.FirstOrDefaultAsync(t => t.Email == email && t.TenantId.ToString() == tenantId && t.IsCompleted == false);
        if(userInvite is null)
        {
            throw new InvalidOperationException($"No invite found for email {email} in tenant {tenantId}.");
        }
        userInvite.IsCompleted = true;
        _dbContext.TenantUserInvites.Update(userInvite);
        await _dbContext.SaveChangesAsync(cancellation);
    }

    public async Task<Guid> CreateNewTenant(string userId, CancellationToken cancellation = default)
    {
        Tenant newTenant = new Tenant() { OwnerId = userId };
        _dbContext.Tenants.Add(newTenant);
        await _dbContext.SaveChangesAsync(cancellation);
        return newTenant.Id;
    }

    public async Task RemoveUserFromTenant(string userId, Guid tenantId, CancellationToken cancellation = default)
    {
        var user = await _userManager.FindByIdAsync(userId);
        if(user is null)
        {
            throw new NotFoundException(userId, "User");
        }

        user.TenantId = null;

        await _userManager.UpdateAsync(user);

        return;
    }

    public async Task<bool> UserBelongsToOtherTenant(string email, Guid compareTenantId, CancellationToken cancellation = default)
    {
        var user = await _userManager.FindByEmailAsync(email);
        if(user is null)
        {
            return false;
        }

        if(user.TenantId != compareTenantId)
        {
            return true;
        }

        return false;
    }

    public async Task<bool> UserIsInvitedToTenant(string email, string tenantId, CancellationToken cancellation = default)
    {
        return await _dbContext.TenantUserInvites.AnyAsync(t => t.Email == email && t.TenantId.ToString() == tenantId && t.IsCompleted == false, cancellation);
    }

    public async Task<bool> UserIsTenantOwner(string userId, Guid tenantId, CancellationToken cancellation = default)
    {
        return await _dbContext.Tenants.AnyAsync((te) => te.OwnerId == userId && te.Id == tenantId, cancellation);
    }
}

Do not forget to register the services in the Infrastructure DependencyInjection extension class:

public static class DependencyInjection
{
    public static void AddInfrastructureServices(this IHostApplicationBuilder builder)
    {
        var connectionString = builder.Configuration.GetConnectionString("SomeAppDb");
        Guard.Against.Null(connectionString, message: "Connection string 'SomeAppDb' not found.");

        builder.Services.AddScoped<ICurrentTenant, CurrentTenant>();

        builder.Services.AddScoped<ISaveChangesInterceptor, AuditableEntityInterceptor>();
        builder.Services.AddScoped<ISaveChangesInterceptor, DispatchDomainEventsInterceptor>();

        builder.Services.AddDbContext<ApplicationDbContext>((sp, options) =>
        {
            options.AddInterceptors(sp.GetServices<ISaveChangesInterceptor>());
            options.UseSqlServer(connectionString);
        });

        builder.Services.AddScoped<IApplicationDbContext>(provider => provider.GetRequiredService<ApplicationDbContext>());

        builder.Services.AddScoped<ITenantService, TenantService>();

        builder.Services.AddAuthentication()
            .AddBearerToken(IdentityConstants.BearerScheme);

        builder.Services.AddAuthorizationBuilder();

        builder.Services
            .AddIdentityCore<ApplicationUser>()
            .AddRoles<IdentityRole>()
            .AddEntityFrameworkStores<ApplicationDbContext>()
            .AddClaimsPrincipalFactory<UserClaimsFactory>()
            .AddApiEndpoints();

        builder.Services.AddSingleton(TimeProvider.System);
        builder.Services.AddTransient<IIdentityService, IdentityService>();

        builder.Services.AddAuthorization(options =>
            options.AddPolicy(Policies.CanPurge, policy => policy.RequireRole(Roles.Administrator)));
    }
}

Important note: The ITenantService service goes after ApplicationDbContext and ICurrentTenant before.

For Tenants and TenantUserInvites manipulations I created these Commands:

namespace SomeApp.Application.Tenants.Commands.RemoveUserFromTenant;

[Authorize]
public record RemoveUserFromTenantCommand : IRequest<bool>
{
    public required string UserId { get; set; }
}

public class RemoveUserFromTenantCommandValidator : AbstractValidator<RemoveUserFromTenantCommand>
{
    public RemoveUserFromTenantCommandValidator()
    {
    }
}

public class RemoveUserFromTenantCommandHandler : IRequestHandler<RemoveUserFromTenantCommand, bool>
{
    private readonly IApplicationDbContext _context;
    private readonly ITenantService _tenantService;
    private readonly IUser _user;
    private readonly ICurrentTenant _currentTenant;

    public RemoveUserFromTenantCommandHandler(IApplicationDbContext context, ITenantService tenantService, IUser user, ICurrentTenant currentTenant)
    {
        _context = context;
        _tenantService = tenantService;
        _user = user;
        _currentTenant = currentTenant;
    }

    public async Task<bool> Handle(RemoveUserFromTenantCommand request, CancellationToken cancellationToken)
    {
        if(string.IsNullOrEmpty(_user.Id) || string.IsNullOrEmpty(_currentTenant.Id))
        {
            return false;
        }

        if (!(await _tenantService.UserIsTenantOwner(_user.Id, new Guid(_currentTenant.Id), cancellationToken))) 
        {
            throw new UserIsNotTenantOwnerException();
        }

        var result = await _tenantService.UserBelongsToOtherTenant(request.UserId, new Guid(_currentTenant.Id), cancellationToken);
        if (result)
        {
            throw new UserBelongsToOtherTenantException(request.UserId);
        }

        await _tenantService.RemoveUserFromTenant(request.UserId, new Guid(_currentTenant.Id), cancellationToken);

        return true;
    }
}
namespace SomeApp.Application.TenantUserInvites.Commands.InviteUserToTenant;

[Authorize]
public record InviteUserToTenantCommand : IRequest<bool>
{
    public required string Email { get; set; }
}

public class InviteUserToTenantCommandValidator : AbstractValidator<InviteUserToTenantCommand>
{
    public InviteUserToTenantCommandValidator()
    {
        RuleFor(x => x.Email).EmailAddress()
            .WithMessage("The email address is not valid.")
            .NotEmpty()
            .WithMessage("The email address cannot be empty.");
    }
}

public class InviteUserToTenantCommandHandler : IRequestHandler<InviteUserToTenantCommand, bool>
{
    private readonly IApplicationDbContext _context;
    private readonly IUser _user;
    private readonly ITenantService _tenantService;

    public InviteUserToTenantCommandHandler(IApplicationDbContext context, IUser user, ITenantService tenantService)
    {
        _context = context;
        _user = user;
        _tenantService = tenantService;
    }

    public async Task<bool> Handle(InviteUserToTenantCommand request, CancellationToken cancellationToken)
    {
        var tenant = await _context.Tenants.FirstOrDefaultAsync(tenant => tenant.OwnerId == _user.Id);
        if (tenant is null)
        {
            throw new TenantNotFoundException();
        }

        var userIsInvited = await _context.TenantUserInvites
            .AnyAsync(invite => invite.Email == request.Email && invite.TenantId == tenant.Id && !invite.IsCompleted, cancellationToken);
        if (userIsInvited)
        {
            throw new UserIsAlreadyInvitedToTenantException(request.Email);
        }

        if(await _tenantService.UserBelongsToOtherTenant(request.Email, tenant.Id))
        {
            throw new UserBelongsToOtherTenantException(request.Email);
        }

        var tenantUserInvite = new TenantUserInvite
        {
            Email = request.Email,
            TenantId = tenant.Id,
            IsCompleted = false
        };

        _context.TenantUserInvites.Add(tenantUserInvite);
        await _context.SaveChangesAsync(cancellationToken);

        return true;
    }
}

Exceptions are for the beauty of the code, nothing special.

And these endpoints:

public class Tenants : EndpointGroupBase
{
    public override void Map(WebApplication app)
    {
        app.MapGroup(this)
            .RequireAuthorization()
            .MapPost(InviteUserToTenant, "invite")
            .MapDelete(RemoveUserFromTenant, "removeUser");
    }

    public async Task<Results<Ok<string>, NotFound<string>, BadRequest<string>>> RemoveUserFromTenant(ISender sender, [FromBody] RemoveUserFromTenantCommand command)
    {
        try
        {
            await sender.Send(command);
            return TypedResults.Ok("Removed user from tenant successfully.");
        }
        catch (UserIsNotTenantOwnerException ex)
        {
            return TypedResults.BadRequest(ex.Message);
        }
        catch (UserBelongsToOtherTenantException ex)
        {
            return TypedResults.BadRequest(ex.Message);
        }
    }


    public async Task<Results<Ok<string>, NotFound<string>, BadRequest<string>>> InviteUserToTenant(ISender sender, [FromBody] InviteUserToTenantCommand command)
    {
        try
        {
            await sender.Send(command);
            return TypedResults.Ok("User invited successfully.");
        }
        catch (TenantNotFoundException)
        {
            return TypedResults.NotFound("Tenant not found.");
        }
        catch (UserIsAlreadyInvitedToTenantException ex)
        {
            return TypedResults.BadRequest(ex.Message);
        }
        catch (UserBelongsToOtherTenantException ex)
        {
            return TypedResults.BadRequest(ex.Message);
        }
    }
}

And just like that, you have a basic multitenancy system. 🙂

Vladimir Mazarakis Avatar

Leave a Reply

Your email address will not be published. Required fields are marked *