ASP.NET Core Identity
ASP.NET Core Identity keeps users in your database. When tenants share that database, make the user type tenant-owned and its user names unique within a tenant. Tenantry then keeps each tenant's users apart like any other tenant-owned entity.
The user type and context
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Tenantry;
public class AppUser : IdentityUser, ITenantEntity<Guid>
{
public Guid TenantId { get; set; }
}
public class AppIdentityDbContext(DbContextOptions<AppIdentityDbContext> options)
: IdentityDbContext<AppUser>(options)
{
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
// Identity makes user names unique across the table. Make them unique within a tenant, under the same name.
var users = builder.Entity<AppUser>();
users.Metadata.RemoveIndex([users.Metadata.FindProperty(nameof(AppUser.NormalizedUserName))!]);
users.HasIndex(u => new { u.NormalizedUserName, u.TenantId }).HasDatabaseName("UserNameIndex").IsUnique();
}
}Register the context with UseTenantry(), and Identity's stores as usual:
builder.Services.AddDbContext<AppIdentityDbContext>(options => options
.UseSqlServer(connectionString)
.UseTenantry());
builder.Services.AddIdentity<AppUser, IdentityRole>()
.AddEntityFrameworkStores<AppIdentityDbContext>();Then:
UserManagerandSignInManagerfind only the current tenant's users. Two tenants can each have a user named alice, and a user signs in to the tenant the request resolves to.- A new user gets the current tenant when it is saved. Creating one with no tenant current throws, as for any tenant-owned entity.
- Roles are shared by every tenant, and list only the current tenant's members. For roles of a tenant's own, make the
role type tenant-owned too, and its names unique within a tenant (
RoleNameIndex), the same way. - Identity keys an external login by its provider and the provider's key, so a Google or Entra ID account can be linked to a user in one tenant only.
Sign-in cookies
Every tenant's cookies are protected with the application's one key ring, so a user's cookie from one tenant also
decrypts for another (Cookies). Put the tenant in the cookie, and refuse a
signed-in user whose cookie names another tenant. The factory derives from the one that adds the user's roles, as
AddIdentity<AppUser, IdentityRole> does:
using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
public sealed class TenantClaimsFactory(
UserManager<AppUser> users, RoleManager<IdentityRole> roles, IOptions<IdentityOptions> options)
: UserClaimsPrincipalFactory<AppUser, IdentityRole>(users, roles, options)
{
protected override async Task<ClaimsIdentity> GenerateClaimsAsync(AppUser user)
{
var identity = await base.GenerateClaimsAsync(user);
identity.AddClaim(new Claim("tenant_id", user.TenantId.ToString()));
return identity;
}
}builder.Services.AddIdentity<AppUser, IdentityRole>()
.AddEntityFrameworkStores<AppIdentityDbContext>()
.AddClaimsPrincipalFactory<TenantClaimsFactory>();
builder.Services.AddTenantry<Guid>(tenant => tenant
.ResolveFromSubdomain(o => o.BaseDomains.Add("example.com"))
.UseStore<EfCoreTenantStore>()
.ValidateTenantAccess((http, t) =>
http.User.Identity?.IsAuthenticated != true ||
http.User.FindFirst("tenant_id")?.Value == t.TenantId.ToString()));The validator lets an anonymous caller through, so the sign-in page has its tenant current and finds the tenant's users. Resolve the tenant from the request (its host or route), not from a claim, since nobody is signed in yet.