This section uses the code from full override as a starting point.
You can find the finished code on our GitHub we recommend reading the previous section to understand the code written so far.
Tenancy Example using Entity Framework
In this documentation you will be learning how to make a non-AdminUI field (one that is not on ISSOUser) editable within AdminUI using claims.
The outcome of this documentation is to take an example user like the one below - one that has TenantName on it which is linked to the tenants table (fig2)
And make them editable in AdminUI by using claims:
Prerequisites
There are 3 NuGet packages and a few external dependencies before you get started
- Nuget Packages
- EntityFramework
- EntityFramework.SqlServer
- EntityFramework.Design
- A SqlServer database
- An IdentityServer that can access your user store to login
Database
First you must create a database model and be able to scaffold and seed a database with some sample data to get AdminUI. In this section you'll be using EntityFramework to do so.
Models
To get started you will create the models that represent your Identity model:
Tenant User
TenantUser
is the model that will represent users in your system. This model is based off of ISSOUser
but does not implement all of the interface. This is for 2 reasons:
- We want to include the roles a user belongs to in the model
- The claims collection needs a concrete type for Entity Framework to work against and
ISSOUser
expects a collection ofISSOClaim
public class TenantUser
{
[Key]
public string UserId { get; set; }
public string UserName { get; set; }
public string Email { get; set; }
[ConcurrencyCheck]
public string ConcurrencyStamp { get; set; }
public bool TwoFactorEnabled { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public bool IsBlocked { get; }
public bool IsDeleted { get; set; }
public bool LockoutEnabled { get; set; }
public DateTimeOffset? LockoutEnd { get; }
public string Password { get; set; }
public virtual ICollection<TenantRole> Roles { get; set; }
public virtual ICollection<TenantClaim> Claims { get; set; }
public virtual Tenant? Tenant { get; set; }
}
Tenant Role
Describes roles that a user can belong to - including the Users collection on this allows EF to create a many-many connection between the Users and Roles tables.
public class TenantRole : ISSORole
{
[Key]
public string Id { get; set; }
public string Description { get; set; }
public bool NonEditable { get; set; }
public string Name { get; set; }
public virtual ICollection<TenantUser> Users { get; set; }
}
Tenant Claim
Describes claims that a user has
public class TenantClaim : ISSOClaim
{
[Key]
public int Id { get; set; }
public string ClaimType { get; set; }
public string ClaimValue { get; set; }
}
Tenant
The table representing tenants a user can belong to
public class Tenant
{
[Key]
public string Name { get; set; }
}
Tenant ClaimType
ClaimTypes represent the ClaimType
field on a TenantClaim
. The main thing to note is your AllowedValues
collection - this is how we will translate the Tenant
table to a selectable list of values
public class TenantClaimType
{
[Key]
public string Id { get; set; }
public string Name { get; set; }
public string DisplayName { get; set; }
public string Description { get; set; }
public bool IsRequired { get; set; }
public bool IsReserved { get; set; }
public SSOClaimValueType ValueType { get; set; }
public string RegularExpressionValidationRule { get; set; }
public string RegularExpressionValidationFailureDescription { get; set; }
public bool IsUserEditable { get; set; }
public ICollection<TenantEnumClaimTypeValue> AllowedValues { get; set; }
}
Tenant Enum Claim Type Allowed Values
This table ties allowed values to particular claim types.
public class TenantEnumClaimTypeValue
{
public string ClaimTypeId { get; set; }
public string Value { get; set; }
}
Create DBContext for Identity
Your next step is to build a DbContext - a DbContext in EntityFramework is the primary class for interacting with your database, it has knowledge of all your tables and the relationships between them.
As you're using Entity Framework to build the database (known as Code-First Entity Framework) you can let the models define most of the relationships,
however a limitation in EntityFramework is that you cannot define composite keys at the model level,
so you need to add an "OnModelCreating" override that defines the TenantEnumClaimTypeValue
as a composite key of the claimType it belongs to and it's value
public class CustomIdentityDb : DbContext
{
public CustomIdentityDb(DbContextOptions<CustomIdentityDb> options) : base(options){}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<TenantEnumClaimTypeValue>(enumClaimTypeValue =>
{
enumClaimTypeValue.HasKey(ctv => new { ctv.ClaimTypeId, ctv.Value });
});
}
public DbSet<TenantUser> Users { get; set; }
public DbSet<TenantRole> Roles { get; set; }
public DbSet<TenantClaim> UserClaims { get; set; }
public DbSet<TenantClaimType> ClaimTypes { get; set; }
public DbSet<TenantEnumClaimTypeValue> EnumClaimTypeAllowedValues { get; set; }
public DbSet<Tenant> Tenants { get; set; }
}
Creating and Running Migrations
Now that your database has been defined in code, you'll need to get it up and running.
To do this, the Entity Framework tooling needs to know where your database in and what database engine to use.
So in your startup register the DbContext with .AddDbContext
and use the OptionsBuilder
to register a SqlServer database.
builder.Services.AddDbContext<CustomIdentityDb>(options =>
{
options.UseSqlServer(@"Server=localhost,1433;User Id=sa;Password=Password123!;Database=CustomIdentityDb;");
});
In our sample the connection string is hardcoded for ease but it is also possible to get it from config if you please.
From a terminal or command line, navigate to your project and run the following
dotnet ef migrations add InitialCreate
- This is what creates the migrations. After this command is run you will have a new Migrations
folder in your project.
Secondly run dotnet ef database update
- This command connects your project to the database server and uses the migrations previously generated to generate a database.
Afterwards you can navigate to your database to confirm that it has been created successfully.
Seeding the database
Provided in the Program.cs
of our sample solution is a commented out block of code to seed the database which does the following:
- Maps an HttpGet to the base path
- Injects the new DbContext you just created
- Creates an Admin role
- Creates 2 Tenants,
- Assigns the Admin Role and 1 Tenant to a new User - this is the use you will use to login to AdminUI
- It then creates a ClaimType that represents tenants.
- An important note is that this code does not populate the allowed values here as you will be filling that collection at a later time
- It is also required, meaning we will see it when editing user details and it is reserved, meaning no one can edit it from within AdminUI.
If you would like to use the code snippet, put it above the app.Run
call, comment out the app.useAdminUI
call, uncomment the seed block and run your solution.
Updating the stores & factory
The next step is to write your services to inject your context so they can make database calls. You will also need to update your factory to
As an example the User store will now look like:
public class UserStore : ISSOUserStore
{
private readonly CustomIdentityDb _identityContext;
public UserStore(CustomIdentityDb identityContext)
{
_identityContext = identityContext ?? throw new ArgumentNullException(nameof(identityContext));
}
...
and the factory:
public class CustomSSOStoreFactory : ISSOStoreFactory
{
private readonly CustomIdentityDb _identityDb;
public CustomSSOStoreFactory(CustomIdentityDb identityDb)
{
_identityDb = identityDb ?? throw new ArgumentNullException(nameof(identityDb));
}
public ISSOUserStore CreateUserStore()
{
return new UserStore(_identityDb);
}
...
Mapping the Tenancy field
Now you will write the stores so they can make the conversion from field to claim happen. As a brief behind the scenes explanation when AdminUI gets a User it will get a list of claims for that user. This is a ClaimType-ClaimValue pair.
AdminUI will then get all ClaimTypes and match that Claim's ClaimType
to a ClaimType's Name
The rest of the guide will not take you over all of the store methods, but the essential ones crucial to the process described above, starting with getting the Claim Types.
ClaimType Store
The main method to focus on in the ClaimType store is the GetAllClaimTypes method. This method:
- Gets all ClaimTypes from the database and maps them to our store level objects (something which implements ISSOClaimType)
- Finds the claim type that represents Tenant (This was added upon seed)
- Populates the Allowed Values collection on that object by calling off to the Tenants table and getting all the names from that table
- Returns the ClaimTypes
public async Task<IEnumerable<ISSOClaimType>> GetAllClaimTypes()
{
var ctList = _identityContext.ClaimTypes.Include(claimType => claimType.AllowedValues)
.Select(dbCt => dbCt.ToStoreClaimType());
var tenantCt = await ctList.FirstOrDefaultAsync(ctList => ctList.Id == TenantClaimTypeConsts.TenantCTId);
tenantCt.AllowedValues = _identityContext.Tenants.Select(x => x.Name).ToList();
return await ctList.ToListAsync();
}
User Store
When Creating or Updating a user you must map the ClaimType that you received from the ClaimStore back from a Claim on the User to a field, for example your create should do the following:
- Map the incoming user to something that database can read
- Hash password if needed
- Grab the value of the Tenant claim from the passed in user
- Validate that the claim value matches a value in the tenants table
- Updates the users tenants field with that value
- Add, Saves and Returns
public async Task<ISSOUser> CreateUserWithPassword(ISSOUser user, string password)
{
var dbUser = user.ToDbUser();
dbUser.ConcurrencyStamp = Guid.NewGuid().ToString();
dbUser.Password = _passwordHasher.HashPassword(dbUser, password);
var tenantName = user.Claims.FirstOrDefault(claim => claim.ClaimType == TenantClaimTypeConsts.TenantCTName)?.ClaimValue;
dbUser.Tenant = await _identityContext.Tenants.FirstOrDefaultAsync(tenant => tenant.Name == tenantName);
_identityContext.Users.Add(dbUser);
await _identityContext.SaveChangesAsync();
var userInDb = await _identityContext.Users.FirstOrDefaultAsync(dbUsers => dbUsers.UserName == user.UserName);
return userInDb.ToStoreUser();
}
public async Task<ISSOUser> UpdateUser(ISSOUser user)
{
var dbUser = await _identityContext.Users.Include(dbUser => dbUser.Claims).Include(dbUser => dbUser.Roles).Include(dbUser => dbUser.Tenant).FirstOrDefaultAsync(dbUser => dbUser.UserId == user.Id);
dbUser.Email = user.Email;
dbUser.FirstName = user.FirstName;
dbUser.UserName = user.UserName;
dbUser.TwoFactorEnabled = user.TwoFactorEnabled;
dbUser.LastName = user.LastName;
dbUser.ConcurrencyStamp = Guid.NewGuid().ToString();
var tenantClaim = user.Claims.FirstOrDefault(userClaim => userClaim.ClaimType == TenantClaimTypeConsts.TenantCTName)?.ClaimValue;
var dbTenant = _identityContext.Tenants.FirstOrDefault(tenant => tenant.Name == tenantClaim);
dbUser.Tenant = dbTenant;
await _identityContext.SaveChangesAsync();
return dbUser.ToStoreUser();
}
Then implement the UserStores Find methods such that when the ClaimType is encountered, you map the Tenant
field to a new Claim that has the TenantClaimType as the ClaimType. For example the FindUserById method looks like this:
public async Task<ISSOUser> FindUserById(string userId)
{
var dbUser = await _identityContext.Users.Include(user => user.Claims).Include(user => user.Roles).Include(user => user.Tenant).FirstOrDefaultAsync(user => user.UserId == userId);
if (dbUser == null)
{
throw new UserNotFoundException(userId);
}
var storeUser = dbUser.ToStoreUser();
storeUser.Claims.Add(new SSOClaim(TenantClaimTypeConsts.TenantCTName, dbUser.Tenant.Name));
return storeUser;
}
IdentityServer Setup
Your IdentityServer setup might not need changing but if you are starting from the Samples you will need to add a profile service like below
public class TenantProfileService : IProfileService
{
private readonly CustomIdentityDb _dbContext;
public TenantProfileService(CustomIdentityDb dbContext)
{
_dbContext = dbContext;
}
public async Task GetProfileDataAsync(ProfileDataRequestContext context)
{
var userId = context.Subject.FindFirstValue(JwtClaimTypes.Subject);
var user = await _dbContext.Users.Include(user => user.Roles).FirstOrDefaultAsync(tenantUser => tenantUser.UserId == userId);
if (!string.IsNullOrWhiteSpace(user?.UserName))
{
context.IssuedClaims.Add(new Claim("name", user.UserName));
}
var userRoles = user.Roles;
foreach (var role in userRoles)
{
context.IssuedClaims.Add(new Claim("role", role.Name));
}
}
public Task IsActiveAsync(IsActiveContext context)
{
context.IsActive = true;
return Task.CompletedTask;
}
}