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.32k stars 267 forks source link

[Question] Example for API. #265

Closed ElChepos closed 4 years ago

ElChepos commented 4 years ago

Good job on your work ! Really nice project.

I have read all the docs and check the samples but can't find any doc on how to use the package with an API. On the API side, i have implanted the Context to store the Tenants Info with EF Core. Also I have a controller that returns the tenants’ info like your HTTPRemoteExample so the front end can call it.

The app consists of a DB for all the Tenants info and a database data per tenants. How can we set the correct database to get the data from ?

Do we need to register many DbContext for each tenants DB and based on a header that we pass when we call the API we get the correct db ? Maybe there is a better way ?

Thx for your time.

AndrewTriesToCode commented 4 years ago

hi @jptoros

Yeah I need to get a good API sample up. Let me see if I understand your app correctly:

Is an API that is using the EFCoreStore for the multitenant store. What strategy are you using to determine the tenant?

And then in the API controllers you make some database calls, and you want it so that it connects to a different database depending on the tenant.

Is that close?

ElChepos commented 4 years ago

Hey @AndrewTriesToCode,

you got it all right !

thank for the fast feedback. I will gladly help you with the creation of a sample if I can get it working.

For now what i have archived since yesterday night is

services.AddDbContext<AppContext>();
services.AddDbContext<TenantsContext>(options => options.UseSqlServer(_configuration["ConnectionStrings:TenantsContext"]));

The first Context is the data of the app. Since depending on each tenant the connection string will change i don't set it in the Startup. For the TenantsContext which is the EFCoreStore I can set the connection string directly there.

For the strategy services.AddMultiTenant().WithRouteStrategy().WithEFCoreStore().WithFallbackStrategy("demo");

With endpoints endpoints.MapControllerRoute("default", "{tenant}/{controller}/{action=Index}/{id?}");

So I had to add the tenant in all my controller routes. [Route("api/v{version:apiVersion}/{tenant}/[controller]")]

Finally in the AppContext public class AppContext: MultiTenantDbContext

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            try{
                optionsBuilder.UseSqlServer(ConnectionString);
            }catch(Exception e){
                optionsBuilder.UseSqlServer("hardcoded connection string");
            }

            base.OnConfiguring(optionsBuilder);
        }

So when I call the API with the tenant in parameters it working #1 with the optionsBuilder.UseSqlServer(ConnectionString); but if for example i open my API to display the swagger for example that does crash since I don't have a tenant yet that why I need to put the try catch. If you have better alternative i'm all in :)

Front End side

services.AddMultiTenant()
                    .WithRouteStrategy()
                    .WithRemoteAuthentication()
                    .WithHttpRemoteStore(tenantsUrl)
                    .WithFallbackStrategy("demo");

endpoints.MapControllerRoute("default", "{__tenant__}/{controller=Dashboard}/{action=Index}/{id?}");

Before each call to the API since i check the base address of the API and the current tenant and I add it to the URI.

 private async Task<string> EnsureTenantIdentifier(string url)
        {
            var tenantInfo = _httpContextAccessor.HttpContext.GetMultiTenantContext().TenantInfo;
            if (tenantInfo != null){
                return $"{tenantInfo.Identifier}/{url}";
            }
            return url;
        }

So far here is the problems that I have.

So far do you think the implementation make sense ? Is it a better way to do it ?

Thanks

AndrewTriesToCode commented 4 years ago

Hi, it looks like you got a lot working already. I'll try to address your questions, but in order to help I actually have to ask some more questions,

The login of the app (Auth0) with OpenIdConnect don't work with .WithRemoteAuthentication() WithRemoteAuthentication takes effect when Auth0 is posting back to your app after the user has logged in. It shouldn't have any effect on app start because you haven't redirected the user to Auth0 yet. Question: do you want to determine the tenant in your app then redirect to the openId connect server (where each tenant might have a different server), or do you want to first send them to Auth0, have them sign in, then once back at your app determine the tenant based on the user that signed in? The library is more designed for the first case, but can handle the second case too.

The problem mentioned in the API for the ConnectionString empty at the start of the application. Your try/catch isn't bad. An alternative is something like:

optionsBuilder.UseSqlServer(ConnectionString ?? "hard_coded_or_other_string");

If you have the fallback strategy in place though there should always be a tenant. Do you have a connection string set for the "demo" tenant? That might be the easiest thing to do. By the way multiple db per tenant can be tricky as you are seeing. Migration handling can be tough too.

How to block user that are not in that tenant to access other data tenant. For this I recommend you use per-tenant options on CookieAuthenticationOptions to set the cookie name to be unique per tenant. Then one if you use normal ASP.NET authentication to protect pages they can only access if they have logged in for that particular tenant.

WithPerTenantOptions<CookieAuthenticationOptions>((options, tenantInfo) =>
{
options.Cookie.Name = tenantInfo.Id + "-cookie";
options.LoginPath = "/" + tenantInfo.Identifier + "/Home/Login";         
});

How to put a access token to access the API with the .WithHttpRemoteStore(tenantsUrl) If I understand correctly your front end is using this store. One option is to just use the same EFCoreStore store and connection that the backend uses. Let the database connection handle the security. If you do want to use HttpRemoteStore you have to use an overload when setting it up:

services.AddMultiTenant()
.WithHttpRemoteStore("https://remoteserver.com/", httpClientBuilder =>
{
httpClientBuilder.ConfigureHttpClient( client =>
{
// Set your client here
client.DefaultRequestHeaders.Authorization = new AuthorizationHeaderValue(...);
});
});

Alternatively there is another overload of WithHttpRemoteStore that lets you use your own custom delegating handler for even more control per request -- but that is more complicated. the .Net docs explain delegating handlers pretty well.

Let me know how it goes!

ElChepos commented 4 years ago

Hi, it looks like you got a lot working already. I'll try to address your questions, but in order to help I actually have to ask some more questions,

The login of the app (Auth0) with OpenIdConnect don't work with .WithRemoteAuthentication() WithRemoteAuthentication takes effect when Auth0 is posting back to your app after the user has logged in. It shouldn't have any effect on app start because you haven't redirected the user to Auth0 yet. Question: do you want to determine the tenant in your app then redirect to the openId connect server (where each tenant might have a different server), or do you want to first send them to Auth0, have them sign in, then once back at your app determine the tenant based on the user that signed in? The library is more designed for the first case, but can handle the second case too.

Second case, I want everybody to go by the Auth0 Login page and with the token returned from Auth0 I will know which Tenant I should redirect to.

The problem mentioned in the API for the ConnectionString empty at the start of the application. Your try/catch isn't bad. An alternative is something like:

optionsBuilder.UseSqlServer(ConnectionString ?? "hard_coded_or_other_string");

If you have the fallback strategy in place though there should always be a tenant. Do you have a connection string set for the "demo" tenant? That might be the easiest thing to do. By the way multiple db per tenant can be tricky as you are seeing. Migration handling can be tough too.

Strange here, I have tried your suggestion, but it still crash here. I do have the .WithFallbackStrategy("demo"); and the connectionstring of demo is set. But when the application run and arrive in the OnConfiguring of the AppContext I have a null referenceException Exception has occurred: CLR/System.NullReferenceException An exception of type 'System.NullReferenceException' occurred in Finbuckle.MultiTenant.EntityFrameworkCore.dll but was not handled in user code: 'Object reference not set to an instance of an object.' at Finbuckle.MultiTenant.MultiTenantDbContext.get_ConnectionString() at DemoApi.Repositories.AppContext.OnConfiguring(DbContextOptionsBuilder optionsBuilder). It looks like we came in there without knowing the current tenant for the first time but after that it work #1.

How to block user that are not in that tenant to access other data tenant. For this I recommend you use per-tenant options on CookieAuthenticationOptions to set the cookie name to be unique per tenant. Then one if you use normal ASP.NET authentication to protect pages they can only access if they have logged in for that particular tenant.

WithPerTenantOptions<CookieAuthenticationOptions>((options, tenantInfo) =>
        {
            options.Cookie.Name = tenantInfo.Id + "-cookie";
            options.LoginPath = "/" + tenantInfo.Identifier + "/Home/Login";         
        });

Thx I see that force a token refresh. Since it the same Auth0 domain under it it just relog the customer directly in with the different tenant. I will have a look on the Auth0 code to see what i need to change here.

How to put a access token to access the API with the .WithHttpRemoteStore(tenantsUrl) If I understand correctly your front end is using this store. One option is to just use the same EFCoreStore store and connection that the backend uses. Let the database connection handle the security. If you do want to use HttpRemoteStore you have to use an overload when setting it up:

services.AddMultiTenant()
        .WithHttpRemoteStore("https://remoteserver.com/", httpClientBuilder =>
        {
            httpClientBuilder.ConfigureHttpClient( client =>
            {
                // Set your client here
               client.DefaultRequestHeaders.Authorization = new AuthorizationHeaderValue(...);
            });
        });

Alternatively there is another overload of WithHttpRemoteStore that lets you use your own custom delegating handler for even more control per request -- but that is more complicated. the .Net docs explain delegating handlers pretty well.

Let me know how it goes!

Thank why I did not think about it :) When I was testing the URl of the remote store i gave a wrong URL and the error show up have a small type in it for the word "parameter". Not important but easy fix an in future release. ArgumentException: Paramter 'endpointTemplate' is not a an http or https uri. (Parameter 'endpointTemplate')

PS : I'm using you the version 5.0.4 will do the swtich when the news 6 is ready.

ElChepos commented 4 years ago

Hey @AndrewTriesToCode one more questions that i'm not finding answer.

I'm trying to load AppConfiguration depending on the tenant in my Program.cs file. I have 3 App Config with different features enables in them. I have added to TenantInfo a new field named "AppTier" which can be "Gold, Silver'Bronze' depending on that value, I want to change the AppConfig that is loaded.

How should I proceed ? Thx

public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureLogging((hostingContext, logging) =>
                    {
                        logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"));
                        if (hostingContext.HostingEnvironment.IsDevelopment())
                        {
                            logging.AddDebug();
                        }
                    }
                )
                .ConfigureAppConfiguration((hostingContext, config) =>
                {
                    config.AddAzureAppConfiguration(options =>
                    {
//Based on the currenttenant AppTier value
                        options.Connect(settings["ConnectionStrings:AppConfig"])
                               .UseFeatureFlags();
                    });
AndrewTriesToCode commented 4 years ago

Hello!

Auth0 If you are sending everyone to Auth0 and using a token value to redirect to a tenant page then no need for WithRemoteAuthentication. Just stick to the strategy that matches your redirect (host, base path, route, etc...) I need to update the docs on when to use that.

EF Core error Is it possible the app is trying to use the db context before the tenant is set? Like maybe migrations or seeding data or something? Can you set logging to debug level and post the output for the entire request?

Auth0 again In your OpenIdConnectOptions you can set options.Prompt = "login" or something like that to force a new signing each time they are redirected to Auth0. Might be something you only need for dev and not production unless you expect someone using the same computer to sign in under different tenants back-to-back.

Azure App configuration The library is designed mainly for per tenant options instead of per-tenant configuration. Since configuration is usually loaded at app startup (when there is no tenant) and not in the middle of a request (when there is a tenant). I tend to think of configuration as more tied to the host and options for the app, but I know that is oversimplifying it. Also, I'm not too familiar with AzureApp configuration. You are the very first person to try this so if you find a good solution let me know :)

I will keep thinking about it and let you know if I can think of a good way to make it work.

AndrewTriesToCode commented 4 years ago

I think this will be something useful: feature filters

I can envision a feature filter that checks the current tenant info for a tier property like gold, silver, bronze. With v6 of this library you can define your own TenantInfo class so you could easily add a Tier property. In V5 and earlier you could store that in the tenantInfo.Items collection. Would probably need a different filter per tier level where each filter just checks to see if the tenant has the tier level or higher and return true if so. Or maybe just a single filter that takes a parameter for the minimum tier required.

So then the next step would be figuring out how to write and plug in our own feature filter class... IFeatureFilter looks pretty easy to implement.

ElChepos commented 4 years ago

@AndrewTriesToCode work #1 for the feature filters good find !

Gonna try to put up a sample soon for others peoples.

Best,

ElChepos commented 4 years ago

@AndrewTriesToCode is there a way to get all the system Tenants with the store ?

AndrewTriesToCode commented 4 years ago

There isn’t a single universal way... it depends on each store. What type are you using? Maybe it would be smart to add a way to do so.

ElChepos commented 4 years ago

I use EFCore. I know i can query the DB. Was just asking if for we would have a extension for it within the store. Something like await _store.TryGetAllAsync();

`[HttpGet("{identifier}")] public async Task<ActionResult> Get(string identifier) { _logger.LogInformation("Tenants endpoint called with identifier \"{identifier}\".", identifier);

        var tenantInfo = await _store.TryGetByIdentifierAsync(identifier);
        if(tenantInfo != null)
        {
            _logger.LogInformation("Tenant \"{name}\" found for identifier \"{identifier}\".", tenantInfo.Name, identifier);
            return tenantInfo;
        }

        _logger.LogWarning("No tenant found with identifier \"{identifier}\".", identifier);
        return NotFound();
    }

[HttpGet("{identifier}")] public async Task<ActionResult<List> GetAll() { _logger.LogInformation("Tenants endpoint called with identifier \"{identifier}\".", identifier);

        var tenantInfo = await _store.TryGetAllAsync();
        if(tenantInfo != null)
        {
            _logger.LogInformation("Tenant \"{name}\" found for identifier \"{identifier}\".", tenantInfo.Name, identifier);
            return tenantInfo;
        }

        _logger.LogWarning("No tenant found with identifier \"{identifier}\".", identifier);
        return NotFound();
    }``
AndrewTriesToCode commented 4 years ago

Yeah that would be a good way to go. Also I think maybe a way to pull all the tenants across all the stores.

ElChepos commented 4 years ago

@AndrewTriesToCode is they a special way to redirect a user to xyz if not tenant is found ?

Example my app is web.testsaas.com if I user only enter that url can I redirect him to a specific url ? Otherwise when they enter web.testsaas.com/tenantxyz it should work as usual.

Thx

stale[bot] commented 4 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.