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.3k stars 265 forks source link

read/write tenant info #175

Closed pkavan closed 4 years ago

pkavan commented 4 years ago

How would I go about having a set of tenant specific values (e.g. colors, images, text) that could be changed by the tenant -- like an admin for the tenant?

markgould commented 4 years ago

Do you use Entity Framework? There is a good guide here on per-tenant data - https://www.finbuckle.com/MultiTenant/Docs/EFCore

You'd restrict the Admin page to a specific role/claim/whatever and then implement just as you would anything else.

pkavan commented 4 years ago

I looked at that, but I use IdentityDbContext() and couldn't reconcile it with using MultiTenantDbContext

Do you use Entity Framework? There is a good guide here on per-tenant data - https://www.finbuckle.com/MultiTenant/Docs/EFCore

You'd restrict the Admin page to a specific role/claim/whatever and then implement just as you would anything else.

AndrewTriesToCode commented 4 years ago

Hi @pkavan and thanks @markgould

I would suggest storing this type of thing in the multitenant store. You could use the Items collection in the TenantInfo to store various setting and such. What type of store are you using?

If you are using the EFCoreStore then Mark's approach makes a lot of sense. You would add the settings as fields on the entity class for the tenant (which must implemenet IEFCoreStoreTenantInfo). So in the EFCoreStore example you could add the fieds here: https://github.com/Finbuckle/Finbuckle.MultiTenant/blob/master/samples/EFCoreStoreSample/Data/AppTenantInfo.cs

Of course you would still need to build a UI that lets users enter and save the settings and a way to retrieve them. The EFCoreStore doesn't populate the Items collection so you would have to modify the store to do that how you want it.

If you are using a different type of store the same idea would apply but the specifics of how you add new fields will differ. The InMemory store is probably not a good one to use for this use case because it would be difficult to save any changes back to the configuration files.

pkavan commented 4 years ago

I am using the in memory store. If I understand it correctly, I agree that it would make more sense to use the EFCoreStore. Does the EFCoreStore create a database for the tenant info? And then TenantInfo would be a table and each tenant would have an entry?

I have said before that the middleware adjustments are my weak point, so I am struggling to see how this all works.

This is what my config in startup looks like now:

public void ConfigureServices(IServiceCollection services)
{
    // Add multitenancy
    services.AddMultiTenant().
        WithInMemoryStore(Configuration.GetSection("AnyCoreAg:MultiTenant:InMemoryStore")).
        WithHostStrategy();

    services.AddDbContext<ApplicationDbContext>();

    services.AddIdentity<ApplicationUser, IdentityRole>(options =>
    {
        //Password Options
        options.Password.RequiredLength = 6;
        options.Password.RequireNonAlphanumeric = false;
        options.Password.RequireLowercase = false;
        options.Password.RequireUppercase = false;
        options.Password.RequireDigit = false;

        // Lockout Options
        options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(3);
        options.Lockout.MaxFailedAccessAttempts = 7;
        options.Lockout.AllowedForNewUsers = true;
    })
        .AddEntityFrameworkStores<ApplicationDbContext>()
        .AddDefaultTokenProviders();

    // Add application services.
    services.AddTransient<IEmailSender, EmailSender>();
    services.AddMvc();
}

and

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseBrowserLink();
        app.UseDeveloperExceptionPage();
        app.UseDatabaseErrorPage();
    }
    else
    {
        app.UseExceptionHandler("/Home/Error");
    }

    app.UseMultiTenant();
    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.UseAuthentication();
    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
    });

}

with my ApplicationDbContext like:

public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
    private TenantInfo _tenantInfo;

    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, TenantInfo tenantInfo)
        : base(options)
    {
        _tenantInfo = tenantInfo;
    }

    #region Tables
        //All my app specific tables here
    #endregion Tables

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
                // We do this check for when we are adding migrations to the dev DB
        if (_tenantInfo == null)
        {
            optionsBuilder.UseSqlServer("Server=(localdb)\\MSSQLLocalDB;Database=AppCore.Beta;Trusted_Connection=True;MultipleActiveResultSets=true");
        }
        else
        {
            optionsBuilder.UseSqlServer(_tenantInfo.ConnectionString);
        }

        base.OnConfiguring(optionsBuilder);
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        // 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);
    }
}

I assume I am going to need two databases. My current ApplicationDbContext above and a second one based on EFCoreStoreDbContext<AppTenantInfo> since my App Context is one/per tenant and the EFCoreStore should be one for all of the tenants. I also assume the EFCoreStoreDb has to be created first, since I will need access to the TenantInfo to create my Application DB context.

Does that all seem correct?

pkavan commented 4 years ago

I think I have it now....with a few things left to figure out.

I went ahead and built another context just for the tenant info. I added the DB and created an entry for my dev instance. I removed the memory store from the AppSettings to make sure I was grabbing from the database. That all seems to work well.

Where I am stuck now is that I had a couple of 'Items' that I added to my AppTenantInfo class. They were 'Items' there, but I added them thus:

public class AppTenantInfo : IEFCoreStoreTenantInfo
{
    public string Id { get; set; }
    public string Identifier { get; set; }
    public string Name { get; set; }
    public string ConnectionString { get; set; }
    public string PrimaryColor { get; set; }
    public bool HasSubscription { get; set; }
}

They show up in the database, but they don't seem to come over when I try to call tenantInfo into a form. Before I had

<nav class="navbar fixed-top navbar-light navbar-expand-sm" style="background-color:@tenantInfo.Items["PrimaryColor"]">

Now, it doesn't seem to have entries for Primary Color.

Am I missing something?

pkavan commented 4 years ago

Figured out a work around. Basically, I have the tenantInfo, which contains the Id, so then I just call the table entry into the class I made that contains the extra fields..

TenantInfo tenantInfo = Context.GetMultiTenantContext().TenantInfo;
var tenantOptions = (AppTenantInfo)tenantContext.TenantInfo.Find(tenantInfo.Id);
AndrewTriesToCode commented 4 years ago

Hi @pkavan glad you found a workaround. I'll try to answer some of your questions:

Does the EFCoreStore create a database for the tenant info? And then TenantInfo would be a table and each tenant would have an entry?

Yes! Well if the database already exists it won't create it, but you have the right idea.

I assume I am going to need two databases.

Not necessarily. You can add an entity to your app or Identity dbcontext and use the same dbcontext. It really depends on what you prefer and what works best for you.

since my App Context is one/per tenant and the EFCoreStore should be one for all of the tenants.

I see what you mean here. I can't see your ApplicationUser but I'm assuming it derives from MultiTenantIdentityUser or has the [MultiTenant] attribute. That being said, the dbcontext itself isn't one per tenant and is really one per request under the hood. You could add an entity for your tenant info onto the same context and it shouldn't be a problem.

I don't like to mix my own stuff with the Identity context thought because it is already pretty complicated and MS might change it in the future. I like your approach.

AndrewTriesToCode commented 4 years ago

They show up in the database, but they don't seem to come over when I try to call tenantInfo into a form. ... am I missing something

Yeah you have run into a limitation of the EFCoreStore if you look at the source below for TryGetAsync which populates the tenantinfo you would normally use you can see it puts in NULL from items because it has no idea what extra things were added to the entity. Maybe I can make this smarter in a future release. I think your workaround is perfect.

public async Task<TenantInfo> TryGetAsync(string id)
{
    return await dbContext.Set<TTenantInfo>()
                    .Where(ti => ti.Id == id)
                    .Select(ti => new TenantInfo(ti.Id, ti.Identifier, ti.Name, ti.ConnectionString, null))
                    .SingleOrDefaultAsync();
}
MesfinMo commented 4 years ago

I run into a similar issue as well; not sure if it's the best approach but here's how I made it work for me.

I created my implementation of IMultiTenantStore and modified the TryGetAsync and TryGetByIdentifierAsync methods to retrieve tenant information from the store and convert the extra items into Dictionary<string, object> as the TenantInfo Items property expects it.

      var tenant =  await dbContext.Set<TTenantInfo>()
                        .Where(ti => ti.Id == id)
                        .SingleOrDefaultAsync();
        if(tenant != null)
        {            
            var tenantItems = tenant.Items.Select(c => new
            {
                c.ItemName,
                c.Item 
            })
            .ToDictionary(pair => pair.ItemName, pair => (object)pair.Item);

            var tenantInfo =  new TenantInfo(
                tenant.Id, 
                tenant.Identifier, 
                tenant.Name, 
                tenant.ConnectionString,
                tenantItems
            );
            return tenantInfo;
        } 
AndrewTriesToCode commented 4 years ago

@MesfinMo Thanks for your response! I've been thinking about this as well (mainly for an http/REST store) and I think a similar approach could work for EFCoreStore or really any store. I hope to have something added to the library for this in a future release.

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.