Skip to content

Architecture and Core Concepts

This guide explains why Pragmatic.Authorization exists, how its pieces fit together, and how to choose the right abstraction for each situation. Read this before diving into the individual feature guides.


Authorization in most .NET applications starts simple and deteriorates into a fragile web of scattered checks that nobody trusts.

Scattered permission checks, no single source of truth

Section titled “Scattered permission checks, no single source of truth”
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
public class ReservationsController : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Search(CancellationToken ct)
{
// Check 1: manual permission string, duplicated across controllers
if (!User.HasClaim("permission", "booking.reservation.read"))
return Forbid();
var result = await _reservations.SearchAsync(ct);
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateRequest request, CancellationToken ct)
{
// Check 2: different pattern, same intent
var isAdmin = User.IsInRole("admin");
var hasPermission = User.HasClaim("permission", "booking.reservation.create");
if (!isAdmin && !hasPermission)
return Forbid();
// Check 3: instance-level check buried in business logic
if (request.CustomerId != GetCurrentUserId() && !isAdmin)
return Forbid();
var reservation = await _reservations.CreateAsync(request, ct);
return CreatedAtAction(nameof(GetById), new { id = reservation.Id }, reservation);
}
[HttpPost("{id}/cancel")]
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
{
// Check 4: yet another pattern for the same system
var permissions = User.Claims
.Where(c => c.Type == "permission")
.Select(c => c.Value)
.ToHashSet();
if (!permissions.Contains("booking.reservation.cancel")
&& !permissions.Any(p => p == "booking.reservation.*")
&& !permissions.Contains("*"))
return Forbid();
await _reservations.CancelAsync(id, ct);
return NoContent();
}
}

Four endpoints, four different authorization patterns. The same permission string "booking.reservation.read" is hardcoded in multiple files. Role-to-permission mapping lives in if-statements. Wildcard handling is hand-rolled and incomplete. When someone asks “what permissions does the booking-manager role have?”, the answer requires reading every controller in the project.

SymptomRoot cause
Permission strings drift across filesNo single source of truth for permission names
Admin bypass logic is inconsistentRole-to-permission mapping is ad-hoc, not declarative
”Can user X do Y on resource Z?” requires reading codeInstance-level authorization is mixed into business logic
Wildcard matching has edge-case bugsEach developer implements * support differently
Token contains permissions that expand over timeNo group/role expansion — permissions are flat in the JWT
Permission changes require code deploymentRoles and permissions are hardcoded, not configurable
No audit trail for permission evaluationsAuthorization checks are invisible to observability

The fundamental issue: authorization decisions are made in business code, scattered across the codebase, with no enforced structure, no compile-time safety, and no runtime observability.


Pragmatic.Authorization inverts the model. You declare what is protected and how — the framework handles resolution, matching, caching, and enforcement.

The same reservation authorization, the Pragmatic way:

// 1. Permissions are generated constants (compile-time safe)
[RequirePermission(BookingPermissions.Reservation.Read)]
public sealed class SearchReservationsQuery : IQuery<PagedResult<ReservationDto>>
{
// query properties...
}
[RequirePermission(BookingPermissions.Reservation.Create)]
public sealed class CreateReservationMutation : Mutation<Reservation>
{
// mutation properties...
}
[RequirePermission(BookingPermissions.Reservation.Cancel)]
[RequirePolicy<ReservationCancellationPolicy>]
public sealed class CancelReservationAction : VoidDomainAction
{
// action properties...
}
// 2. Roles compose from module definitions (host-level, declarative)
await PragmaticApp.RunAsync(args, app =>
{
app.UseAuthorization(authz =>
{
authz.DefaultPolicy = DefaultEndpointPolicy.RequireAuthenticated;
authz.MapRole<AdminRole>();
authz.MapRole<BookingManagerRole>(r => r
.IncludeDefinition<BookingOperator>()
.IncludeDefinition<CatalogReader>());
authz.MapGroup<CustomerCareGroup>();
authz.UsePermissionCache(TimeSpan.FromMinutes(5));
});
});

The source generator produces:

  • BookingPermissions.Reservation.Read, .Create, .Update, .Delete, .All — compile-time constants
  • PermissionRegistry — a complete list of all known permissions for admin tooling
  • SeedFromJson() if roles.pragmatic.json exists — role definitions from config, not code

At runtime:

  • The provider chain resolves user claims to a flat permission set (claims, roles, groups)
  • WildcardMatcher handles * patterns consistently
  • CachedPermissionResolver caches the resolved set per request (and optionally cross-request)
  • PolicyEvaluationFilter evaluates composable policies before the action runs (Order 210)
  • ResourceAuthorizationFilter handles instance-level checks (Order 250)
  • AuthorizationDiagnostics emits OTel counters for every check

No reflection at check time. No manual wildcard code. No permission strings scattered across business logic.


Every request flows through four layers of authorization, each progressively more specific. The framework runs only the layers that are configured.

Request
|
v
L1 Authentication Who is the caller?
| ICurrentUser populated from JWT/cookie/headers
v
L2 Permission Gate Can the caller perform this operation?
| CachedPermissionResolver + WildcardMatcher
| Enforced by [RequirePermission] on action
v
L3 Resource Authorization Can the caller act on THIS resource?
| IResourceAuthorizer<T> + ResourceAuthorizationFilter (Order 250)
| [RequirePolicy<T>] + PolicyEvaluationFilter (Order 210)
v
L4 Data-Level Filtering Which rows can the caller see?
| IQueryFilter<T>, IPermissionBasedFilter<T>
v
Handler

L1 is handled by Pragmatic.Identity (authentication). L2 through L4 are Pragmatic.Authorization. Each layer is independent:

  • An endpoint with [RequirePermission] but no resource authorizer runs L1 + L2 only.
  • An endpoint with [RequirePolicy<T>] and an IResourceAuthorizer<T> runs L1 + L2 + L3.
  • A query with IQueryFilter<T> runs L1 + L2 + L4 (data filtering at the IQueryable level).

Permissions follow a three-segment naming convention: {boundary}.{entity}.{operation}.

booking.reservation.read -- Read reservations in the Booking boundary
billing.invoice.refund -- Custom operation: refund in Billing
catalog.amenity.update -- Update amenities in Catalog
L0: booking -- boundary namespace
L1: booking.reservation.read -- CRUD (SG-generated from entities)
L2: billing.invoice.refund -- custom operation (IPermission)
L3: IQueryFilter<T> -- data-level scope (row filtering)

L0 and L1 permissions are generated automatically by the SG when it detects entities in a module. L2 permissions are defined explicitly via IPermission or [RequirePermission("refund", Description = "...")]. L3 is enforced at the data layer.

For each entity detected in a module, the SG generates nested static classes:

// Generated: BookingPermissions
public static class BookingPermissions
{
public static class Reservation
{
public const string Read = "booking.reservation.read";
public const string Create = "booking.reservation.create";
public const string Update = "booking.reservation.update";
public const string Delete = "booking.reservation.delete";
public const string All = "booking.reservation.*";
}
public static class Guest
{
public const string Read = "booking.guest.read";
public const string Create = "booking.guest.create";
public const string Update = "booking.guest.update";
public const string Delete = "booking.guest.delete";
public const string All = "booking.guest.*";
}
}

Use these constants in [RequirePermission] attributes and IRoleDefinition.Permissions for compile-time safety. Typos become compile errors.

For operations beyond standard CRUD:

public sealed class RefundInvoicePermission : IPermission
{
public static string Name => "billing.invoice.refund";
public static string? Description => "Refund a paid invoice";
public static string? Category => "Billing";
}

The SG includes IPermission implementations in the generated PermissionRegistry, making them discoverable via IPermissionCatalog.

Applied to actions, mutations, and queries. Multiple permissions use AND logic (all required):

[RequirePermission("booking.reservation.read")]
public sealed class SearchReservationsQuery : IQuery<PagedResult<ReservationDto>>

The attribute supports two modes:

ModeUsageResult
Explicit name[RequirePermission("booking.reservation.read")]Uses the literal string
SG-derived[RequirePermission("refund", Description = "Refund a paid invoice")]SG derives full name from boundary.entity.operation context

When Description is set, the SG generates a constant in the appropriate {Boundary}Permissions class.


Pragmatic.Authorization distinguishes between module permission templates and application roles. This separation lets modules define what permissions exist without dictating how they are assembled into roles.

IRoleDefinition — Module Permission Templates

Section titled “IRoleDefinition — Module Permission Templates”

Modules define IRoleDefinition to expose reusable permission bundles. These live in the module project:

// In Showcase.Booking module
public sealed class BookingOperator : IRoleDefinition
{
public static string Name => "booking-operator";
public static string? Description => "Full CRUD on reservations and guests";
public static IReadOnlyList<string> Permissions =>
[
BookingPermissions.Reservation.All,
BookingPermissions.Guest.All,
BookingPermissions.GuestPreferences.All
];
}

IRoleDefinition is NOT an application role. It is a building block — a named set of permissions that the host can compose into application roles.

Application roles live in the host project and represent the actual roles that users are assigned:

// In the host project
public sealed class ShowcaseAdmin : IRole
{
public static string Name => "admin";
public static string? Description => "Full system access";
public static IReadOnlyList<string> DefaultPermissions => ["*"];
}
public sealed class BookingManagerRole : IRole
{
public static string Name => "booking-manager";
public static string? Description => "Full booking operations with catalog context";
public static IReadOnlyList<string> DefaultPermissions => [];
// Permissions come from IncludeDefinition<BookingOperator>() in Program.cs
}

Roles are mapped in Program.cs via AuthorizationBuilder:

authz.MapRole<AdminRole>();
authz.MapRole<BookingManagerRole>(r => r
.IncludeDefinition<BookingOperator>()
.IncludeDefinition<CatalogReader>());
authz.MapRole<ReceptionistRole>(r => r
.WithPermissions(
BookingPermissions.Reservation.Read,
BookingPermissions.Reservation.Create)
.IncludeDefinition<CatalogReader>());
authz.MapRole("auditor", r => r
.WithOperation<BookingBoundary>(CrudOperation.Read)
.WithOperation<BillingBoundary>(CrudOperation.Read));
AspectIRoleIRoleDefinition
Defined inHost projectModule project
PurposeApplication role (mapped to user claim)Permission template (building block)
Used inauthz.MapRole<T>()roleBuilder.IncludeDefinition<T>()
At runtimeResolved by RoleExpansionProvider via role claimNot resolved directly — feeds into roles
NamingApplication-specific (“admin”, “booking-manager”)Module-specific (“booking-operator”, “catalog-reader”)

RoleBuilder provides fluent configuration when mapping roles:

MethodDescription
WithPermissions(params string[])Adds explicit permissions
IncludeDefinition<T>()Includes all permissions from an IRoleDefinition
WithAllPermissions()Grants * (all permissions)
WithAllPermissions<TBoundary>()Grants {boundary}.*
WithOperation<TBoundary>(CrudOperation)Grants {boundary}.*.{operation}
WithoutPermissions(params string[])Removes specific permissions (fails fast if covered by wildcard)
ClearDefaults()Clears IRole.DefaultPermissions

Resolution order:

  1. Start with IRole.DefaultPermissions (unless ClearDefaults() was called)
  2. Add all permissions from WithPermissions() and IncludeDefinition<T>()
  3. Remove permissions from WithoutPermissions() (with wildcard safety check)

Groups aggregate roles. A user with a group claim gets all the roles (and their permissions) assigned to that group.

public sealed class CustomerCareGroup : IGroup
{
public static string Name => "customer-care";
public static string? Description => "Customer care team";
public static IReadOnlyList<string> DefaultRoles =>
[
BookingManagerRole.Name,
CatalogViewerRole.Name
];
}
authz.MapGroup<CustomerCareGroup>();
authz.MapGroup<ExtendedGroup>(g => g
.WithRoles("extra-role")
.WithRole<AnotherRole>());
authz.MapGroup("operations", g => g.WithRoles("front-desk", "catalog-viewer"));
MethodDescription
WithRoles(params string[])Adds roles by name
WithRole<TRole>()Adds a strongly-typed role

When a user has group claim "customer-care":

GroupExpansionProvider (Order 200):
"customer-care" --> IGroupRoleStore --> ["booking-manager", "catalog-viewer"]
For each role:
"booking-manager" --> IRolePermissionStore --> ["booking.reservation.*", "booking.guest.*"]
"catalog-viewer" --> IRolePermissionStore --> ["catalog.amenity.read", "catalog.property.read"]
Merged result: { "booking.reservation.*", "booking.guest.*",
"catalog.amenity.read", "catalog.property.read" }

Permission resolution runs through an ordered chain of IPermissionProvider implementations. Each provider contributes permissions from a different source. Results are merged with union semantics — 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
RoleExpansionProvider100IRolePermissionStore via role claimsRoles are mapped or custom store registered
GroupExpansionProvider200IGroupRoleStore + IRolePermissionStore via group claimsGroups 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" }

This provider is always registered. It enables direct permission assignment via JWT claims.

For each "role" claim, queries IRolePermissionStore.GetPermissionsForRoleAsync(roleName):

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, resolves groups to roles, then roles to permissions:

User claims: { "group": ["customer-care"] }
IGroupRoleStore: "customer-care" --> { "booking-manager", "catalog-viewer" }
IRolePermissionStore: "booking-manager" --> { "booking.reservation.*", "booking.guest.*" }
IRolePermissionStore: "catalog-viewer" --> { "catalog.amenity.read" }
Result: HashSet { "booking.reservation.*", "booking.guest.*", "catalog.amenity.read" }

Register additional providers that resolve permissions from external sources:

authz.AddPermissionProvider<ExternalPermissionProvider>();

Custom providers participate in the same chain. Set the Order property to control execution sequence.

Request arrives
|
v
CachedPermissionResolver.ResolvePermissions()
|
+-- Already resolved this request? --> Return cached set (cache_hits +1)
|
+-- Cross-request cache enabled? --> Check ICacheStack
| |
| +-- Cache hit? --> Return cached set
| |
| +-- Cache miss? --> Run provider chain, cache result
|
+-- No cross-request cache --> Run provider chain (cache_misses +1)
|
v
For each IPermissionProvider (ordered by Order):
Call ResolvePermissionsAsync(user)
Merge into HashSet<string> (union)
|
v
Store as _resolvedPermissions (request-scoped)
|
v
Return IReadOnlySet<string>

Beyond permission gates (“can the user perform this operation?”), some operations need instance-level authorization (“can the user act on THIS specific resource?”).

public interface IResourceAuthorizer<in TResource>
{
ValueTask<bool> CanAccessAsync(
ICurrentUser user, TResource resource, string action,
CancellationToken ct = default);
}

The interface is contravariant on TResource, so IResourceAuthorizer<DomainAction<OrderDto>> can be used for any action that derives from DomainAction<OrderDto>.

public sealed class InvoiceAuthorizer : IResourceAuthorizer<RefundInvoiceAction>
{
public ValueTask<bool> CanAccessAsync(
ICurrentUser user, RefundInvoiceAction resource, string action,
CancellationToken ct = default)
{
// Billing admin can refund anything
if (user.Authorization.HasPermission("billing.admin"))
return ValueTask.FromResult(true);
// Must be authenticated with the refund permission
if (!user.IsAuthenticated)
return ValueTask.FromResult(false);
return ValueTask.FromResult(
user.Authorization.HasPermission("billing.invoice.refund"));
}
}
authz.AddResourceAuthorizer<InvoiceAuthorizer>();

AddResourceAuthorizer discovers all IResourceAuthorizer<T> interfaces on the type and registers each in DI as Scoped.

ResourceAuthorizationFilter (Order 250 in the action pipeline) resolves IResourceAuthorizer<TAction> from DI and calls CanAccessAsync. If the authorizer returns false, the filter returns ForbiddenError. If no authorizer is registered for the action type, the filter is a no-op.


For authorization rules more complex than a single permission check, ResourcePolicy provides composable, declarative rules. Analogous to Specification<T> but for authorization decisions.

MethodDescription
ResourcePolicy.AllowAlways allows (identity for OR)
ResourcePolicy.DenyAlways denies (identity for AND)
ResourcePolicy.RequirePermission(string)Single permission check
ResourcePolicy.RequireAnyPermission(params string[])Any of the permissions (OR)
ResourcePolicy.RequireAllPermissions(params string[])All of the permissions (AND)
ResourcePolicy.InRole(string)Role membership check
ResourcePolicy.InGroup(string)Group membership check
ResourcePolicy.HasClaim(string, string?)Claim existence / value check
ResourcePolicy.IsAuthenticated()Authentication check (singleton)
ResourcePolicy.HasPrincipalKind(PrincipalKind)Principal kind check
ResourcePolicy.Custom(Func<ICurrentUser, bool>)Delegate-based (not serializable)

Policies compose with & (AND), | (OR), and ! (NOT):

var policy = ResourcePolicy.RequirePermission("booking.reservation.create")
& ResourcePolicy.IsAuthenticated()
| ResourcePolicy.HasPrincipalKind(PrincipalKind.Service);
bool allowed = policy.Evaluate(currentUser);

C# operator precedence applies: & binds tighter than |. Use parentheses for clarity.

public sealed class ReservationManagementPolicy : ResourcePolicy
{
public override bool Evaluate(ICurrentUser user)
{
var isService = HasPrincipalKind(PrincipalKind.Service);
var hasPermission = IsAuthenticated()
& RequirePermission(BookingPermissions.Reservation.Create);
return (isService | hasPermission).Evaluate(user);
}
}

Use the [RequirePolicy<T>] attribute:

[RequirePolicy<ReservationManagementPolicy>]
public sealed class CreateReservationMutation : Mutation<Reservation>

Requirements:

  • The policy type must have a parameterless constructor.
  • The instance is created once and cached by PolicyEvaluationFilter.
  • Evaluated at Order 210 in the action pipeline.
  • Denial returns 403 Forbidden.

For policies requiring I/O (database lookups, external services):

var asyncPolicy = AsyncResourcePolicy.RequireExternalPermission(
async (user, ct) => await externalService.CheckPermissionAsync(user.Id, ct));

Sync policies convert implicitly to async via SyncToAsyncPolicy. Async composition supports short-circuit evaluation: AND returns false immediately if the left side is false, OR returns true immediately if the left side is true.

PolicySerializer converts between ResourcePolicy and PolicyExpression for JSON storage:

var policy = ResourcePolicy.RequirePermission("orders.read")
& ResourcePolicy.IsAuthenticated();
PolicyExpression expr = PolicySerializer.Serialize(policy);
// Store expr as JSON in a database
ResourcePolicy restored = PolicySerializer.Deserialize(expr);
bool result = restored.Evaluate(currentUser);

Custom() and RequireExternalPermission() delegate-based policies are not serializable and throw NotSupportedException.


WildcardMatcher provides consistent wildcard pattern matching for permissions. All matching is case-insensitive.

PatternMatchesUse case
*All permissionsSuper admin
booking.*All permissions starting with booking.Boundary admin
booking.reservation.*All reservation operationsEntity admin
booking.*.readRead on all entities in bookingBoundary reader
*.reservation.readRead Reservation across all boundariesCross-boundary reader

WildcardMatcher.Matches(pattern, permission) resolves in this order:

  1. Exact match: string.Equals(pattern, permission, OrdinalIgnoreCase) — returns true
  2. Global wildcard: pattern == "*" — returns true for any permission
  3. Suffix wildcard: Pattern like "booking.*" (ends with .*, no *. inside) — checks permission.StartsWith("booking.")
  4. Segment wildcard: Pattern like "booking.*.read" — splits on . and compares segment by segment, with * matching exactly one segment

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

Pattern: booking.*.read
Permission: booking.reservation.read
Match: booking=booking, *=reservation, read=read --> TRUE

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

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

When segment counts differ and there is no trailing *:

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

CachedPermissionResolver.MatchesPermission(required) runs two steps:

  1. Fast path: Exact match — resolved.Contains(required). Covers the most common case.
  2. Slow path: For each resolved permission that contains *, calls WildcardMatcher.Matches(granted, required).

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


Permission resolution has two caching layers.

CachedPermissionResolver 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.

This means an endpoint that calls HasPermission three times triggers the provider chain only once.

When UsePermissionCache is configured and ICacheStack is available in DI:

authz.UsePermissionCache(TimeSpan.FromMinutes(5));

The resolved permission set is cached cross-request via ICacheStack.

Cache key format:

  • Single-tenant: {prefix}:{userId}
  • Multi-tenant: {prefix}:{tenantId}:{userId}

Cache tags: ["user:{userId}"] for targeted invalidation:

// Invalidate a specific user's cached permissions (e.g., after role change)
await cacheStack.InvalidateByTagAsync($"user:{userId}");
PropertyTypeDefaultDescription
ExpirationTimeSpan5 minutesCache entry TTL
KeyPrefixstring"permissions"Prefix for cache keys

The source generator detects Pragmatic.Authorization via FeatureDetector and generates the following when IPermission, IRole, or [RequirePermission] types are present.

One nested static class per boundary, containing constants for each entity:

// Generated: BookingPermissions
public static class BookingPermissions
{
public static class Reservation
{
public const string Read = "booking.reservation.read";
public const string Create = "booking.reservation.create";
public const string Update = "booking.reservation.update";
public const string Delete = "booking.reservation.delete";
public const string All = "booking.reservation.*";
}
}

Constants are grouped by the first segment (boundary) of the permission name. The SG derives boundary names from IPermission.Name by extracting the first dot-delimited segment.

A unified registry of all known permissions and roles in the assembly:

// Generated: PermissionRegistry
public static class PermissionRegistry
{
public static IReadOnlyList<PermissionInfo> AllPermissions { get; } = [
new("booking.reservation.read", "Read Reservation", "Booking"),
new("booking.reservation.create", "Create Reservation", "Booking"),
// ...
];
public static IReadOnlyList<RoleInfo> AllRoles { get; } = [
new("admin", "Full system access", ["*"]),
// ...
];
}

The registry feeds IPermissionCatalog for admin tooling and inspection.

When [RequirePermission("refund", Description = "Refund a paid invoice")] is used on an action, the SG generates a constant in the boundary’s permission class:

// Generated: partial class BillingPermissions
public static partial class BillingPermissions
{
public static partial class Invoice
{
/// <summary>Refund a paid invoice</summary>
public const string Refund = "billing.invoice.refund";
}
}

When roles.pragmatic.json exists as an AdditionalFile:

// Generated: RoleSeedingExtensions
public static class RoleSeedingExtensions
{
public static AuthorizationBuilder SeedFromJson(this AuthorizationBuilder builder)
{
builder.MapRole("front-desk", r => r.WithPermissions(
"booking.reservation.read", "booking.reservation.create", "booking.guest.read"));
builder.MapGroup("operations", g => g.WithRoles("front-desk", "catalog-viewer"));
return builder;
}
}

Role inheritance ("inherits" in JSON) is expanded at compile-time. The generated code contains flattened permission sets.


Stores provide the backing data for role-to-permission and group-to-role mappings.

StorePopulated byLifetime
InMemoryRolePermissionStoreMapRole, SeedFromJsonSingleton
InMemoryGroupRoleStoreMapGroup, SeedFromJsonSingleton

Thread-safe via ConcurrentDictionary. Case-insensitive lookup.

Override with database-backed implementations:

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

Custom stores are registered as Scoped. When registered, they replace the in-memory store (the in-memory store is not registered).

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

Same logic applies for groups. When groups are mapped but no role store exists, an empty InMemoryRolePermissionStore is auto-registered so GroupExpansionProvider does not fail.

InterfaceExtendsPurpose
ITemporalRolePermissionStoreIRolePermissionStorePoint-in-time permission resolution
ITenantRolePermissionStoreIRolePermissionStoreTenant-specific permissions
ITemporalGroupRoleStoreIGroupRoleStorePoint-in-time group resolution
ITenantGroupRoleStoreIGroupRoleStoreTenant-specific groups
InterfacePurpose
IDynamicPermissionStoreRuntime-created permissions
IDynamicRoleStoreRuntime-created roles
IResourcePolicyStoreDynamic policy-to-resource assignments

Consumed by the catalog system and the optional Pragmatic.Authorization.Management package.


IPermissionCatalog and IResourceCatalog are discovery APIs for admin tooling and inspection. They merge static (SG-generated) and dynamic (database-backed) sources.

public interface IPermissionCatalog
{
ValueTask<IReadOnlyList<PermissionInfo>> GetAllPermissionsAsync(CancellationToken ct = default);
ValueTask<IReadOnlyList<RoleInfo>> GetAllRolesAsync(CancellationToken ct = default);
ValueTask<IReadOnlySet<string>> GetEffectivePermissionsAsync(
string userId, CancellationToken ct = default);
}

DefaultPermissionCatalog merges the SG-generated PermissionRegistry with IDynamicPermissionStore (if registered).

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

DefaultResourceCatalog merges static resources with IResourcePolicyStore (if registered).

public sealed record ProtectedResource(
string ResourceType, // "Endpoint", "Action", "Mutation"
string Identifier, // route pattern or action FQN
string DisplayName,
string? Category)
{
public IReadOnlyList<string> StaticPermissions { get; init; } = [];
public PolicyExpression? PolicyExpression { get; init; }
public string? ResourceGroup { get; init; }
}

AuthorizationDiagnostics provides OTel-compatible instrumentation:

InstrumentNameDescription
ActivitySourcePragmatic.AuthorizationDistributed tracing
Counter<long>pragmatic.authorization.permission_checksTotal permission checks
Counter<long>pragmatic.authorization.permission_deniedDenied permission checks
Counter<long>pragmatic.authorization.cache_hitsCache hits (resolved set already available)
Counter<long>pragmatic.authorization.cache_missesCache misses (full provider chain invoked)

Use these metrics to monitor:

  • Denial rate: High permission_denied / permission_checks ratio may indicate misconfigured roles.
  • Cache hit rate: Low cache_hits / (cache_hits + cache_misses) suggests caching is not effective (check TTL or whether most requests are unauthenticated).

ICurrentUser provides the claims that feed the provider chain:

Claim typeUsed by
permissionClaimsPermissionProvider (direct permissions)
roleRoleExpansionProvider (expanded to permissions)
groupGroupExpansionProvider (expanded to roles, then permissions)
scopeIUserAuthorization.Scopes

IUserAuthorization is accessed via ICurrentUser.Authorization, providing HasPermission, IsInRole, IsInGroup, and HasScope methods.

The action pipeline integrates authorization at two points:

FilterOrderPurpose
PolicyEvaluationFilter210Evaluates [RequirePolicy<T>]
ResourceAuthorizationFilter250Calls IResourceAuthorizer<T>.CanAccessAsync

Both filters skip evaluation for internal calls (checked via ActionCallContext.IsInternalCall).

[RequirePermission] and [RequirePolicy<T>] on endpoint classes attach authorization metadata to the generated endpoint. The SG generates .RequireAuthorization() calls with the appropriate policy.

[AllowAnonymous] overrides group-level or default authorization.

DefaultEndpointPolicy controls endpoints without explicit authorization attributes:

ValueBehavior
AllowAnonymousNo authorization required (default)
RequireAuthenticatedRequires a valid identity
RequirePermissionSG-derived CRUD permission based on HTTP verb and entity

Data-level authorization integrates via IQueryFilter<T> and IPermissionBasedFilter<T>. These are separate from Pragmatic.Authorization but consume IUserAuthorization for permission decisions at the row level.

Cross-request permission caching uses ICacheStack from Pragmatic.Caching. The cache key includes the user ID (and tenant ID if multi-tenant). Tags enable targeted invalidation.


UseAuthorization / AddPragmaticAuthorization registers:

RegistrationLifetimeNotes
IPermissionProvider (ClaimsPermissionProvider)ScopedAlways registered
IPermissionProvider (RoleExpansionProvider)ScopedWhen roles are mapped or custom store used
IPermissionProvider (GroupExpansionProvider)ScopedWhen groups are mapped or custom store used
IRolePermissionStore (InMemoryRolePermissionStore)SingletonWhen MapRole used without custom store
IGroupRoleStore (InMemoryGroupRoleStore)SingletonWhen MapGroup used without custom store
IUserAuthorization (CachedPermissionResolver)ScopedAlways registered
IPermissionCatalog (DefaultPermissionCatalog)SingletonTryAdd (SG-generated takes precedence)
IResourceCatalog (DefaultResourceCatalog)SingletonTryAdd (SG-generated takes precedence)
AuthorizationOptionsOptionsVia IOptions<AuthorizationOptions>

The roles.pragmatic.json file provides declarative role and group definitions without code:

{
"roles": {
"front-desk": {
"description": "Front desk staff with basic booking access",
"permissions": ["booking.reservation.read", "booking.reservation.create", "booking.guest.read"],
"inherits": ["catalog-viewer"]
},
"revenue-manager": {
"description": "Revenue management with full catalog and rate access",
"permissions": ["catalog.*", "booking.reservation.read"]
}
},
"groups": {
"operations": {
"description": "All operational staff",
"roles": ["front-desk", "catalog-viewer"]
}
}
}

Role inheritance (inherits) is expanded at compile-time by the SG. Circular references are detected. The generated SeedFromJson() contains flattened permission sets.

Usage:

authz.SeedFromJson(); // Calls generated RoleSeedingExtensions.SeedFromJson()

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. The request-scoped cache ensures the provider chain runs at most once per request.