Skip to content

Permission Resolution

This document explains how Pragmatic.Authorization resolves permissions at runtime: the provider chain, caching layers, and wildcard matching internals.

Permission resolution is a three-phase process:

  1. Provider chain — Multiple IPermissionProvider implementations run in order, each contributing permissions.
  2. Caching — Results are cached per request (always) and optionally cross-request via ICacheStack (using CacheCategories.Permissions).
  3. MatchingWildcardMatcher checks if any resolved permission matches the required one.

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);
}
ProviderOrderSourceRegistered when
ClaimsPermissionProvider0ICurrentUser.Claims["permission"]Always
RoleExpansionProvider100IRolePermissionStoreRoles are mapped or custom store registered
GroupExpansionProvider200IGroupRoleStore + IRolePermissionStoreGroups are mapped or custom store registered

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" }

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" }

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" }

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.

Request arrives
|
v
CachedPermissionResolver.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
|
v
For each IPermissionProvider (ordered by Order):
Call ResolvePermissionsAsync(user)
Merge into HashSet<string> (union)
|
v
Store result as _resolvedPermissions (request-scoped)
|
v
Return IReadOnlySet<string>

CachedPermissionResolver implements IUserAuthorization and is the runtime bridge between the provider chain and permission checks. It is registered as Scoped (one per request).

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.

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 permissions
await cacheStack.InvalidateByTagAsync($"user:{userId}");

HasPermission(string required) checks in two steps:

  1. Fast path: Exact match — resolved.Contains(required). This covers the most common case.
  2. Slow path: Wildcard match — for each resolved permission that contains *, checks WildcardMatcher.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") -> true

CachedPermissionResolver also exposes claims-based properties:

PropertyClaim typeFallback
RolesroleEmpty array
GroupsgroupEmpty array
ScopesscopeEmpty array

These are read directly from ICurrentUser.Claims without provider chain resolution.

WildcardMatcher is an internal static class that handles wildcard pattern matching for permissions. All matching is case-insensitive.

Matches(string pattern, string permission) has three paths:

  1. Exact match: string.Equals(pattern, permission, OrdinalIgnoreCase)
  2. Global wildcard: pattern == "*" matches everything
  3. Suffix wildcard: Pattern like "booking.*" (ends with .*, no *. inside) — checks if permission starts with the prefix ("booking.")
  4. Segment wildcard: Pattern like "booking.*.read" or "*.reservation.read" — splits both strings on . and compares segment by segment, with * matching exactly one segment

When patterns and permissions have the same number of segments, each * matches any single segment:

Pattern: booking.*.read
Permission: booking.reservation.read
Segments: [booking, *, read] vs [booking, reservation, read]
Match: booking=booking, *=reservation, read=read -> TRUE

When the pattern has a trailing * and fewer segments, it matches all remaining:

Pattern: booking.*
Permission: booking.reservation.read
Trailing * detected -> check prefix: booking=booking -> TRUE

When segment counts differ and there is no trailing *, it does not match:

Pattern: booking.*.read
Permission: booking.reservation
Segments: 3 vs 2, no trailing * -> FALSE

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" }

CachedPermissionResolver emits metrics via AuthorizationDiagnostics:

EventCounter
Permission check calledpermission_checks +1
Permission check deniedpermission_denied +1
Resolved set reused within requestcache_hits +1
Resolved set computed (provider chain ran)cache_misses +1
OperationComplexity
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 hitO(1) ICacheStack lookup
Wildcard suffix matchO(1) string prefix check
Wildcard segment matchO(S) where S = number of segments

In practice, the resolved permission set is small (tens to low hundreds), making the wildcard scan negligible.

PropertyTypeDefaultDescription
EnablePermissionCachingbooltrueEnable request-scoped caching
PermissionClaimTypestring"permission"Claim type for direct permissions
CacheOptionsPermissionCacheOptions?nullCross-request cache settings (null = disabled)
DefaultEndpointPolicyDefaultEndpointPolicyAllowAnonymousDefault endpoint authorization mode
PropertyTypeDefaultDescription
ExpirationTimeSpan5 minutesCache entry TTL
KeyPrefixstring"permissions"Prefix for cache keys