Identity Persistence
Database-backed authorization with EF Core: temporal role/permission stores, user-group-role entities, and Just-In-Time provisioning.
Overview
Section titled “Overview”Pragmatic.Identity.Persistence replaces the in-memory authorization stores from Pragmatic.Authorization with EF Core implementations that support:
- Temporal validity — permissions and role assignments have
ValidFrom/ValidTotimestamps - Runtime management — role/permission mappings can be changed without redeployment
- Audit trail — revocation sets
ValidToinstead of deleting, preserving history - JIT provisioning — automatically create local user records on first external login
Entity Model
Section titled “Entity Model”All join entities implement ITemporalRelation with ValidFrom/ValidTo for time-bounded validity.
+-----------------+ | ExternalIdent | | Record<TKey> | | Provider | | Issuer|Subject | +--------+--------+ | | UserId (FK) v+------------------+ +-----------------+ +------------------+| UserRole<TKey> |<-->| IdentityUser |<-->| UserGroup<TKey> || RoleName | | Base<TKey> | | GroupName || ValidFrom/To | | DisplayName | | ValidFrom/To || AssignedBy | | Email | | AssignedBy |+--------+---------+ | ExternalIdKey | +--------+---------+ | | ProvisionSource | | | +-----------------+ | v v+------------------+ +------------------+| RolePermission | | GroupRole || RoleName | | GroupName || PermissionName | | RoleName || ValidFrom/To | | ValidFrom/To |+------------------+ +------------------+Entity Details
Section titled “Entity Details”RolePermission
Section titled “RolePermission”Maps a role to a permission with temporal validity.
| Column | Type | Indexed |
|---|---|---|
Id | Guid (PK) | Yes |
RoleName | string(256) | Yes (composite) |
PermissionName | string(512) | Yes (composite) |
ValidFrom | DateTimeOffset | Yes (composite) |
ValidTo | DateTimeOffset? | Yes (composite) |
Table: RolePermissions
Indices:
IX_RolePermissions_Role_Temporalon (RoleName,ValidFrom,ValidTo)IX_RolePermissions_Role_Permission_ValidFromunique on (RoleName,PermissionName,ValidFrom)
GroupRole
Section titled “GroupRole”Maps a group to a role with temporal validity.
| Column | Type |
|---|---|
Id | Guid (PK) |
GroupName | string |
RoleName | string |
ValidFrom | DateTimeOffset |
ValidTo | DateTimeOffset? |
Table: GroupRoles
UserRole<TKey>
Section titled “UserRole<TKey>”Assigns a role to a user with temporal validity.
| Column | Type |
|---|---|
Id | Guid (PK) |
UserId | TKey (FK) |
RoleName | string |
AssignedBy | string? |
ValidFrom | DateTimeOffset |
ValidTo | DateTimeOffset? |
UserGroup<TKey>
Section titled “UserGroup<TKey>”Assigns a user to a group with temporal validity.
| Column | Type |
|---|---|
Id | Guid (PK) |
UserId | TKey (FK) |
GroupName | string |
AssignedBy | string? |
ValidFrom | DateTimeOffset |
ValidTo | DateTimeOffset? |
ExternalIdentityRecord<TKey>
Section titled “ExternalIdentityRecord<TKey>”Links a local user to an external identity provider.
| Column | Type |
|---|---|
Id | Guid (PK) |
UserId | TKey (FK) |
Provider | string (e.g., “Auth0”, “EntraID”, “Keycloak”) |
Issuer | string (issuer URI) |
Subject | string (provider-unique user ID) |
ExternalIdentityKey | string (computed: {Issuer}|{Subject}) |
LastUsedAt | DateTimeOffset? |
+ IAuditable fields |
EF Stores
Section titled “EF Stores”EfRolePermissionStore
Section titled “EfRolePermissionStore”Implements ITemporalRolePermissionStore (extends IRolePermissionStore).
Queries the RolePermission table and filters by temporal validity using IClock.UtcNow:
// Only returns permissions where: ValidFrom <= now AND (ValidTo is null OR ValidTo > now)var permissions = await store.GetPermissionsForRoleAsync("booking-manager");
// Point-in-time query (e.g., for audit)var pastPermissions = await store.GetPermissionsForRoleAsync("booking-manager", asOf: someDate);EfGroupRoleStore
Section titled “EfGroupRoleStore”Implements ITemporalGroupRoleStore (extends IGroupRoleStore).
Same temporal filtering for the group-to-role mapping:
var roles = await store.GetRolesForGroupAsync("customer-care");How They Integrate
Section titled “How They Integrate”When registered, these stores replace the in-memory stores from Pragmatic.Authorization. The permission resolution chain becomes:
User claims (roles, groups) | vCachedPermissionResolver | +-- ClaimsPermissionProvider (reads "permission" claims directly) +-- RoleExpansionProvider (calls EfRolePermissionStore for each role) +-- GroupExpansionProvider (calls EfGroupRoleStore for each group, then EfRolePermissionStore) | vMerged permission set (cached per request or cross-request via ICacheStack)Basic Setup
Section titled “Basic Setup”await PragmaticApp.RunAsync(args, app =>{ // Authentication (required) app.UseJwtAuthentication(jwt => { /* ... */ });
// Authorization with DB-backed stores app.UseAuthorization(authz => { // Static role definitions are still supported alongside DB stores authz.MapRole<AdminRole>(); });
// Register EF stores (replaces in-memory stores) app.UseIdentityPersistence<AppUser, Guid>();});With JIT Provisioning
Section titled “With JIT Provisioning”app.UseIdentityPersistence<AppUser, Guid>(persistence =>{ persistence.EnableJitProvisioning();});Skipping Default Stores
Section titled “Skipping Default Stores”If you have a custom IRolePermissionStore or IGroupRoleStore, skip the EF defaults:
app.UseIdentityPersistence<AppUser, Guid>(persistence =>{ persistence.SkipRolePermissionStore(); // You provide your own persistence.SkipGroupRoleStore(); // You provide your own});Without IPragmaticBuilder
Section titled “Without IPragmaticBuilder”For direct DI registration:
services.AddPragmaticIdentityPersistence<AppUser, Guid>(persistence =>{ persistence.EnableJitProvisioning();});Database Configuration
Section titled “Database Configuration”Using IdentityDbContext (Deprecated)
Section titled “Using IdentityDbContext (Deprecated)”The IdentityDbContext<TUser, TKey> base class provides all entity configurations automatically:
// Deprecated approach -- use boundary DbContext composition insteadpublic class AppIdentityDbContext : IdentityDbContext<AppUser, Guid>{ public AppIdentityDbContext(DbContextOptions<AppIdentityDbContext> options) : base(options) { }}Using ApplyIdentityConfigurations (Deprecated)
Section titled “Using ApplyIdentityConfigurations (Deprecated)”Apply identity configurations to an existing DbContext:
public class AppDbContext : DbContext{ protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.ApplyIdentityConfigurations<AppUser, Guid>(); }}Recommended: UsePackage Composition
Section titled “Recommended: UsePackage Composition”The preferred approach is to use the [UsePackage<LocalIdentityPackage>] attribute on your host module. The source generator integrates identity entities into your boundary DbContext via OwnsOne composition.
Temporal Authorization
Section titled “Temporal Authorization”Concept
Section titled “Concept”All authorization entities use ValidFrom/ValidTo for time-bounded validity. This enables:
- Scheduled permissions: Grant access starting from a future date
- Expiring assignments: Auto-revoke after a deadline
- Audit trail: Revocation sets
ValidToinstead of deleting rows - Point-in-time queries: Check what permissions were active at any moment
Example: Temporary Role Assignment
Section titled “Example: Temporary Role Assignment”// Grant "booking-manager" role for 30 daysvar userRole = new UserRole<Guid>{ UserId = userId, RoleName = "booking-manager", AssignedBy = currentUser.Id, ValidFrom = DateTimeOffset.UtcNow, ValidTo = DateTimeOffset.UtcNow.AddDays(30)};dbContext.Set<UserRole<Guid>>().Add(userRole);await dbContext.SaveChangesAsync();Example: Revoking a Permission
Section titled “Example: Revoking a Permission”// Revoke by setting ValidTo to now (preserves audit trail)var rolePermission = await dbContext.Set<RolePermission>() .FirstAsync(rp => rp.RoleName == "editor" && rp.PermissionName == "publish.article");
rolePermission.ValidTo = DateTimeOffset.UtcNow;await dbContext.SaveChangesAsync();Temporal Queries via IClock
Section titled “Temporal Queries via IClock”The EF stores use IClock.UtcNow for temporal filtering, which means:
- In production, permissions are filtered by the real current time
- In tests, you can inject a
FakeClockto control the time and test temporal behavior - Point-in-time queries use the explicit
asOfparameter overload
JIT Provisioning
Section titled “JIT Provisioning”What It Does
Section titled “What It Does”Just-In-Time (JIT) provisioning automatically creates a local user record when a user first authenticates via an external identity provider (Auth0, Entra ID, Keycloak, etc.).
On subsequent logins, it updates LastLoginAt and the external identity link.
How It Works
Section titled “How It Works”External IdP token arrives | vJitProvisioningService.ProvisionAsync(issuer, subject, configure) | +-- Find existing ExternalIdentityRecord by (issuer, subject) | | | +-- Found → Update LastLoginAt, update claims (unless SCIM-provisioned) | +-- Not found → Create new user entity + ExternalIdentityRecord link | vJitProvisioningResult<TUser> { User, IsNewUser }public class ExternalLoginHandler(JitProvisioningService<AppUser, Guid> jit){ public async Task<AppUser> HandleExternalLogin(ClaimsPrincipal principal, CancellationToken ct) { var issuer = principal.FindFirst("iss")?.Value!; var subject = principal.FindFirst("sub")?.Value!;
var result = await jit.ProvisionAsync( issuer, subject, user => { // Map claims to user properties user.DisplayName = principal.FindFirst("name")?.Value; user.Email = principal.FindFirst("email")?.Value; }, ct);
if (result.IsNewUser) { // First login -- assign default role, send welcome email, etc. }
return result.User; }}SCIM Awareness
Section titled “SCIM Awareness”If a user was provisioned via SCIM (ProvisionSource.Scim), JIT provisioning does not overwrite SCIM-owned fields. It only updates LastLoginAt and LastUsedAt on the external identity record.
Provider Detection
Section titled “Provider Detection”The JitProvisioningService automatically detects the identity provider from the issuer URI:
| Issuer Contains | Provider Name |
|---|---|
microsoftonline.com | EntraID |
auth0.com | Auth0 |
keycloak | Keycloak |
| (other) | Hostname from URI |
IdentityUserBase (Deprecated)
Section titled “IdentityUserBase (Deprecated)”The IdentityUserBase<TKey> abstract class was the original base for user entities. It is now deprecated in favor of the package composition approach with [PragmaticUser].
Properties provided by IdentityUserBase<TKey>:
| Property | Type | Description |
|---|---|---|
Id | TKey | Primary key |
DisplayName | string? | Human-readable name |
Email | string? | Email address |
IsActive | bool | Account active flag |
ExternalIdentityKey | string? | "{issuer}|{subject}" canonical key |
ProvisionSource | ProvisionSource | How the account was created |
LastLoginAt | DateTimeOffset? | Last authentication timestamp |
+ IAuditable | CreatedAt, CreatedBy, UpdatedAt, UpdatedBy |
Migration Path
Section titled “Migration Path”Instead of inheriting IdentityUserBase<TKey>, define a plain domain entity with the [PragmaticUser] attribute and compose identity as an owned property:
// Old (deprecated)public class AppUser : IdentityUserBase<Guid> { }
// New (recommended)[PragmaticUser]public partial class AppUser{ public Guid Id { get; set; } public string? DisplayName { get; set; } public LocalIdentity Identity { get; set; } = null!; // Owned entity}Caching
Section titled “Caching”Request-Level Cache
Section titled “Request-Level Cache”CachedPermissionResolver always caches resolved permissions for the lifetime of the request scope. No configuration needed.
Cross-Request Cache
Section titled “Cross-Request Cache”For applications with many users and frequent permission checks, enable cross-request caching:
app.UseAuthorization(authz =>{ authz.UsePermissionCache(TimeSpan.FromMinutes(5));});This uses ICacheStack (via CacheCategories.Permissions category) with:
- Cache key:
permissions:{tenantId}:{userId}(orpermissions:{userId}without multi-tenancy) - Tags:
user:{userId}(for targeted invalidation) - Expiration: configurable via the
TimeSpanparameter
Cache Invalidation
Section titled “Cache Invalidation”When role/permission mappings change (e.g., via admin panel), invalidate the cache by tag:
await cache.RemoveByTagAsync($"user:{userId}");