Closed dwt12777 closed 4 years ago
Hi @dwt12777
Good question!
It looks like you are very close, but the seeded default tenant needs a connection string. Then when you create the app db context in your code here it will use the connection string from the tenant:
// defaultTenant needs a connection string which AppDbContext uses.
using (var context = new AppDbContext(defaultTenant,
serviceProvider.GetRequiredService<DbContextOptions<AppDbContext>>()))
If that doesn't work, post your AppDbContext class and let me see how that is defined.
also I like this!
//to do: figure out how real men store passwords
Thanks @AndrewTriesToCode - the more I get into this the more I realize I have to learn, and storing passwords correctly should probably be on that list!
Anyway, regarding the problem at hand: I tried adding a connection string to the defaultTenant but I don't think that solved the problem. Here's what I've done:
I updated defaultTenant that gets generated in the SeedTenantStore method to this:
private static ReardenTenantInfo SeedTenantStore(IServiceProvider sp)
{
var scopeServices = sp.CreateScope().ServiceProvider;
var store = scopeServices.GetRequiredService<IMultiTenantStore<ReardenTenantInfo>>();
// Create a default tenant
var defaultTenant = new ReardenTenantInfo
{
Id = Guid.NewGuid().ToString(),
Identifier = Constants.Tenant.DefaultIdentifier,
Name = Constants.Tenant.DefaultName,
ConnectionString = Constants.AppConnectionString.Value // this points to a sqlite db in the Data folder
};
// If default tenant doesn't exist, add it
var existingTenant = store.TryGetByIdentifierAsync(defaultTenant.Identifier).Result;
if (existingTenant == null)
{
store.TryAddAsync(defaultTenant).Wait();
}
else
{
defaultTenant = existingTenant;
}
return defaultTenant;
}
And that seems to work fine: the default tenant gets added to the tenant store, and the connection string value is populated. In my AppDbContext though in the OnConfiguration
method you'll notice I wasn't actually reading in the connection string from the tenant as one of your samples showed, but rather was just pulling it again from the Constants
(which pulls from the config file). Anyway, that may have nothing to do with this, but just pointing it out:
AppDbContext.cs
using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using ReardenNews.Models;
namespace ReardenNews.Web.Data
{
public class AppDbContext : MultiTenantIdentityDbContext<IdentityUser, IdentityRole, string>
{
public DbSet<Issue> Issue { get; set; }
public DbSet<Newsletter> Newsletter { get; set; }
public DbSet<Article> Article { get; set; }
public DbSet<SiteOptions> SiteOptions { get; set; }
public AppDbContext(ReardenTenantInfo tenantInfo) : base(tenantInfo)
{
}
public AppDbContext(ReardenTenantInfo tenantInfo, DbContextOptions<AppDbContext> options)
: base(tenantInfo, options)
{
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
var conn = Constants.AppConnectionString.Value;
optionsBuilder.UseSqlite(conn);
base.OnConfiguring(optionsBuilder);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Models.Newsletter>().IsMultiTenant();
modelBuilder.Entity<Models.Issue>().IsMultiTenant();
modelBuilder.Entity<Models.Article>().IsMultiTenant();
modelBuilder.Entity<Models.SiteOptions>().IsMultiTenant();
}
}
}
Also, I originally had the class declared like:
public class AppDbContext : MultiTenantIdentityDbContext
But in studying your documentation I realized you'd set this up to allow users and roles to exist across tenants if I did this instead:
public class AppDbContext : MultiTenantIdentityDbContext<IdentityUser, IdentityRole, string>
Which seems awesome to me - the ability to have a single user account and standard set of roles, but applied differently per tenant. Love it - I thought I was going to have to deal with completely independent user accounts per tenant. Anyway, when when I made this change, it pushed the original problem downstream. In my original post I couldn't create users because the AspUsersNet table was expecting a TenantId. Well, by making the change and no longer treating Users (and Roles) as MultiTenant - my SeedClass was now able to create users and roles no problem. But where it breaks now is when it then tries to assign a user to a role. The AspNetUserRoles table IS multi-tenant (and should be). And this is now where I'm having the same problem... I can't seem to seed that table because it's expecting a TenantId that I can't give.
More specifically, this is now the line that throws the null reference error:
IR = await userManager.AddToRoleAsync(user, role);
And again, I think it's because the AspNetUserRoles table is expecting a TenantId. Hope this all makes sense!
Hello again. A few thoughts:
you'd set this up to allow users and roles to exist across tenants
Honestly I hadn't intended it that way -- the idea was that you could define your own IdentityUser class (to add more data) and then set it up manually to be multitenant on the OnModelCreating
method. What you described makes sense but I haven't tested it that way. A true many-to-many user / tenant use case will probably need a more complicated configuration I plan to write about this in the future.
I recommend using a different connection string for the store and for the actual app data. That is because multiple apps might use the same store--but its just a recommendation and not always applicable. Might not make sense in your case.
For your issue:
I think what is happening is UserManager is creating its own AppDbContext
instance internally using DI. This will Try to get a TenantInfo from DI. Internally when DI wants a TenantInfo it needs an IMultiTenantContextAccessor
to get it. You can manually set the tenant info for the IMultiTenantContextAccessor
that DI will retreive like this:
var accessor = serviceProvider.GetRequiredService<IMultiTenantContextAccessor>();
var multiTenantContext = new MultiTenantContext<MyTenantInfo>();
multiTenantContext.TenantInfo = defaultTenant
accessor.MultiTenantContext = multiTenantContext;
Put that in your code right after the service provider scope is created and everything that needs a tenant info will get the default one. This also means you can get the AppDbContext
from the serviceProvider if you would rather do that than "new" it into existence.
By the way this is how the middleware in a web app sets the current tenant. It uses DI to get a tenant resolver, resolves the tenant, then sets the IMultiTenantAccessor like I described above so that it is available to other parts of the app for that request. Look here if you are curious.
This is working great so far! Thank you so much for responding.
And I hear what you're saying about the many-to-many scenario not being the intended design. I'll tread carefully!
I am also trying to seed tenants and identity data. But the way I am thinking about is a bit different and I would like to know your feedback if you have some time :).
Since this is multi-tenant environment, I would like to have the db creation & seeding separated. So as of now I have individual model builders (IEntityTypeConfiguration
Add-Migration Initial -Context ApplicationDbContext -Args 'ACME' where ACME is the tenant to create the migration(Tenants come from appconfig). Update-Database command will just run the migration and create an empty database. I can't seed with Update-Database because I can't inject UserManager etc.
Now to seed data; the way I can think of doing it and avoid a null tenant is to wait for a say API call from a tenant (actual tenant is resolved at this point) and when creating DBContext go ahead and seed if no data. On the other hand I am uneasy about checking whether db is seeded or not everytime a dbcontext is getting created.
Do you think this is a correct approach? If not what would you suggest?
So I'm trying to build a SeedData class (relying heavily on the walkthrough provided here: https://docs.microsoft.com/en-us/aspnet/core/security/authorization/secure-data?view=aspnetcore-3.1).
A key difference of course between my project and the walkthrough is my project is using multi-tenancy Identity thanks to finbuckle. And that leads me to the problem I'm trying to figure out:
How can I seed multi-tenant identity objects (e.g. Users, Roles) from the middleware?
I'm not even sure if I'm asking the right question, but I ask it like that because in the above-referenced walkthrough, calls like:
var user = await userManager.FindByNameAsync(UserName);
or
await userManager.CreateAsync(user, defaultPassword);
work just fine. But in my project they're failing with null object reference errors. And as best I can tell, the null item it's expecting is a TenantId when it tries to find/create a user. And apparently no TenantId is being passed in with the new user I'm trying to create. I can't explicitly set a TenantId value because of the shadow property magic being worked by Finbuckle...
I'm still trying to understand "middleware" and how this all fits together, but as best I can tell, when I'm messing around in Program.cs or Startup.cs - I'm in the world of middleware and things like
HttpContext.GetMultiTenantContext...
aren't available to me yet.However... even though I don't have a HttpContext yet (I think), the first step in my SeedData process is to seed the TenantStore itself first and create a default tenant. And that works just fine. As you can see below, it returns a defaultTenant object (complete with ID), and I use that Tenant to instantiate an AppDbContext - hoping the subsequent Identity calls would then be "tenant-aware."
But so far, no luck. I can't figure out how to ask Identity "Hey, when you create this user, make sure it's associated with this tenantID! Please."
For reference, here are my current Program and Startup classes:
Program.cs
SeedData.cs