IdenityServer and implementing database per tenant strategy

Viewed 235

I'm trying to implement multi-tenant IdentityServer, each tenant has its own database.

Client side I'm using Angular, I'm passing my tenantId using oidc-client, like so:

Angular

import { UserManager, UserManagerSettings, User } from "oidc-client";

private manager = new UserManager(getClientSettings());

login() {
    return this.manager.signinRedirect({
        extraQueryParams: {
            tenant: AppConfig.settings.connectionApiData.tenant,
        },
    });
}

AppIdentityDbContext

public class AppIdentityDbContext : IdentityDbContext<AppUser>
{
    private readonly IApplicationService _applicationService;
    private readonly HttpContext _httpContext;
    private readonly ITenantProvider provider;
    private readonly Tenant.Tenant _tenant;

    public AppIdentityDbContext(DbContextOptions<AppIdentityDbContext> options,
        ITenantProvider provider,
        IHttpContextAccessor httpContextAccessor, IApplicationService applicationService) : base(options)
    {
        _tenant = provider.GetTenantById(_applicationService.TenantId);
        _httpContext = httpContextAccessor.HttpContext;
        _applicationService = applicationService;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        // Customize the ASP.NET Identity model and override the defaults if needed.
        // For example, you can rename the ASP.NET Identity table names and more.
        // Add your customizations after calling base.OnModelCreating(builder);

        modelBuilder.Entity<IdentityRole>().HasData(new IdentityRole
            {Name = Constants.Roles.Consumer, NormalizedName = Constants.Roles.Consumer.ToUpper()});
    }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        
        optionsBuilder.UseMySql(_tenant.DatabaseConnectionString);
        base.OnConfiguring(optionsBuilder);
    }
}

TenantProvider

 public class Tenant
{
    public Guid Id { get; set; }
    public string Name { get; set; }
    public string Host { get; set; }
    public string DatabaseConnectionString { get; set; }
}

public interface ITenantProvider
{
    Tenant GetFirstTenant();
    Tenant GetTenantById(Guid id);
}

public class TenantProvider : ITenantProvider
{
    private static readonly IList<Tenant> Tenants = new List<Tenant>
    {
        new Tenant
        {
            Id = Guid.Parse("51aab199-1482-4f0d-8ff1-5ca0e7bc525a"),
            Name = "Imaginary corp",
            DatabaseConnectionString = "server=localhost;port=3306;database=AuthServer;user=root;password=root"
        },

        new Tenant
        {
            Id = Guid.Parse("ae4e21fa-57cb-4733-b971-fdd14c4c667e"),
            Name = "The Very Big corp",
            DatabaseConnectionString = "server=localhost;port=3306;database=AuthServer;user=root;password=root"
        }
    };


    public Tenant GetFirstTenant()
    {
        return Tenants.First();
    }

    public Tenant GetTenantById(Guid id)
    {
        return Tenants.FirstOrDefault(t => t.Id == id);
    }
}

In Startup.cs in order to add AddIdentity I've added a connection string, but the connection string must come from the TenantProvider

Startup.cs

public void ConfigureServices(IServiceCollection services)
    {
        services.AddDbContext<AppIdentityDbContext>(
              options => options.UseMySql(Configuration.GetConnectionString("DefaultConnection")
           ));

        services.AddIdentity<AppUser, IdentityRole>()
            .AddEntityFrameworkStores<AppIdentityDbContext>()
            .AddDefaultTokenProviders();

        services.AddIdentityServer()
            .AddDeveloperSigningCredential()
            // this adds the operational data from DB (codes, tokens, consents)
            .AddOperationalStore(options =>
            {
                options.ConfigureDbContext = builder => builder.UseMySql(Configuration.GetConnectionString("DefaultConnection"));

                // this enables automatic token cleanup. this is optional.
                options.EnableTokenCleanup = true;
                options.TokenCleanupInterval = 30; // interval in seconds
            })
            //.AddInMemoryPersistedGrants()
            .AddInMemoryIdentityResources(Config.GetIdentityResources())
            .AddInMemoryApiResources(Config.GetApiResources())
            .AddInMemoryClients(Config.GetClients())
            .AddAspNetIdentity<AppUser>();

        services.AddTransient<IProfileService, IdentityClaimsProfileService>();
        
        services.AddScoped<IApplicationService, ApplicationService>();

        services.AddCors(options => options.AddPolicy("AllowAll", p => p.AllowAnyOrigin()
           .AllowAnyMethod()
           .AllowAnyHeader()));

        services.AddMvc(options =>
        {
            options.EnableEndpointRouting = false;
        }).SetCompatibilityVersion(CompatibilityVersion.Latest);


        services.AddTransient<ITenantProvider, TenantProvider>();
    }
    
    
    

TenantMiddleware sets the tenantId in applicationService, in order to inject it later with tenantProvider

TenantMiddleware

public class TenantMiddleware
{
    private readonly RequestDelegate next;

    public TenantMiddleware(RequestDelegate next)
    {
        this.next = next;
    }

    public async Task Invoke(HttpContext context, IApplicationService applicationService, ITenantProvider tenant)
    {
         var queryString = HttpUtility.ParseQueryString(context.Request.Query["ReturnUrl"]);

        Guid.TryParse(queryString["tenant"], out var tenantId);

        applicationService.TenantId = tenantId;
        await next(context);
    }
}

How do I setup AppIdentityDbContext in Startup.cs if I must wait for a request to be handled by TenantMiddleware first? Will it work?

2 Answers

For multi tenant with different db source it is not possible to set it on the Startup.cs it must be at runtime like creating some ContextFactory and other stuff just to handle the DbContext setting. You can try this article it might give a hint for the solution you are looking for, Building Multi-tenant Web API With .NET Core and Best Practices.

I think IdentityServer4 doesn't support that. To achieve what you want to do, I assume you have a "master" database to store the connection strings to each tenant DB, Why not store the User in the DB as well?

For our case, we split the "User" table into 2. The business logic info is stored in the Tenant DB. The login related info is stored in the "master" database. And We added GUID columns to link these two tables. Actually the only scenario to use these two tables are only user "Activation" and "De-activation", which we'll call the Identity Service from our main service. Other than that, we only use the "user" table in Tenant DB.

Related