Adding MultiTenancy to selfhost project with mixed auth-db

I have a self-host project running on linux using Kestrel and nginx.

I have used the selfhost template : x selfhost

And added auth: x mix auth-db mysql

I need a separate database per customer.

The api would look something like this:

/tenantId/someressource

I want every request that go through for example:

/tenant01/someressource
-> mysql database tenant01

/tenant02/someressource
-> mysql database tenant01

I’ve been looking at this documentation: https://docs.servicestack.net/multitenancy

Is the last example example (Multi Tenancy Example: https://github.com/ServiceStack/ServiceStack/blob/master/tests/ServiceStack.WebHost.Endpoints.Tests/MultiTennantAppHostTests.cs) on the above page the best way to go for this?

Would this work with a selfhosted project as well?

I’m trying to find some guide on how to add multitenancy to a selfhost project with auth-db mixed in.

The Multitenancy Example is a good starting configuration, you’ll still need to configure the route of every Service with the path you want, e.g:

[Route("/{TenantId}/someresource")]
public class SomeResource : IForTenant, IReturn<SomeResourceResponse>
{
    public string TenantId { get; set; }
    //...
}

I’d recommend starting with the web project instead which is an empty .NET Core Web App with the recommended .NET Configuration.

Is the web project using Kestrel by default and can it be deployed as is on Linux?

All .NET Core Templates are cross-platform & can run on Linux.

It uses Kestrel by default & when running on Linux, but ASP .NET Core Apps can also be easily configured to run in different hosts on Windows as well.

1 Like

Thanks. I got the multitenant part working I’ll see if I can add the authentification and authorization.

I know this is not something specific to ServiceStack but I’ve been having issues (and apparently it’s common from my searches) where I can’t get my ASP.NET Core developer certificate to be properly trusted on my development machine.

No matter what I do I always got the warning in the browser that the certificate is not trusted etc. I tried everything I could find online including the command to trust the certificate manually:

dotnet dev-certs https --trust

I ended up following the method in this article:

And create a self signed and modified my CreateHostBuilder like so:

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
            .ConfigureWebHostDefaults(builder =>
            {
                builder.UseModularStartup<Startup>()
                    .UseKestrel((context, options) =>
                    {
                        if (!context.HostingEnvironment.IsDevelopment()) return;
                        //localhost certificate.
                        var fileName = context.Configuration["CertificateFileName"];
                        var pwd = context.Configuration["CertificatePassword"];

                        var certificate = new X509Certificate2(fileName, pwd);
                        options.AddServerHeader = false;
                        options.Listen(IPAddress.Loopback, 44321, listenOptions => { listenOptions.UseHttps(certificate); });
                    })
                    ;
            });

This works perfectly and the certificate is trusted etc.

I was wondering if there’s a cleaner way to do this using by modifying launchSettings.json and appsettings.Development.json to achieve the same. I’ll keep searching in the meantime.

I found the config on the link you posted. Thanks!

In any case maybe the above article can help other people with the same issue.

Thanks!

Using mix auth-db 2 new files are added:

Configure.Db.cs
Configure.AuthRepository.cs

Right now, I have pretty much everything in Startup.cs as per the MultiTenancy example (full code at the end).

Does it make sense to use the added classes/files generated by mix auth-db in the context of a MultiTenancy service?

For example, in the Configure method of AppHost in the Multitenancy example we have:

container.Register<IAuthRepository>(c => new OrmLiteAuthRepositoryMultitenancy(c.TryResolve<IDbConnectionFactory>(),
                    connectionStrings.ToArray()));

From what I understand, I could use Configure.Db.cs to set the “Master” db from the example and create any tables in that database if needed.

But should I also move in this class the code to initiate each tenant db and somehow set the array of available connection strings in that class? If so how can this be done?

The same question applies to Configure.AuthRepository.cs

For example,

Should this:

public class ConfigureAuthRepository : IConfigureAppHost, IConfigureServices, IPreInitPlugin
{
    public void Configure(IServiceCollection services)
    {
        services.AddSingleton<IAuthRepository>(c =>
            new OrmLiteAuthRepository<AppUser, UserAuthDetails>(c.Resolve<IDbConnectionFactory>())
            {
                UseDistinctRoleTables = true
            });
    }
    
    ...
}

Be modified to use OrmLiteAuthRepositoryMultitenancy instead? Is so how can the array of connections be accessed?

Here is the full content of my Startup.cs file:

using System;
using System.Collections.Generic;
using System.Data;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Funq;
using ServiceStack;
using ServiceStack.Configuration;
using MultitenancyAuth.ServiceInterface;
using ServiceStack.Auth;
using ServiceStack.Data;
using ServiceStack.OrmLite;
using ServiceStack.Web;

namespace MultitenancyAuth
{
    public class Startup : ModularStartup
    {

        public new void ConfigureServices(IServiceCollection services)
        {
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseServiceStack(new AppHost
            {
                AppSettings = new NetCoreAppSettings(Configuration)
            });
        }
    }

    public class AppHost : AppHostBase
    {
        public AppHost() : base("MultitenancyAuth", typeof(MultiTenantService).Assembly) {}

        public override IAuthRepository GetAuthRepository(IRequest req = null)
        {
            Console.WriteLine($"In {typeof(AppHost)} ** override ** {nameof(GetAuthRepository)} param: {nameof(IRequest)}");
            
            return req != null
                ? new OrmLiteAuthRepositoryMultitenancy(GetDbConnection(req)) //At Runtime
                : TryResolve<IAuthRepository>();                              //On Startup
        }
        
        // Configure your AppHost with the necessary configuration and dependencies your App needs
        public override void Configure(Container container)
        {
            Console.WriteLine($"In {typeof(AppHost)} {nameof(Configure)} param: {nameof(Container)}");
            
            SetConfig(new HostConfig
            {
                DefaultRedirectPath = "/metadata",
                DebugMode = AppSettings.Get(nameof(HostConfig.DebugMode), false)
            });

            Plugins.Add(new AuthFeature(() => new AuthUserSession(),
                new IAuthProvider[]
                {
                    new BasicAuthProvider(AppSettings),
                    new CredentialsAuthProvider(AppSettings)
                }));
            
            var maindb = "maindb";
            
            TenantCredentials credentials = Configuration.GetSection(maindb)
                .Get<TenantCredentials>();
            
            string mainDbConnection = $"Server=localhost;User Id={credentials.MySql.User};Password={credentials.MySql.UserPassword};Database={maindb};Pooling=true;MinPoolSize=0;MaxPoolSize=200";
            
            container.Register<IDbConnectionFactory>(new OrmLiteConnectionFactory(mainDbConnection, MySqlDialect.Provider));
            
            var dbFactory = container.Resolve<IDbConnectionFactory>();
            
            List<string> tenants = new List<string>
            {
                "tenantone",
                "tenanttwo"
            };
            
            List<string> connectionStrings = new List<string>();
            
            foreach (var tenant in tenants)
            {
                string conn = GetConnectionStringForTenant(tenant);
                
                connectionStrings.Add(conn);
                
                using IDbConnection db = dbFactory.OpenDbConnectionString(conn);
                InitDb(db, tenant, $"{tenant} inc.");
            }
            
            container.Register<IAuthRepository>(c =>
                new OrmLiteAuthRepositoryMultitenancy(c.TryResolve<IDbConnectionFactory>(),
                    connectionStrings.ToArray()));

            container.Resolve<IAuthRepository>().InitSchema(); // Create any missing UserAuth tables
            
            RegisterTypedRequestFilter<IForTenant>((req,res,dto) => 
                req.Items[Keywords.DbInfo] = new ConnectionInfo { ConnectionString = GetConnectionStringForTenant(dto.TenantId)});
        }
        
        public void InitDb(IDbConnection db, string tenantId, string company)
        {
            Console.WriteLine($"In {typeof(AppHost)} {nameof(InitDb)} params: {nameof(IDbConnection)} tenantID, company");
            
            db.DropAndCreateTable<TenantConfig>();
            db.Insert(new TenantConfig { Id = tenantId, Company = company });
        }

        public string GetConnectionStringForTenant(string tenantId)
        {
            Console.WriteLine($"In {typeof(AppHost)} {nameof(GetConnectionStringForTenant)} params: tenantID");
            
            if (string.IsNullOrWhiteSpace(tenantId)) return null;

            TenantCredentials credentials = Configuration.GetSection(tenantId)
                .Get<TenantCredentials>();
            
            if (string.IsNullOrWhiteSpace(credentials.MySql.User)) return null;

            string db = tenantId.ToLowerInvariant();
            
            return $"Server=localhost;User Id={credentials.MySql.User};Password={credentials.MySql.UserPassword};Database={db};Pooling=true;MinPoolSize=0;MaxPoolSize=200";
        }
    }
}

The Modular Startup classes are there to define groups of related functionality that you can layer on. So you could either add all the Multitenancy configuration in a single .Db class or across multiple classes. I’d probably replace the impl in Configure.AuthRepository.cs with the OrmLiteAuthRepositoryMultitenancy registration. It would just comes down to preference as which is more manageable, nothing technical.

To share configuration between the various plugins & functionality I’d likely have a populated MultitenancyConfig class registered in the IOC which everyone will be access with container.Resolve<MultitenancyConfig>() or anywhere else in ServiceStack via the singleton HostContext.TryResolve<MultitenancyConfig>().

I created a class MultiTenancyConfig:

    public class MultiTenancyConfig
    {
        public MultiTenancyConfig(IConfiguration configuration)
        {
            Configuration = configuration;
            
            Tenants = new List<string>
            {
                "tenantone",
                "tenanttwo"
            };

            List<string> conns = new List<string>();
            
            foreach (var tenant in Tenants)
            {
                conns.Add(GetConnectionStringForTenant(tenant));
            }

            Connections = conns.ToArray();
        }
        
        IConfiguration Configuration { get; }
        public string[] Connections { get; }
        
        public List<string> Tenants { get; }
        
        public string GetConnectionStringForTenant(string tenantId)
        {
            Console.WriteLine($"In {typeof(AppHost)} {nameof(GetConnectionStringForTenant)} params: tenantID");
            
            if (string.IsNullOrWhiteSpace(tenantId)) return null;
        
            TenantCredentials credentials = Configuration.GetSection(tenantId)
                .Get<TenantCredentials>();
            
            if (string.IsNullOrWhiteSpace(credentials.MySql.User)) return null;
        
            string db = tenantId.ToLowerInvariant();
            
            return $"Server=localhost;User Id={credentials.MySql.User};Password={credentials.MySql.UserPassword};Database={db};Pooling=true;MinPoolSize=0;MaxPoolSize=200";
        }
    }

In AppHost Configure I register it like this:

container.Register(new MultiTenancyConfig(Configuration));

Now when you say you would replace the implementation in Configure.AuthRepository.cs do you mean this portion:

    public class ConfigureAuthRepository : IConfigureAppHost, IConfigureServices, IPreInitPlugin
    {
        public void Configure(IServiceCollection services)
        {
            services.AddSingleton<IAuthRepository>(c =>
                new OrmLiteAuthRepository<AppUser, UserAuthDetails>(c.Resolve<IDbConnectionFactory>()) {
                    UseDistinctRoleTables = true
                });            
        }
}

I tried:

    public class ConfigureAuthRepository : IConfigureAppHost, IConfigureServices, IPreInitPlugin
    {
        public void Configure(IServiceCollection services)
        {
            MultiTenancyConfig cfg = HostContext.TryResolve<MultiTenancyConfig>();

            services.AddSingleton<IAuthRepository>(c =>
                new OrmLiteAuthRepositoryMultitenancy<AppUser, UserAuthDetails>(c.Resolve<IDbConnectionFactory>(), cfg.Connections)
                {
                    UseDistinctRoleTables = true
                });
        } 
}

But at this point AppHost does not exist or has not been initialized.

Configure(IServiceCollection) is run before anything else, the only thing you can access when registering dependencies is resolving other dependencies which you’d do from within the factory function, e.g:

services.AddSingleton<IAuthRepository>(c =>
    new OrmLiteAuthRepositoryMultitenancy<AppUser, UserAuthDetails>(
        c.Resolve<IDbConnectionFactory>(), 
        c.Resolve<MultiTenancyConfig>().Connections) {
            UseDistinctRoleTables = true
    });

Also the MultiTenancyConfig should also be registered in Configure(IServiceCollection) since it’s being referenced by dependencies registered in Configure(IServiceCollection), e.g:

public class ConfigureConfigServices : IConfigureServices
{
    public void Configure(IServiceCollection ioc) =>
        ioc.AddSingleton(c=>new MultiTenancyConfig(c.Resolve<IConfiguration>()));
}

Thanks. That work perfectly.

1 Like

In ConfigureAuthRepository Configure(IAppHost appHost)

        public void Configure(IAppHost appHost)
        {
            var authRepo = appHost.Resolve<IAuthRepository>();

            authRepo.InitSchema();
            
            CreateUser(authRepo, "admin@email.com", "Admin User", "p@55wOrd", roles: new[] {RoleNames.Admin});
        }

CreateUser()

Throws System.NotSupportedException: This operation can only be called within context of a Request

.InitSchema(); seems to initiate all the default tables for each tenant correctly

Is there a way to access the AuthRepository of each tenant at this level?

You can’t use the IAuthRepository APIs on Startup but you can cast to a OrmLiteAuthRepositoryMultitenancy and use its EachDb API to execute the same DB commands across all DBs, e.g. this is similar to the CreateUserAuth API:

var multi =(OrmLiteAuthRepositoryMultitenancy)appHost.Resolve<IAuthRepository>();
multi.EachDb(db => {
    newUser.PopulatePasswordHashes(password);
    newUser.CreatedDate = DateTime.UtcNow;
    newUser.ModifiedDate = newUser.CreatedDate;

    db.Save((TUserAuth) newUser);
});
1 Like