Closed pkavan closed 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.
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.
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.
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?
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?
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);
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.
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();
}
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;
}
@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.
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.
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?