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.
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:
- Single-tenant:
{prefix}:{userId} - Multi-tenant:
{prefix}:{tenantId}:{userId}
Cache tags: ["user:{userId}"] (for targeted invalidation).
// Example: invalidate a specific user's cached permissionsawait cacheStack.InvalidateByTagAsync($"user:{userId}");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) |
DefaultEndpointPolicy | DefaultEndpointPolicy | AllowAnonymous | Default endpoint authorization mode |
PermissionCacheOptions
Section titled “PermissionCacheOptions”| Property | Type | Default | Description |
|---|---|---|---|
Expiration | TimeSpan | 5 minutes | Cache entry TTL |
KeyPrefix | string | "permissions" | Prefix for cache keys |