Common Mistakes
These are the most common issues developers encounter when using Pragmatic.Authorization. Each section shows the wrong approach, the correct approach, and explains why.
1. Hardcoding Permission Strings Instead of Using Constants
Section titled “1. Hardcoding Permission Strings Instead of Using Constants”Wrong:
[RequirePermission("booking.reservation.read")]public sealed class SearchReservationsQuery : IQuery<PagedResult<ReservationDto>>{ // ...}
// Elsewhere in the codebase:[RequirePermission("booking.reservations.read")] // Typo: "reservations" vs "reservation"public sealed class GetReservationEndpoint : Endpoint<ReservationDto>{ // ...}Runtime result: The query works, but the endpoint never authorizes correctly because "booking.reservations.read" (plural) is a different permission from "booking.reservation.read" (singular). No compile-time error. No runtime warning. The user gets 403 Forbidden and nobody knows why.
Right:
[RequirePermission(BookingPermissions.Reservation.Read)]public sealed class SearchReservationsQuery : IQuery<PagedResult<ReservationDto>>{ // ...}
[RequirePermission(BookingPermissions.Reservation.Read)]public sealed class GetReservationEndpoint : Endpoint<ReservationDto>{ // ...}Why: The SG generates BookingPermissions.Reservation.Read as a const string. A typo in the constant name is a compile error. A typo in a string literal is a silent authorization failure.
2. Forgetting to Configure UseAuthorization in Program.cs
Section titled “2. Forgetting to Configure UseAuthorization in Program.cs”Wrong:
await PragmaticApp.RunAsync(args, app =>{ // No UseAuthorization call! app.UseAuthentication<JwtBearerHandler>("Bearer");});// In a module:[RequirePermission(BookingPermissions.Reservation.Read)]public sealed class SearchReservationsQuery : IQuery<PagedResult<ReservationDto>>{ // ...}Runtime result: When Pragmatic.Authorization is referenced, the SG auto-registers AddPragmaticAuthorization() with default settings. However, no roles are mapped and DefaultPolicy is AllowAnonymous. The [RequirePermission] attribute on the action is checked by the pipeline, but the user has no resolved permissions because no role store is configured. Authenticated users without direct permission claims get 403 on everything.
Right:
await PragmaticApp.RunAsync(args, app =>{ app.UseAuthentication<JwtBearerHandler>("Bearer"); app.UseAuthorization(authz => { authz.DefaultPolicy = DefaultEndpointPolicy.RequireAuthenticated; authz.MapRole<AdminRole>(); authz.MapRole<BookingManagerRole>(r => r .IncludeDefinition<BookingOperator>()); });});Why: Without MapRole, the RoleExpansionProvider is not registered. Users with role claims have no mechanism to resolve those roles into permissions. Always configure UseAuthorization with at least one role mapping or a custom IRolePermissionStore.
3. Wrong Permission Naming Convention
Section titled “3. Wrong Permission Naming Convention”Wrong:
public sealed class ReadReservationPermission : IPermission{ public static string Name => "ReadReservation"; public static string? Description => "Can read reservations"; public static string? Category => "Booking";}Compile result: PRAG1002 warning — “Permission name ‘ReadReservation’ doesn’t follow the recommended ‘boundary.entity.operation’ convention.”
Right:
public sealed class ReadReservationPermission : IPermission{ public static string Name => "booking.reservation.read"; public static string? Description => "Can read reservations"; public static string? Category => "Booking";}Why: The three-segment convention {boundary}.{entity}.{operation} is what the SG uses to generate BookingPermissions.Reservation.Read constants. It is also what RoleBuilder.WithAllPermissions<BookingBoundary>() uses to derive "booking.*" and RoleBuilder.WithOperation<BookingBoundary>(CrudOperation.Read) uses to derive "booking.*.read". Non-standard names break wildcard matching and role composition. Wildcard "booking.*" will not match "ReadReservation".
4. Not Configuring the Provider Chain for Groups
Section titled “4. Not Configuring the Provider Chain for Groups”Wrong:
app.UseAuthorization(authz =>{ authz.MapRole<AdminRole>(); authz.MapRole<BookingManagerRole>(r => r .IncludeDefinition<BookingOperator>()); // No MapGroup! But users have "group" claims...});Runtime result: Users with group claims (e.g., "group": ["customer-care"]) get no permissions from their groups. The GroupExpansionProvider is not registered because no groups were mapped and no custom IGroupRoleStore was configured. The group claim is silently ignored.
Right:
app.UseAuthorization(authz =>{ authz.MapRole<AdminRole>(); authz.MapRole<BookingManagerRole>(r => r .IncludeDefinition<BookingOperator>());
// Map the group so GroupExpansionProvider is registered authz.MapGroup<CustomerCareGroup>();});Why: The GroupExpansionProvider is only registered when MapGroup is called or a custom IGroupRoleStore is registered. Without it, group claims are not expanded to roles and permissions. If your identity provider includes group claims in the token, you must map those groups in UseAuthorization.
5. Resource Authorizer Without Filter Registration
Section titled “5. Resource Authorizer Without Filter Registration”Wrong:
public sealed class InvoiceAuthorizer : IResourceAuthorizer<RefundInvoiceAction>{ public ValueTask<bool> CanAccessAsync( ICurrentUser user, RefundInvoiceAction resource, string action, CancellationToken ct = default) { return ValueTask.FromResult( user.Authorization.HasPermission("billing.invoice.refund")); }}
// In Program.cs:app.UseAuthorization(authz =>{ authz.MapRole<AdminRole>(); // Forgot: authz.AddResourceAuthorizer<InvoiceAuthorizer>();});Runtime result: The InvoiceAuthorizer is never called. ResourceAuthorizationFilter (Order 250) resolves IResourceAuthorizer<RefundInvoiceAction> from DI. Since the authorizer is not registered, the filter resolves null and is a no-op. No error, no warning — the resource-level check is simply skipped.
Right:
app.UseAuthorization(authz =>{ authz.MapRole<AdminRole>(); authz.AddResourceAuthorizer<InvoiceAuthorizer>();});Why: AddResourceAuthorizer<T>() discovers all IResourceAuthorizer<T> interfaces on the type and registers each in DI. Without this registration, the filter cannot find the authorizer. Always register resource authorizers explicitly.
6. Checking Permissions Manually Instead of Via Attributes
Section titled “6. Checking Permissions Manually Instead of Via Attributes”Wrong:
[Endpoint(HttpVerb.Post, "/reservations")]public partial class CreateReservationEndpoint : Endpoint<ReservationDto>{ private ICurrentUser _currentUser = null!;
public override async Task<Result<ReservationDto>> HandleAsync(CancellationToken ct) { // Manual permission check in business logic if (!_currentUser.Authorization.HasPermission("booking.reservation.create")) return Result<ReservationDto>.Failure(ForbiddenError.ActionDenied("CreateReservation"));
// ... business logic }}Runtime result: Works, but the permission check is invisible to the framework. The OpenAPI spec does not show that the endpoint requires booking.reservation.create. The PermissionRegistry does not know this endpoint is protected. Admin tooling that reads IResourceCatalog cannot discover the permission requirement. The check is duplicated if multiple endpoints need the same permission.
Right:
[Endpoint(HttpVerb.Post, "/reservations")][RequirePermission(BookingPermissions.Reservation.Create)]public partial class CreateReservationEndpoint : Endpoint<ReservationDto>{ public override async Task<Result<ReservationDto>> HandleAsync(CancellationToken ct) { // No manual check needed -- the pipeline enforces it before HandleAsync runs // ... business logic }}Why: [RequirePermission] is declarative. The SG uses it to generate OpenAPI metadata, the action pipeline enforces it before your handler runs, and the PermissionRegistry makes it discoverable. Manual checks bypass all of these benefits.
Exception: Manual checks are appropriate for fine-grained, context-dependent decisions inside business logic (e.g., “if the user has billing.admin, show all invoices; otherwise, show only their own”). Use [RequirePermission] for the gate, and IUserAuthorization.HasPermission for branching logic.
7. Using WithoutPermissions with Wildcard Grants
Section titled “7. Using WithoutPermissions with Wildcard Grants”Wrong:
authz.MapRole<LimitedBookingRole>(r => r .WithPermissions("booking.*") .WithoutPermissions("booking.reservation.delete"));Startup result: InvalidOperationException at startup: “Cannot exclude ‘booking.reservation.delete’ — covered by wildcard ‘booking.*’. Use explicit permissions instead of wildcards when using WithoutPermissions.”
Right:
// Option A: Use explicit permissions instead of wildcardsauthz.MapRole<LimitedBookingRole>(r => r .WithPermissions( BookingPermissions.Reservation.Read, BookingPermissions.Reservation.Create, BookingPermissions.Reservation.Update, // Deliberately omitting .Delete BookingPermissions.Guest.All));
// Option B: Use IncludeDefinition and exclude from the explicit setauthz.MapRole<LimitedBookingRole>(r => r .IncludeDefinition<BookingOperator>() .WithoutPermissions("booking.reservation.delete"));// Works IF BookingOperator.Permissions uses explicit permissions,// not "booking.*" wildcardsWhy: WithoutPermissions performs exact-match removal from the permission list. A wildcard like "booking.*" is a single string in the set — removing "booking.reservation.delete" from a set that contains "booking.*" does not narrow the wildcard. At runtime, WildcardMatcher would still match "booking.reservation.delete" against "booking.*". The RoleBuilder detects this conflict and fails fast at startup to prevent silent authorization bypasses.
8. Not Invalidating Cache After Permission Changes
Section titled “8. Not Invalidating Cache After Permission Changes”Wrong:
// Admin endpoint: update user's rolepublic async Task<VoidResult<IError>> Execute(CancellationToken ct){ await _roleStore.UpdateUserRoleAsync(UserId, NewRole, ct); // Done! But... the user's cached permissions are stale return VoidResult<IError>.Success();}Runtime result: With UsePermissionCache(TimeSpan.FromMinutes(5)) configured, the user continues operating with their old permissions for up to 5 minutes after the role change. They might access resources they should no longer have access to, or be denied access to newly granted resources.
Right:
public async Task<VoidResult<IError>> Execute(CancellationToken ct){ await _roleStore.UpdateUserRoleAsync(UserId, NewRole, ct);
// Invalidate the user's cached permission set await _cacheStack.InvalidateByTagAsync($"user:{UserId}", ct);
return VoidResult<IError>.Success();}Why: Cross-request permission caching stores the resolved permission set keyed by user ID with tag "user:{userId}". When permissions change (role assignment, group membership, direct permission grant), you must invalidate the cache entry. The ICacheStack.InvalidateByTagAsync method removes all entries tagged with the user ID.
If you are not using cross-request caching (UsePermissionCache is not configured), this is not an issue — request-scoped caching resolves fresh on every request.
9. Wildcard Pattern Mistakes
Section titled “9. Wildcard Pattern Mistakes”9a. Mid-segment wildcards
Section titled “9a. Mid-segment wildcards”Wrong:
authz.MapRole<CustomRole>(r => r .WithPermissions("booking.reserv*.read"));Runtime result: The pattern "booking.reserv*.read" is treated as a literal string (not expanded). WildcardMatcher only recognizes * as a whole segment or trailing wildcard. The pattern "reserv*" does not match "reservation" — it matches literally "reserv*".
Right:
authz.MapRole<CustomRole>(r => r .WithPermissions("booking.reservation.read"));// Or use segment wildcard:authz.MapRole<CustomRole>(r => r .WithPermissions("booking.*.read"));9b. Double wildcards
Section titled “9b. Double wildcards”Wrong:
authz.MapRole<SuperRole>(r => r .WithPermissions("booking.*.*"));Runtime result: This works but may not do what you expect. "booking.*.*" matches only 3-segment permissions where the first segment is "booking". It does NOT match "booking.reservation.read.history" (4 segments). For “everything in booking”, use "booking.*" (trailing wildcard matches all remaining segments).
Right:
authz.MapRole<SuperRole>(r => r .WithPermissions("booking.*"));// "booking.*" matches "booking.reservation.read",// "booking.guest.create", etc. (any depth)9c. Case sensitivity assumptions
Section titled “9c. Case sensitivity assumptions”Wrong:
// Assuming case-sensitive matchingif (user.Authorization.HasPermission("Booking.Reservation.Read")) // ...Runtime result: This works correctly. All permission matching in Pragmatic.Authorization is case-insensitive (StringComparer.OrdinalIgnoreCase). However, the convention is lowercase with dots, and the SG generates lowercase constants. Use the generated constants to avoid any ambiguity.
10. Policy Without Parameterless Constructor
Section titled “10. Policy Without Parameterless Constructor”Wrong:
public sealed class TenantPolicy : ResourcePolicy{ private readonly string _tenantId;
public TenantPolicy(string tenantId) => _tenantId = tenantId;
public override bool Evaluate(ICurrentUser user) => user.TenantId == _tenantId;}
[RequirePolicy<TenantPolicy>]public sealed class SomeAction : DomainAction<SomeResult>{ // ...}Compile result: Compile error on [RequirePolicy<TenantPolicy>] — the where TPolicy : ResourcePolicy, new() constraint requires a parameterless constructor. TenantPolicy has only a constructor with a string tenantId parameter.
Right:
// Option A: Use composition with factory methods (no constructor parameters)public sealed class TenantIsolationPolicy : ResourcePolicy{ public override bool Evaluate(ICurrentUser user) => user.IsAuthenticated && user.TenantId is not null;}
// Option B: Use ResourcePolicy.Custom for parameterized checks// (but this is not usable with [RequirePolicy<T>])var policy = ResourcePolicy.Custom(user => user.TenantId == expectedTenantId);Why: PolicyEvaluationFilter creates policy instances via Activator.CreateInstance<TPolicy>() and caches them. The new() constraint on RequirePolicyAttribute<TPolicy> enforces this at compile time. Policies that need runtime state should compose built-in factory methods inside Evaluate (which access ICurrentUser directly) rather than taking constructor parameters.
11. Mixing IRole and IRoleDefinition
Section titled “11. Mixing IRole and IRoleDefinition”Wrong:
// In a module: using IRole instead of IRoleDefinitionpublic sealed class BookingOperator : IRole // Should be IRoleDefinition!{ public static string Name => "booking-operator"; public static string? Description => "Full CRUD on reservations and guests"; public static IReadOnlyList<string> DefaultPermissions => [ BookingPermissions.Reservation.All, BookingPermissions.Guest.All ];}
// In the host:authz.MapRole<BookingManagerRole>(r => r .IncludeDefinition<BookingOperator>()); // Compile error: BookingOperator is IRole, not IRoleDefinitionCompile result: IncludeDefinition<T>() has a where T : IRoleDefinition constraint. Using IRole does not satisfy this constraint.
Right:
// In a module: IRoleDefinition for permission templatespublic 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 ];}
// In the host: IRole for application rolespublic sealed class BookingManagerRole : IRole{ public static string Name => "booking-manager"; public static string? Description => "Full booking operations"; public static IReadOnlyList<string> DefaultPermissions => [];}Why: IRole is for application roles (defined in the host, mapped to user claims). IRoleDefinition is for module permission templates (building blocks that compose into roles). The interfaces have different members: IRole.DefaultPermissions vs IRoleDefinition.Permissions. Modules should never define IRole — that is the host’s responsibility.
12. Duplicate Permission Names
Section titled “12. Duplicate Permission Names”Wrong:
// In module A:public sealed class ViewInvoicePermission : IPermission{ public static string Name => "billing.invoice.read"; public static string? Description => "View invoices"; public static string? Category => "Billing";}
// In module B (same assembly):public sealed class ReadInvoicePermission : IPermission{ public static string Name => "billing.invoice.read"; // Same name! public static string? Description => "Read invoice data"; public static string? Category => "Billing";}Compile result: PRAG1001 error — “Permission name ‘billing.invoice.read’ is defined by both ‘ViewInvoicePermission’ and ‘ReadInvoicePermission’.”
Right:
Ensure each IPermission type has a unique Name:
public sealed class ViewInvoicePermission : IPermission{ public static string Name => "billing.invoice.read"; public static string? Description => "View invoices"; public static string? Category => "Billing";}// Remove the duplicate -- one IPermission per permission nameWhy: The SG generates permission constants and registry entries from IPermission types. Duplicate names would produce conflicting constants or ambiguous registry entries. The SG validates uniqueness and reports PRAG1001 to prevent this.
Quick Reference
Section titled “Quick Reference”| Mistake | Diagnostic / Symptom |
|---|---|
| Hardcoded permission strings | Typos cause silent 403 |
Missing UseAuthorization | Users with role claims get no permissions |
| Wrong naming convention | PRAG1002 warning, wildcard matching fails |
Missing MapGroup | group claims silently ignored |
| Resource authorizer not registered | IResourceAuthorizer<T> never called |
| Manual permission checks | Invisible to OpenAPI, catalog, pipeline |
WithoutPermissions + wildcard | InvalidOperationException at startup |
| Cache not invalidated | Stale permissions for up to TTL |
| Mid-segment wildcards | Treated as literal, no matching |
Policy without new() constructor | Compile error on [RequirePolicy<T>] |
| Mixing IRole and IRoleDefinition | Compile error on IncludeDefinition<T>() |
| Duplicate permission names | PRAG1001 compile error |