Skip to content

Identity Persistence

Database-backed authorization with EF Core: temporal role/permission stores, user-group-role entities, and Just-In-Time provisioning.

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/ValidTo timestamps
  • Runtime management — role/permission mappings can be changed without redeployment
  • Audit trail — revocation sets ValidTo instead of deleting, preserving history
  • JIT provisioning — automatically create local user records on first external login

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 |
+------------------+ +------------------+

Maps a role to a permission with temporal validity.

ColumnTypeIndexed
IdGuid (PK)Yes
RoleNamestring(256)Yes (composite)
PermissionNamestring(512)Yes (composite)
ValidFromDateTimeOffsetYes (composite)
ValidToDateTimeOffset?Yes (composite)

Table: RolePermissions Indices:

  • IX_RolePermissions_Role_Temporal on (RoleName, ValidFrom, ValidTo)
  • IX_RolePermissions_Role_Permission_ValidFrom unique on (RoleName, PermissionName, ValidFrom)

Maps a group to a role with temporal validity.

ColumnType
IdGuid (PK)
GroupNamestring
RoleNamestring
ValidFromDateTimeOffset
ValidToDateTimeOffset?

Table: GroupRoles

Assigns a role to a user with temporal validity.

ColumnType
IdGuid (PK)
UserIdTKey (FK)
RoleNamestring
AssignedBystring?
ValidFromDateTimeOffset
ValidToDateTimeOffset?

Assigns a user to a group with temporal validity.

ColumnType
IdGuid (PK)
UserIdTKey (FK)
GroupNamestring
AssignedBystring?
ValidFromDateTimeOffset
ValidToDateTimeOffset?

Links a local user to an external identity provider.

ColumnType
IdGuid (PK)
UserIdTKey (FK)
Providerstring (e.g., “Auth0”, “EntraID”, “Keycloak”)
Issuerstring (issuer URI)
Subjectstring (provider-unique user ID)
ExternalIdentityKeystring (computed: {Issuer}|{Subject})
LastUsedAtDateTimeOffset?
+ IAuditable fields

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);

Implements ITemporalGroupRoleStore (extends IGroupRoleStore).

Same temporal filtering for the group-to-role mapping:

var roles = await store.GetRolesForGroupAsync("customer-care");

When registered, these stores replace the in-memory stores from Pragmatic.Authorization. The permission resolution chain becomes:

User claims (roles, groups)
|
v
CachedPermissionResolver
|
+-- ClaimsPermissionProvider (reads "permission" claims directly)
+-- RoleExpansionProvider (calls EfRolePermissionStore for each role)
+-- GroupExpansionProvider (calls EfGroupRoleStore for each group, then EfRolePermissionStore)
|
v
Merged permission set (cached per request or cross-request via ICacheStack)
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>();
});
app.UseIdentityPersistence<AppUser, Guid>(persistence =>
{
persistence.EnableJitProvisioning();
});

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

For direct DI registration:

services.AddPragmaticIdentityPersistence<AppUser, Guid>(persistence =>
{
persistence.EnableJitProvisioning();
});

The IdentityDbContext<TUser, TKey> base class provides all entity configurations automatically:

// Deprecated approach -- use boundary DbContext composition instead
public 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>();
}
}

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.

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 ValidTo instead of deleting rows
  • Point-in-time queries: Check what permissions were active at any moment
// Grant "booking-manager" role for 30 days
var 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();
// 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();

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 FakeClock to control the time and test temporal behavior
  • Point-in-time queries use the explicit asOf parameter overload

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.

External IdP token arrives
|
v
JitProvisioningService.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
|
v
JitProvisioningResult<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;
}
}

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.

The JitProvisioningService automatically detects the identity provider from the issuer URI:

Issuer ContainsProvider Name
microsoftonline.comEntraID
auth0.comAuth0
keycloakKeycloak
(other)Hostname from URI

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>:

PropertyTypeDescription
IdTKeyPrimary key
DisplayNamestring?Human-readable name
Emailstring?Email address
IsActiveboolAccount active flag
ExternalIdentityKeystring?"{issuer}|{subject}" canonical key
ProvisionSourceProvisionSourceHow the account was created
LastLoginAtDateTimeOffset?Last authentication timestamp
+ IAuditableCreatedAt, CreatedBy, UpdatedAt, UpdatedBy

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
}

CachedPermissionResolver always caches resolved permissions for the lifetime of the request scope. No configuration needed.

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} (or permissions:{userId} without multi-tenancy)
  • Tags: user:{userId} (for targeted invalidation)
  • Expiration: configurable via the TimeSpan parameter

When role/permission mappings change (e.g., via admin panel), invalidate the cache by tag:

await cache.RemoveByTagAsync($"user:{userId}");