Skip to content

Stores

Pragmatic.Authorization uses pluggable stores for role-permission and group-role mappings. This document covers the in-memory implementations, the store interfaces, and the forward-compatible interfaces for temporal and multi-tenant scenarios.

Resolves permissions assigned to a role:

public interface IRolePermissionStore
{
ValueTask<IReadOnlySet<string>> GetPermissionsForRoleAsync(
string roleName, CancellationToken ct = default);
ValueTask<IReadOnlyList<string>> GetAllRolesAsync(CancellationToken ct = default);
}

Used by RoleExpansionProvider (Order 100) and GroupExpansionProvider (Order 200, indirectly).

Resolves roles assigned to a group:

public interface IGroupRoleStore
{
ValueTask<IReadOnlySet<string>> GetRolesForGroupAsync(
string groupName, CancellationToken ct = default);
ValueTask<IReadOnlyList<string>> GetAllGroupsAsync(CancellationToken ct = default);
}

Used by GroupExpansionProvider (Order 200).

Default store for development, testing, and configuration-driven setups. Populated by AuthorizationBuilder.MapRole and SeedFromJson().

Thread safety: Uses ConcurrentDictionary<string, HashSet<string>> for the role map. Per-set locking on AddRole and GetPermissionsForRoleAsync (snapshot under lock to avoid concurrent modification during enumeration).

Lifetime: Registered as Singleton when populated via MapRole. The in-memory store is created during UseAuthorization configuration and lives for the application lifetime.

public sealed class InMemoryRolePermissionStore : IRolePermissionStore
{
public void AddRole(string roleName, IEnumerable<string> permissions);
public ValueTask<IReadOnlySet<string>> GetPermissionsForRoleAsync(string roleName, CancellationToken ct = default);
public ValueTask<IReadOnlyList<string>> GetAllRolesAsync(CancellationToken ct = default);
}

Case-insensitive role name lookup (uses StringComparer.OrdinalIgnoreCase).

Default store for group-role mappings. Populated by AuthorizationBuilder.MapGroup.

public sealed class InMemoryGroupRoleStore : IGroupRoleStore
{
public void AddGroup(string groupName, IEnumerable<string> roles);
public ValueTask<IReadOnlySet<string>> GetRolesForGroupAsync(string groupName, CancellationToken ct = default);
public ValueTask<IReadOnlyList<string>> GetAllGroupsAsync(CancellationToken ct = default);
}

Case-insensitive group name lookup.

Override the in-memory stores with database-backed implementations:

authz.UseRolePermissionStore<EfRolePermissionStore>();
authz.UseGroupRoleStore<EfGroupRoleStore>();

When a custom store is registered:

  • The in-memory store (if any roles were mapped) is not registered.
  • The RoleExpansionProvider or GroupExpansionProvider is still registered to use the custom store.

Custom stores are registered as Scoped (one per request) via Services.AddScoped<IRolePermissionStore, T>().

public sealed class EfRolePermissionStore(MyDbContext db) : IRolePermissionStore
{
public async ValueTask<IReadOnlySet<string>> GetPermissionsForRoleAsync(
string roleName, CancellationToken ct = default)
{
var permissions = await db.RolePermissions
.Where(rp => rp.RoleName == roleName)
.Select(rp => rp.PermissionName)
.ToListAsync(ct);
return new HashSet<string>(permissions, StringComparer.OrdinalIgnoreCase);
}
public async ValueTask<IReadOnlyList<string>> GetAllRolesAsync(CancellationToken ct = default)
{
return await db.RolePermissions
.Select(rp => rp.RoleName)
.Distinct()
.ToListAsync(ct);
}
}

Register:

authz.UseRolePermissionStore<EfRolePermissionStore>();

PragmaticBuilderAuthorizationExtensions.AddPragmaticAuthorization determines which stores and providers to register:

ConditionStoreProvider
MapRole called, no custom storeInMemoryRolePermissionStore (Singleton)RoleExpansionProvider (Scoped)
UseRolePermissionStore<T> calledCustom T (Scoped)RoleExpansionProvider (Scoped)
Neither calledNoneNone
ConditionStoreProvider
MapGroup called, no custom storeInMemoryGroupRoleStore (Singleton)GroupExpansionProvider (Scoped)
UseGroupRoleStore<T> calledCustom T (Scoped)GroupExpansionProvider (Scoped)
Neither calledNoneNone

When groups are mapped (or a custom group store is registered) but no role store exists, the builder automatically registers an empty InMemoryRolePermissionStore. This ensures GroupExpansionProvider (which depends on both IGroupRoleStore and IRolePermissionStore) does not fail.

These interfaces extend the base stores with additional parameters. They are defined today for forward compatibility but are not consumed by the current runtime. Implement them in your custom store to prepare for future features.

Point-in-time permission resolution for temporal authorization:

public interface ITemporalRolePermissionStore : IRolePermissionStore
{
ValueTask<IReadOnlySet<string>> GetPermissionsForRoleAsync(
string roleName, DateTimeOffset asOf, CancellationToken ct = default);
}

Use case: “What permissions did the admin role have on January 15?” Useful for audit trails and compliance.

Tenant-specific permission resolution for multi-tenant authorization:

public interface ITenantRolePermissionStore : IRolePermissionStore
{
ValueTask<IReadOnlySet<string>> GetPermissionsForRoleAsync(
string roleName, string tenantId, CancellationToken ct = default);
}

Use case: “What permissions does the manager role have in Tenant A?” Different tenants may have different permission sets for the same role.

Point-in-time group resolution:

public interface ITemporalGroupRoleStore : IGroupRoleStore
{
ValueTask<IReadOnlySet<string>> GetRolesForGroupAsync(
string groupName, DateTimeOffset asOf, CancellationToken ct = default);
}

Tenant-specific group resolution:

public interface ITenantGroupRoleStore : IGroupRoleStore
{
ValueTask<IReadOnlySet<string>> GetRolesForGroupAsync(
string groupName, string tenantId, CancellationToken ct = default);
}

These interfaces support runtime RBAC management (creating roles and permissions at runtime via admin UIs). They are consumed by the catalog system and the optional Pragmatic.Authorization.Management package.

public interface IDynamicPermissionStore
{
ValueTask<IReadOnlyList<PermissionInfo>> GetAllAsync(CancellationToken ct = default);
ValueTask<bool> ExistsAsync(string permissionName, CancellationToken ct = default);
}

Used by DefaultPermissionCatalog to merge runtime-created permissions with SG-generated static permissions.

public interface IDynamicRoleStore
{
ValueTask<IReadOnlyList<RoleInfo>> GetAllAsync(CancellationToken ct = default);
ValueTask<bool> ExistsAsync(string roleName, CancellationToken ct = default);
}

Used by DefaultPermissionCatalog to merge runtime-created roles.

public interface IResourcePolicyStore
{
ValueTask<IReadOnlyList<ProtectedResource>> GetAllResourcesAsync(CancellationToken ct = default);
ValueTask<PolicyExpression?> GetPolicyAsync(string resourceIdentifier, CancellationToken ct = default);
}

Used by DefaultResourceCatalog for dynamic policy-to-resource assignments.

For integration tests, use the in-memory stores directly:

var store = new InMemoryRolePermissionStore();
store.AddRole("test-role", ["permission.one", "permission.two"]);
services.AddSingleton<IRolePermissionStore>(store);
services.AddScoped<IPermissionProvider, RoleExpansionProvider>();

Or use the full AuthorizationBuilder:

services.AddPragmaticAuthorization(authz =>
{
authz.MapRole("test-admin", r => r.WithAllPermissions());
authz.MapRole("test-viewer", r => r.WithPermissions("entity.read"));
});

The Showcase integration tests use CreateClientWithRoles and CreateClientWithGroups helpers that set HTTP headers (X-User-Roles, X-User-Groups), which the NoOpAuthenticationHandler maps to claims. The RoleExpansionProvider and GroupExpansionProvider then resolve these claims to permissions via the in-memory stores.

StoreDefault ImplementationLifetimePopulated by
IRolePermissionStoreInMemoryRolePermissionStoreSingletonMapRole, SeedFromJson
IGroupRoleStoreInMemoryGroupRoleStoreSingletonMapGroup, SeedFromJson
IDynamicPermissionStoreNone (optional)ScopedRBAC Management package
IDynamicRoleStoreNone (optional)ScopedRBAC Management package
IResourcePolicyStoreNone (optional)ScopedRBAC Management package

Custom stores registered via UseRolePermissionStore<T>() or UseGroupRoleStore<T>() are Scoped.