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

How can I seed multi-tenant identity objects (e.g. Users, Roles) from the middleware? #312

Closed dwt12777 closed 4 years ago

dwt12777 commented 4 years ago

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

using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using ReardenNews.Web.Data;
using System;

namespace ReardenNews.Web
{
    public class Program
    {

        public static void Main(string[] args)
        {

            var host = CreateHostBuilder(args).Build();

            var optionsBuilder = new DbContextOptionsBuilder<EFCoreStoreDbContext>();
            optionsBuilder.UseSqlite(Constants.AppConnectionString.Value);
            using (var db = new EFCoreStoreDbContext(optionsBuilder.Options))
            {
                db.Database.Migrate();
            }

            using (var scope = host.Services.CreateScope())
            {
                var services = scope.ServiceProvider;

                try
                {
                    var context = services.GetRequiredService<AppDbContext>();
                    context.Database.Migrate();

                    //to do: figure out how real men store passwords
                    var defaultPassword = Constants.DefaultPassword;

                    SeedData.Initialize(services, defaultPassword).Wait();
                }
                catch (Exception ex)
                {
                    var logger = services.GetRequiredService<ILogger<Program>>();
                    logger.LogError(ex, "An error occurred seeding the DB.");
                }
            }

            host.Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }
}

SeedData.cs

using Finbuckle.MultiTenant;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using ReardenNews.Models;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace ReardenNews.Web.Data
{
    public static class SeedData
    {
        public static async Task Initialize(IServiceProvider serviceProvider, string defaultPassword)
        {
            var defaultTenant = SeedTenantStore(serviceProvider);

            using (var context = new AppDbContext(defaultTenant,
                serviceProvider.GetRequiredService<DbContextOptions<AppDbContext>>()))
            {
                // Add developer account
                var developerId = await EnsureUser(serviceProvider, defaultPassword, Constants.Roles.DefaultDeveloperUserName);
                await EnsureRole(serviceProvider, developerId, Constants.Roles.DevelopersRole);

                // Add admin account
                var adminId = await EnsureUser(serviceProvider, defaultPassword, Constants.Roles.DefaultAdministratorUserName);
                await EnsureRole(serviceProvider, adminId, Constants.Roles.AdministratorsRole);

                SeedDB(context, developerId, adminId);
            }
        }

        private static async Task<string> EnsureUser(IServiceProvider serviceProvider, string defaultPassword, string UserName)
        {
            var userManager = serviceProvider.GetService<UserManager<IdentityUser>>();

            var user = await userManager.FindByNameAsync(UserName);

            if (user == null)
            {
                user = new IdentityUser
                {
                    UserName = UserName,
                    EmailConfirmed = true
                };
                await userManager.CreateAsync(user, defaultPassword);
            }

            if (user == null)
            {
                throw new Exception("The password is probably not strong enough!");
            }

            return user.Id;
        }

        private static async Task<IdentityResult> EnsureRole(IServiceProvider serviceProvider,
                                                              string uid, string role)
        {
            IdentityResult IR = null;
            var roleManager = serviceProvider.GetService<RoleManager<IdentityRole>>();

            if (roleManager == null)
            {
                throw new Exception("roleManager null");
            }

            if (!await roleManager.RoleExistsAsync(role))
            {
                IR = await roleManager.CreateAsync(new IdentityRole(role));
            }

            var userManager = serviceProvider.GetService<UserManager<IdentityUser>>();

            var user = await userManager.FindByIdAsync(uid);

            if (user == null)
            {
                throw new Exception("The testUserPw password was probably not strong enough!");
            }

            IR = await userManager.AddToRoleAsync(user, role);

            return IR;
        }

        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,
            };

            // 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;
        }

        public static void SeedDB(AppDbContext context, string developerId, string adminID)
        {
            if (context.SiteOptions.Any())
            {
                return;   // DB has been seeded
            }

            context.SiteOptions.AddRange(
                new SiteOptions
                {
                    SiteName = Constants.Site.Name,
                    CopyrightName = Constants.Site.Copyright
                });

            context.SaveChanges();
        }
    }
}
AndrewTriesToCode commented 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

dwt12777 commented 4 years ago

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!

AndrewTriesToCode commented 4 years ago

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.

dwt12777 commented 4 years ago

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!

mkgn commented 3 years ago

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) and a IDesignTimeDbContextFactory setup where I will pass a command like;

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?