Finbuckle / Finbuckle.MultiTenant

Finbuckle.MultiTenant is an open-source multitenancy middleware library for .NET. It enables tenant resolution, per-tenant app behavior, and per-tenant data isolation.
https://www.finbuckle.com/multitenant
Apache License 2.0
1.26k stars 261 forks source link

Hostname strategy #82

Closed mcinnes01 closed 5 years ago

mcinnes01 commented 5 years ago

Hi

I am struggling to get the hostname strategy working. I'm using the EFCore InMemory store, which I populate with "TenantInfo" items. I've assumed the hostname should be set against the "Identifier" property so my setup looks like the following:

        public void ConfigureServices(IServiceCollection services)
        {
            // Register options
            services.AddOptions();

            services.AddMediatR(typeof(RetrieveTenants.Handler).Assembly);

            #region Tenant Db Configuration

            // Tenant database connection
            services.AddDbContext<ConfiguratorDbContext>(options =>
                options.UseSqlServer(Configuration["TenantDbConnection:ConnectionString"]));

            #endregion

            services.AddLogging();
            services.AddMvc()
                .SetCompatibilityVersion(CompatibilityVersion.Version_2_1)
                .AddFluentValidation(cfg =>
                {
                    cfg.RegisterValidatorsFromAssemblyContaining(typeof(AbstractValidator<>));
                });

            #region Multitenancy configuration

            services.AddMultiTenant()
                .WithEFCoreStore<StoreDbContext, AppTenantInfo>()
                .WithHostStrategy();

            #endregion

            #region Application Db Configuration

            // Register the db context, but do not specify a provider/connection string since
            // these vary by tenant.
            services.AddDbContext<WintrixDbContext>();

            #endregion
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
        {
            loggerFactory.AddConsole(Configuration.GetSection("Logging"));
            loggerFactory.AddDebug();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseDatabaseErrorPage();
            }
            else
            {
                app.UseHsts();
            }

            app.UseStaticFiles();
            app.UseMultiTenant();
            app.UseMvc();

            SetupStore(app.ApplicationServices);
        }

        private void SetupStore(IServiceProvider sp)
        {
            var scopeServices = sp.CreateScope().ServiceProvider;
            var store = scopeServices.GetRequiredService<IMultiTenantStore>();
            var mediator = scopeServices.GetRequiredService<IMediator>();

            var tenants = mediator
                .Send(new RetrieveTenants.Query
                {
                    Environment = Configuration["TenantDbConnection:Environment"]
                })
                .GetAwaiter()
                .GetResult()
                .Tenants;

            foreach (var tenant in tenants)
            {
                store.TryAddAsync(new TenantInfo(tenant.Id, tenant.Hostname, tenant.Name, tenant.ConnectionString, null)).Wait();
            }
        }

Currently I seem to get no tenantInfo when I call:

HttpContext.GetMultiTenantContext()?.TenantInfo;

My setup is meant to use our tenant database to get the tenant configuration data including connection string and hostnames. I then load this in to a TenantInfo objects with the hostname as the Identifier and insert this in to an EFCore store.

That part seems to work ok becuase I see all the tenants if I query the StoreDbContext directly.

I've added bindings in IIS for each host name and added entries to my host file.

Each tenant has a separate database which should then be resolved by the tenant connection string in the EFCoreStore, but as I said above, in my controller the tenantInfo is currently null?

Any help would be much appreciated

I guess one thing I thought about, was in your hostname sample you only replaced the subdomain, in my case the whole host name is different.

i.e.

www.something.com another.domain.net

Andy

AndrewTriesToCode commented 5 years ago

Hi Andy, thanks for the detailed write-up. By default the host strategy looks at the left-most subdomain in the host. I think in your case you want to look at the entire host right? Try this variation in your ConfigureServices:

...WithHostStrategy("__tenant__"); (note there are 2 underscores before and after 'tenant')

Fyi this is from a previous blog post about this:

WithHostStrategy now accepts a template string which defines how the strategy will find the tenant identifier. The pattern specifies the location for the tenant identifier using __tenant__ and can contain other valid domain characters. It can also use '?' and '*' characters to represent one or "zero or more" segments. For example:

  • __tenant__.* is the default if no pattern is provided and selects the first domain segment for the tenant identifier.
  • *.__tenant__.? selects the main domain as the tenant identifier and ignores any subdomains and the top level domain.
  • __tenant__.example.com will always use the subdomain for the tenant identifier, but only if there are no prior subdomains and the overall host ends with "example.com".
  • *.__tenant.__.?.? is similar to the above example except it will select the first subdomain even if others exist and doesn't require ".com".

Also you stated that you were using the InMemory but it looks like you are using the EFCoreStore which is different. I noticed you are using ConfiguratorDbContext in AddDbContext and StoreDbContext in WithEFCoreStore. I'm not sure what you are trying to do, but if the tenants are stored in the mediator and you only need them in memory when running the app you might be better off with using WithInMemoryStore for Finbuckle.Multitenant to avoid the added complication of Entity Framework Core. Let me know if you have any questions.

mcinnes01 commented 5 years ago

Hi

Thanks for the quick reply, I did a little debugging and got it working with a custom strategy as I was struggling getting around the regex manipulation in the HostStrategy but I'll try out your examples and see if I can avoid that.

    public class DomainStrategy : IMultiTenantStrategy
    {
        private const string regex = @"^(?<identifier>(([a-zA-Z]|[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9]))$";
        private readonly ILogger<DomainStrategy> logger;

        public DomainStrategy(ILogger<DomainStrategy> logger)
        {
            this.logger = logger;
        }

        public async Task<string> GetIdentifierAsync(object context)
        {
            if (!(context is HttpContext))
                throw new MultiTenantException(null,
                    new ArgumentException("\"context\" type must be of type HttpContext", nameof(context)));

            var host = (context as HttpContext).Request.Host;

            Utilities.TryLogInfo(logger, $"Host:  \"{host.Host ?? "<null>"}\"");

            if (host.HasValue == false)
                return null;

            string identifier = null;

            var match = Regex.Match(host.Host, regex,
                RegexOptions.ExplicitCapture | RegexOptions.IgnoreCase,
                TimeSpan.FromMilliseconds(100));

            if (match.Success)
            {
                identifier = match.Groups["identifier"].Value;
            }

            Utilities.TryLogInfo(logger, $"Found identifier:  \"{identifier ?? "<null>"}\"");

            return await Task.FromResult(identifier); // Prevent the compliler warning that no await exists.
        }
    }

Perhaps I didn't explain very well regarding the store....

So basically we have a database that has all the tenants configuration data in, however this isn't in the nicest format, so I've created a ConfiguratorDbContext and use the mediator to retrieve the tenants with an object derived from IEFCoreStoreTenantInfo.

I guess in an ideal world this database would be the EFCoreStore, but I couldn't get an EFCoreStore working with a Sql Database, so here's where my explanation went awry... I meant to say I used an "UseInMemoryDatabase" opposed to a "UseSqlServer" database, but just said in memory store.

What I've ended up doing is storing the IEFCoreStoreTenantInfo in this EFCore InMemoryDatabase store.

I've now got it working in terms of resolving the tenants dbcontexts or as per my code "WintrixDbContext".

Sadly this is a rebuild against an existing monster of a database, so the first connection is offensively slow per tenant as it builds the dbcontext.

I've been playing with using a DbContextFactory, firstly because it means I can separate my domain objects in to slices, removing any coupling against a huge DbContext and secondly because performance wise it only builds what I need. Here is the example I've been looking at: https://github.com/bytefish/ModularEfCore

I'm just playing to see if I can extend your MultiTenantDbContext to take in a custom DbContextOptions implementation, in order to provide the IEntityMap implementation in order to build up the dbcontext on the fly.

Something along the lines of:

    public class ModularDbContext : MultiTenantDbContext
    {
        private readonly ModularDbContextOptions options;

        public ModularDbContext(TenantInfo tenantInfo, ModularDbContextOptions options)
            : base(tenantInfo, options.Options)
        {
            this.options = options;
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(ConnectionString);
            base.OnConfiguring(optionsBuilder);
        }

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

            foreach (var mapping in options.Mappings)
            {
                mapping.Map(builder);
            }

            options.DbContextSeed?.Seed(builder);
        }
    }

I'll continue playing and let you know how I get along.

Many thanks

Andy

AndrewTriesToCode commented 5 years ago

Andy, very interesting stuff you are working on. I took a closer look at my code and unfortunately it has an implicit assumption that the identifier in the host will be a single segment, i.e. it will not contain any . characters. This obviously doesn't work in your situation. I think your custom strategy is a better idea for your needs.

mcinnes01 commented 5 years ago

Great thanks, I got it all working, I've managed to integrate finbuckle with the modular ef context approach.

    public class ModularDbContext : MultiTenantDbContext
    {
        private readonly ModularDbContextOptions options;

        public ModularDbContext(TenantInfo tenantInfo, ModularDbContextOptions options)
            : base(tenantInfo, options.Options)
        {
            this.options = options;
        }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            optionsBuilder.UseSqlServer(ConnectionString);
            base.OnConfiguring(optionsBuilder);
        }

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

            foreach (var mapping in options.Mappings)
            {
                mapping.Map(builder);
            }

            options.DbContextSeed?.Seed(builder);
        }
    }

And my options:

    public class ModularDbContextOptions
    {
        public readonly DbContextOptions<ModularDbContext> Options;
        public readonly IDbContextSeed DbContextSeed;
        public readonly IEnumerable<IEntityTypeMap> Mappings;

        public ModularDbContextOptions(DbContextOptions<ModularDbContext> options, IDbContextSeed dbContextSeed, IEnumerable<IEntityTypeMap> mappings)
        {
            DbContextSeed = dbContextSeed;
            Options = options;
            Mappings = mappings;
        }
    }

And the startup:

            // Register Database Entity Maps:
            services.AddSingleton<IEntityTypeMap, CarMap>();
            services.AddSingleton<IEntityTypeMap, DvlaLookupMap>();

            // Register the Seed:
            services.AddSingleton<IDbContextSeed, DefaultSeed>();

            // Finally register the DbContextOptions:
            services.AddTransient<ModularDbContextOptions>();

            // This Factory is used to create the DbContext from the custom DbContextOptions:
            services.AddTransient<IModularDbContextFactory, ModularDbContextFactory>();

            // Register the db context, but do not specify a provider/connection string since
            // these vary by tenant.
            services.AddDbContext<ModularDbContext.ModularDbContext>();