Permission Resolution
This document explains how Pragmatic.Authorization resolves permissions at runtime: the provider chain, caching layers, and wildcard matching internals.
Overview
Section titled “Overview”Permission resolution is a three-phase process:
- Provider chain – Multiple
IPermissionProviderimplementations run in order, each contributing permissions. - Caching – Results are cached per request (always) and optionally cross-request via
ICacheStack(usingCacheCategories.Permissions). - Matching –
WildcardMatcherchecks if any resolved permission matches the required one.
The Provider Chain
Section titled “The Provider Chain”IPermissionProvider is the extension point. Each provider has an Order value that determines execution sequence. Results from all providers are merged with union semantics (additive only, no provider can remove permissions added by another).
public interface IPermissionProvider{ int Order { get; } ValueTask<IReadOnlySet<string>> ResolvePermissionsAsync( ICurrentUser user, CancellationToken ct = default);}Built-in Providers
Section titled “Built-in Providers”| Provider | Order | Source | Registered when |
|---|---|---|---|
ClaimsPermissionProvider |
0 | ICurrentUser.Claims["permission"] |
Always |
RoleExpansionProvider |
100 | IRolePermissionStore |
Roles are mapped or custom store registered |
GroupExpansionProvider |
200 | IGroupRoleStore + IRolePermissionStore |
Groups are mapped or custom store registered |
ClaimsPermissionProvider
Section titled “ClaimsPermissionProvider”The base provider. Reads the "permission" claim from ICurrentUser.Claims and returns each value as a permission string.
User claims: { "permission": ["booking.reservation.read", "catalog.amenity.read"] }Result: HashSet { "booking.reservation.read", "catalog.amenity.read" }RoleExpansionProvider
Section titled “RoleExpansionProvider”For each "role" claim on the user, queries IRolePermissionStore.GetPermissionsForRoleAsync(roleName) and merges results.
User claims: { "role": ["booking-manager"] }
IRolePermissionStore lookup: "booking-manager" -> { "booking.reservation.*", "booking.guest.*", "catalog.property.read" }
Result: HashSet { "booking.reservation.*", "booking.guest.*", "catalog.property.read" }GroupExpansionProvider
Section titled “GroupExpansionProvider”For each "group" claim on the user, queries IGroupRoleStore.GetRolesForGroupAsync(groupName) to get roles, then queries IRolePermissionStore.GetPermissionsForRoleAsync(roleName) for each role.
User claims: { "group": ["customer-care"] }
IGroupRoleStore lookup: "customer-care" -> { "booking-manager", "catalog-viewer" }
IRolePermissionStore lookup: "booking-manager" -> { "booking.reservation.*", "booking.guest.*", "catalog.property.read" } "catalog-viewer" -> { "catalog.amenity.read", "catalog.property.read" }
Result: HashSet { "booking.reservation.*", "booking.guest.*", "catalog.property.read", "catalog.amenity.read" }Custom Providers
Section titled “Custom Providers”Register via AuthorizationBuilder:
authz.AddPermissionProvider<ExternalPermissionProvider>();Or via CompositePermissionProvider, which aggregates multiple IPermissionProvider instances excluding itself (to avoid recursion). CompositePermissionProvider has Order -1, running before all others.
Resolution Flow
Section titled “Resolution Flow”Request arrives | vCachedPermissionResolver.ResolvePermissions() | +-- Cached? --> Return cached set | +-- ICacheStack enabled? --> Check ICacheStack (Permissions category) | | | +-- Cache hit? --> Return cached set | | | +-- Cache miss? --> Run provider chain, cache result | +-- No cross-request cache --> Run provider chain | vFor each IPermissionProvider (ordered by Order): Call ResolvePermissionsAsync(user) Merge into HashSet<string> (union) | vStore result as _resolvedPermissions (request-scoped) | vReturn IReadOnlySet<string>CachedPermissionResolver
Section titled “CachedPermissionResolver”CachedPermissionResolver implements IUserAuthorization and is the runtime bridge between the provider chain and permission checks. It is registered as Scoped (one per request).
Request-Scoped Caching
Section titled “Request-Scoped Caching”Permissions are resolved lazily on first access to Permissions, HasPermission, HasAnyPermission, or HasAllPermissions. The resolved set is stored in _resolvedPermissions and reused for all subsequent checks within the same request.
Sync vs Async Checks
Section titled “Sync vs Async Checks”CachedPermissionResolver also implements the async members of IUserAuthorization:
GetPermissionsAsync, HasPermissionAsync, HasAnyPermissionAsync, HasAllPermissionsAsync. On a
cross-request cache miss these await the cache/provider chain instead of blocking a thread-pool thread,
so prefer them on request hot paths. The synchronous HasPermission overloads remain available for the
common warm-cache case and for the ASP.NET authorization bridge.
Cross-Request Caching
Section titled “Cross-Request Caching”When UsePermissionCache is configured and ICacheStack is available in DI, the resolved permissions are cached cross-request via the CacheCategories.Permissions category.
Cache key format (explicit scope markers avoid cross-tenant collisions):
- Global / single-tenant:
{prefix}:g:u:{userId} - Multi-tenant:
{prefix}:t:{tenantId}:u:{userId}
Cache tags. The entry is tagged by everything that contributed to it:
user:{userId},tenant:{tenantId}(multi-tenant only)role:{roleName}for each role claim,group:{groupName}for each group claim
Cache Invalidation
Section titled “Cache Invalidation”UsePermissionCache registers IPermissionCacheInvalidator. Inject it and call the method matching the
change instead of assembling tag strings yourself:
public interface IPermissionCacheInvalidator{ ValueTask InvalidateUserAsync(string userId, CancellationToken ct = default); ValueTask InvalidateTenantAsync(string tenantId, CancellationToken ct = default); ValueTask InvalidateRoleAsync(string roleName, CancellationToken ct = default); // reaches every holder of the role ValueTask InvalidateGroupAsync(string groupName, CancellationToken ct = default); // reaches every member of the group}// After changing a user's roles or disabling the account:await invalidator.InvalidateUserAsync(userId, ct);Because entries are tagged by their contributing roles and groups, InvalidateRoleAsync /
InvalidateGroupAsync reach all affected users without enumerating them.
Consistency Strategy
Section titled “Consistency Strategy”PermissionCacheOptions.Strategy (a PermissionCacheStrategy) selects how the cache stays consistent:
| Strategy | Behavior |
|---|---|
TimeToLive (default) |
The entry expires after Expiration and is re-resolved. A short expiration gives periodic re-validation; a long one favors throughput. Calling the invalidator is optional. |
ManualInvalidation |
The entry is kept until IPermissionCacheInvalidator evicts it; call it on every authorization change for immediate propagation. Expiration still applies as a safety-net upper bound. |
authz.UsePermissionCache(o =>{ o.Expiration = TimeSpan.FromMinutes(30); o.Strategy = PermissionCacheStrategy.ManualInvalidation;});Permission Matching
Section titled “Permission Matching”HasPermission(string required) checks in two steps:
- Fast path: Exact match –
resolved.Contains(required). This covers the most common case. - Slow path: Wildcard match – for each resolved permission that contains
*, checksWildcardMatcher.Matches(granted, required).
// Example: user has ["booking.*"]// HasPermission("booking.reservation.read")// Step 1: "booking.reservation.read" not in set (set contains "booking.*") -> false// Step 2: "booking.*" contains '*' -> WildcardMatcher.Matches("booking.*", "booking.reservation.read") -> trueClaims-Based Properties
Section titled “Claims-Based Properties”CachedPermissionResolver also exposes claims-based properties:
| Property | Claim type | Fallback |
|---|---|---|
Roles |
role |
Empty array |
Groups |
group |
Empty array |
Scopes |
scope |
Empty array |
These are read directly from ICurrentUser.Claims without provider chain resolution.
WildcardMatcher
Section titled “WildcardMatcher”WildcardMatcher is an internal static class that handles wildcard pattern matching for permissions. All matching is case-insensitive.
Matching Algorithm
Section titled “Matching Algorithm”Matches(string pattern, string permission) has three paths:
- Exact match:
string.Equals(pattern, permission, OrdinalIgnoreCase) - Global wildcard:
pattern == "*"matches everything - Suffix wildcard: Pattern like
"booking.*"(ends with.*, no*.inside) – checks if permission starts with the prefix ("booking.") - Segment wildcard: Pattern like
"booking.*.read"or"*.reservation.read"– splits both strings on.and compares segment by segment, with*matching exactly one segment
Segment Matching Rules
Section titled “Segment Matching Rules”When patterns and permissions have the same number of segments, each * matches any single segment:
Pattern: booking.*.readPermission: booking.reservation.readSegments: [booking, *, read] vs [booking, reservation, read]Match: booking=booking, *=reservation, read=read -> TRUEWhen the pattern has a trailing * and fewer segments, it matches all remaining:
Pattern: booking.*Permission: booking.reservation.readTrailing * detected -> check prefix: booking=booking -> TRUEWhen segment counts differ and there is no trailing *, it does not match:
Pattern: booking.*.readPermission: booking.reservationSegments: 3 vs 2, no trailing * -> FALSEExpandWildcards
Section titled “ExpandWildcards”ExpandWildcards(patterns, allKnownPermissions) expands wildcard patterns against a known permission set. This is used for admin tooling (catalog) and WithoutPermissions validation.
var patterns = new[] { "booking.*", "catalog.amenity.read" };var allKnown = new HashSet<string>{ "booking.reservation.read", "booking.reservation.create", "booking.guest.read", "catalog.amenity.read", "catalog.property.read"};
var expanded = WildcardMatcher.ExpandWildcards(patterns, allKnown);// Result: { "booking.reservation.read", "booking.reservation.create",// "booking.guest.read", "catalog.amenity.read" }Observability
Section titled “Observability”CachedPermissionResolver emits metrics via AuthorizationDiagnostics:
| Event | Counter |
|---|---|
| Permission check called | permission_checks +1 |
| Permission check denied | permission_denied +1 |
| Resolved set reused within request | cache_hits +1 |
| Resolved set computed (provider chain ran) | cache_misses +1 |
Performance Characteristics
Section titled “Performance Characteristics”| Operation | Complexity |
|---|---|
| First permission check (cold) | O(P * R) where P = providers, R = roles/groups per user |
| Subsequent checks (warm) | O(1) exact match, O(N) wildcard scan where N = resolved permissions |
| Cross-request cache hit | O(1) ICacheStack lookup |
| Wildcard suffix match | O(1) string prefix check |
| Wildcard segment match | O(S) where S = number of segments |
In practice, the resolved permission set is small (tens to low hundreds), making the wildcard scan negligible.
Configuration Reference
Section titled “Configuration Reference”AuthorizationOptions
Section titled “AuthorizationOptions”| Property | Type | Default | Description |
|---|---|---|---|
EnablePermissionCaching |
bool |
true |
Enable request-scoped caching |
PermissionClaimType |
string |
"permission" |
Claim type for direct permissions |
CacheOptions |
PermissionCacheOptions? |
null |
Cross-request cache settings (null = disabled) |
PermissionCacheOptions
Section titled “PermissionCacheOptions”| Property | Type | Default | Description |
|---|---|---|---|
Expiration |
TimeSpan |
5 minutes | Cache entry TTL (safety-net upper bound under ManualInvalidation) |
KeyPrefix |
string |
"permissions" |
Prefix for cache keys |
Strategy |
PermissionCacheStrategy |
TimeToLive |
TimeToLive or ManualInvalidation (see Cache Invalidation) |
