Skip to content

Repository files navigation

Build status

MultiTenancyServer

MultiTenancyServer aims to be a lightweight package for adding multi-tenancy support to any codebase easily. Its design is heavily influenced from ASP.NET Core Identity. You can add multi-tenancy support to your model without adding any tenant key properties to any classes or entities. Using ASP.NET Core, the current tenant can be retrieved by a custom domain name, sub-domain, partial hostname, HTTP request header, child or partial URL path, query string parameter, authenticated user claim, or a custom request parser implementation. Using Entity Framework Core, tenant keys are added as shadow properties (or optionally concrete properties) and enforced through global query filters, all configurable options can be set from defaults or overidden per entity. The below example highlights how to use MultiTenancyServer with ASP.NET Core Identity and IdentityServer4 together. You can find many full working samples integrated with IdentityServer4, ASP.NET Core Identity (using different key types such as String and Int64), and Entity Framework Core in the samples repo.

Define Model

Define your own tenant model, or inherit from TenancyTenant, or just use TenancyTenant as is. In this example we will inherit from TenancyTenant and add a display name.

publicclassTenant:TenancyTenant{// Custom property for display name of tenant.publicstringName{get;set;}}

Register Services

Example of adding multi-tenancy support to ASP.NET Core.

publicvoidConfigureServices(IServiceCollectionservices){varconnectionString=Configuration.GetConnectionString("DefaultConnection");varmigrationsAssembly=typeof(AppDbContext).GetTypeInfo().Assembly.GetName().Name;services.AddDbContext<AppDbContext>(options =>{options.UseSqlServer(connectionString, sql =>sql.MigrationsAssembly(migrationsAssembly));});// Add Multi-Tenancy Server defining TTenant<TKey> as type Tenant with an ID (key) of type string.services.AddMultiTenancy<Tenant,string>()// Add one or more IRequestParser (MultiTenancyServer.AspNetCore)..AddRequestParsers(parsers =>{// Parsers are processed in the order they are added,// typically 1 or 2 parsers should be all you need.parsers// www.tenant1.com.AddDomainParser()// tenant1.tenants.multitenancyserver.io.AddSubdomainParser(".tenants.multitenancyserver.io")// from partial hostname.AddHostnameParser("^(regular_expression)$")// HTTP header X-TENANT = tenant1.AddHeaderParser("X-TENANT")// /tenants/tenant1.AddChildPathParser("/tenants/")// from partial path.AddPathParser("^(regular_expression)$")// ?tenant=tenant1.AddQueryParser("tenant")// Claim from authenticated user principal..AddClaimParser("http://schemas.microsoft.com/identity/claims/tenantid")// Add custom request parser with lambda..AddCustomParser(httpContext =>"tenant1");// Add custom request parser implementation..AddMyCustomParser();})// Use in memory tenant store for development (MultiTenancyServer.Stores).AddInMemoryStore(newTenant[]{newTenant(){Id="TENANT_1",CanonicalName="Tenant1",NormalizedCanonicalName="TENANT1"}})// Use EF Core store for production (MultiTenancyServer.EntityFrameworkCore)..AddEntityFrameworkStore<AppDbContext,Tenant,string>()// Use custom store..AddMyCustomStore();// Add ASP.NET Core Identityservices.AddIdentity<User,Role>().AddEntityFrameworkStores<AppDbContext>().AddDefaultTokenProviders();// Add Identity Server 4varbuilder=services.AddIdentityServer().AddAspNetIdentity<User>()// Add the config data from DB (clients, resources).AddConfigurationStore<AppDbContext>(options =>{options.ConfigureDbContext= b =>b.UseSqlServer(connectionString,
sql =>sql.MigrationsAssembly(migrationsAssemblyName));})// Add the operational data from DB (codes, tokens, consents).AddOperationalStore<AppDbContext>(options =>{options.ConfigureDbContext= b =>b.UseSqlServer(connectionString,
sql =>sql.MigrationsAssembly(migrationsAssemblyName));});if(Environment.IsDevelopment()){builder.AddDeveloperSigningCredential();}else{thrownewException("Key not configured.");}}

Add Middleware

Example of configuring application with multi-tenancy support for ASP.NET Core MVC and IdentityServer4.

publicvoidConfigure(IApplicationBuilderapp){// other code removed for brevityapp.UseStaticFiles();app.UseMultiTenancy<Tenant>();app.UseIdentityServer();app.UseAuthentication();app.UseMvcWithDefaultRoute();}

Configure Entities

Example of DbContext with multi-tenancy support for ASP.NET Core Identity and IdentityServer4.

publicclassAppDbContext:// ASP.NET Core Identity EF CoreIdentityDbContext<User,Role,string,UserClaim,UserRole,UserLogin,RoleClaim,UserToken>,// IdentityServer4 EF CoreIConfigurationDbContext,IPersistedGrantDbContext,// MultiTenancyServer EF CoreITenantDbContext<Tenant,string>{privatestaticobject_tenancyModelState;privatereadonlyITenancyContext<Tenant>_tenancyContext;publicAppDbContext(DbContextOptions<AppDbContext>options,ITenancyContext<Tenant>tenancyContext):base(options){// The request scoped tenancy context.// Should not access the tenancyContext.Tenant property in the constructor yet,// as the request pipeline has not finished running yet and it will likely be null._tenancyContext=tenancyContext;}// IdentityServer4 implementation.publicDbSet<Client>Clients{get;set;}publicDbSet<IdentityResource>IdentityResources{get;set;}publicDbSet<ApiResource>ApiResources{get;set;}publicDbSet<PersistedGrant>PersistedGrants{get;set;}// MultiTenancyServer implementation.publicDbSet<Tenant>Tenants{get;set;}protectedoverridevoidOnModelCreating(ModelBuilderbuilder){base.OnModelCreating(builder);// IdentityServer4 configuration.varconfigurationStoreOptions=newConfigurationStoreOptions();builder.ConfigureClientContext(configurationStoreOptions);builder.ConfigureResourcesContext(configurationStoreOptions);varoperationalStoreOptions=newOperationalStoreOptions();builder.ConfigurePersistedGrantContext(operationalStoreOptions);// MultiTenancyServer configuration.vartenantStoreOptions=newTenantStoreOptions();builder.ConfigureTenantContext<Tenant,string>(tenantStoreOptions);// Add multi-tenancy support to model.vartenantReferenceOptions=newTenantReferenceOptions();builder.HasTenancy<string>(tenantReferenceOptions,out_tenancyModelState);// Configure custom properties on Tenant (MultiTenancyServer).builder.Entity<Tenant>(b =>{b.Property(t =>t.Name).HasMaxLength(256);});// Configure properties on User (ASP.NET Core Identity).builder.Entity<User>(b =>{// Add multi-tenancy support to entity.b.HasTenancy(()=>_tenantId,_tenancyModelState,hasIndex:false);// Remove unique index on NormalizedUserName.b.HasIndex(u =>u.NormalizedUserName).HasName("UserNameIndex").IsUnique(false);// Add unique index on TenantId and NormalizedUserName.b.HasIndex(tenantReferenceOptions.ReferenceName,nameof(User.NormalizedUserName)).HasName("TenantUserNameIndex").IsUnique();});// Configure properties on Role (ASP.NET Core Identity).builder.Entity<Role>(b =>{// Add multi-tenancy support to entity.b.HasTenancy(()=>_tenancyContext.Tenant.Id,_tenancyModelState,hasIndex:false);// Remove unique index on NormalizedName.b.HasIndex(r =>r.NormalizedName).HasName("RoleNameIndex").IsUnique(false);// Add unique index on TenantId and NormalizedName.b.HasIndex(tenantReferenceOptions.ReferenceName,nameof(Role.NormalizedName)).HasName("TenantRoleNameIndex").IsUnique();});// Configure properties on Client (IdentityServer4).builder.Entity<Client>(b =>{// Add multi-tenancy support to entity.b.HasTenancy(()=>_tenancyContext.Tenant.Id,_tenancyModelState,hasIndex:false);// Remove unique index on ClientId.b.HasIndex(c =>c.ClientId).IsUnique(false);// Add unique index on TenantId and ClientId.b.HasIndex(tenantReferenceOptions.ReferenceName,nameof(Client.ClientId)).IsUnique();});// Configure properties on IdentityResource (IdentityServer4).builder.Entity<IdentityResource>(b =>{// Add multi-tenancy support to entity.b.HasTenancy(()=>_tenancyContext.Tenant.Id,_tenancyModelState,hasIndex:false);// Remove unique index on Name.b.HasIndex(r =>r.Name).IsUnique(false);// Add unique index on TenantId and Name.b.HasIndex(tenantReferenceOptions.ReferenceName,nameof(IdentityResource.Name)).IsUnique();});// Configure properties on ApiResource (IdentityServer4).builder.Entity<ApiResource>(b =>{// Add multi-tenancy support to entity.b.HasTenancy(()=>_tenancyContext.Tenant.Id,_tenancyModelState,hasIndex:false);// Remove unique index on Name.b.HasIndex(r =>r.Name).IsUnique(false);// Add unique index on TenantId and Name.b.HasIndex(tenantReferenceOptions.ReferenceName,nameof(ApiResource.Name)).IsUnique();});// Configure properties on ApiScope (IdentityServer4).builder.Entity<ApiScope>(b =>{// Add multi-tenancy support to entity.b.HasTenancy(()=>_tenancyContext.Tenant.Id,_tenancyModelState,hasIndex:false);// Remove unique index on Name.b.HasIndex(s =>s.Name).IsUnique(false);// Add unique index on TenantId and Name.b.HasIndex(tenantReferenceOptions.ReferenceName,nameof(ApiScope.Name)).IsUnique();});// Configure properties on PersistedGrant (IdentityServer4).builder.Entity<PersistedGrant>(b =>{// Add multi-tenancy support to entity.b.HasTenancy(()=>_tenancyContext.Tenant.Id,_tenancyModelState);});}publicoverrideintSaveChanges(boolacceptAllChangesOnSuccess){// Ensure multi-tenancy for all tenantable entities.this.EnsureTenancy(_tenancyContext?.Tenant?.Id,_tenancyModelState,_logger);returnbase.SaveChanges(acceptAllChangesOnSuccess);}publicoverrideTask<int>SaveChangesAsync(boolacceptAllChangesOnSuccess,CancellationTokencancellationToken=default){// Ensure multi-tenancy for all tenantable entities.this.EnsureTenancy(_tenancyContext?.Tenant?.Id,_tenancyModelState,_logger);returnbase.SaveChangesAsync(acceptAllChangesOnSuccess,cancellationToken);}}

Additional Options

publicclassTenantReferenceOptions{// Summary:// If set to a non-null value, the store will use this value as the name for the// tenant's reference property. The default is "TenantId".publicstringReferenceName{get;set;}// Summary:// True to enable indexing of tenant reference properties in the store, otherwise// false. The default is true.publicboolIndexReferences{get;set;}// Summary:// If set to a non-null value, the store will use this value as the name of the// index for any tenant references. The name is also a format pattern of {0:PropertyName}.// The default is "{0}Index", eg. "TenantIdIndex".publicstringIndexNameFormat{get;set;}// Summary:// Determines if a null tenant reference is allowed for entities and how querying// for null tenant references is handled.publicNullTenantReferenceHandlingNullTenantReferenceHandling{get;set;}}publicenumNullTenantReferenceHandling{// Summary:// A null tenant reference is NOT allowed for the entity, where possible a NOT NULL// or REQUIRED constraint should be set on the tenant reference, querying for entities// with a null tenant reference will match NO entities.// This is the default option.NotNullDenyAccess=0,// Summary:// A null tenant reference is allowed for the entity, where possible an NULLABLE// or OPTIONAL constraint should be set on the tenant reference, querying for entities// with a null tenant reference will match those expected results.// This may be useful where globally defined system entities are set with a null// tenant reference.NullableEntityAccess=1,// Summary:// A null tenant reference is NOT allowed for the entity, where possible a NOT NULL// or REQUIRED constraint should be set on the tenant reference, querying for entities// with a null tenant reference will match ALL entities across all tenants.// For obvious security reasons, this is typically not recommended; however, this// can be useful for admin reporting across all tenants.NotNullGlobalAccess=2}

Interesting Logs

Microsoft.AspNetCore start of request

Microsoft.AspNetCore.Hosting.Internal.WebHost:Information: Request starting HTTP/1.1 POST http://localhost:5020/account/login?returnUrl=%2Fgrants application/x-www-form-urlencoded 267

MultiTenancyServer.EntityFrameworkCore lookup tenant

Microsoft.EntityFrameworkCore.Database.Command:Information: Executed DbCommand (3ms) [Parameters=[@__normalizedCanonicalName_0='LOCALHOST' (Size = 256)], CommandType='Text', CommandTimeout='30'] SELECT TOP(1) [u].[Id], [u].[CanonicalName], [u].[ConcurrencyStamp], [u].[Name], [u].[NormalizedCanonicalName] FROM [Tenants] AS [u] WHERE [u].[NormalizedCanonicalName] = @__normalizedCanonicalName_0

MultiTenancyServer.AspNetCore found tenant

MultiTenancyServer.Http.HttpTenancyProvider:Debug: Tenant TEST_TENANT_1 found by DomainParser for value localhost in request http://localhost:5020/account/login?returnUrl=%2Fgrants.

Microsoft.AspNetCore.Identity lookup user within tenant

Microsoft.EntityFrameworkCore.Database.Command:Information: Executed DbCommand (13ms) [Parameters=[@___tenantId_0='TEST_TENANT_1' (Size = 4000), @__normalizedUserName_0='ALICE' (Size = 256)], CommandType='Text', CommandTimeout='30'] SELECT TOP(1) [u].[Id], ..., [u].[UserName] FROM [Users] AS [u] WHERE (@___tenantId_0 IS NOT NULL AND ([u].[TenantId] = @___tenantId_0)) AND ([u].[NormalizedUserName] = @__normalizedUserName_0)

IdentityServer4 lookup persisted grant within tenant

Microsoft.EntityFrameworkCore.Database.Command:Information: Executed DbCommand (1ms) [Parameters=[@___tenantId_0='TEST_TENANT_1' (Size = 4000), @__subjectId_0='3ab99036-8ac1-4270-8a1e-390988966b9c' (Size = 200)], CommandType='Text', CommandTimeout='30'] SELECT [p].[Key], [p].[ClientId], [p].[CreationTime], [p].[Data], [p].[Expiration], [p].[SubjectId], [p].[TenantId], [p].[Type] FROM [PersistedGrants] AS [p] WHERE (@___tenantId_0 IS NOT NULL AND ([p].[TenantId] = @___tenantId_0)) AND ([p].[SubjectId] = @__subjectId_0)

About

MultiTenancyServer core library.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages