Roles and Groups
Pragmatic.Authorization uses a strongly-typed, compile-safe role and group system built on three interfaces: IRole, IRoleDefinition, and IGroup. This document explains the composition model and the APIs for building application roles from module permission templates.
The Three Interfaces
Section titled “The Three Interfaces”IRole — Application Roles
Section titled “IRole — Application Roles”IRole defines an application-level role with a name, description, and default permissions. Roles are defined in the host project.
public interface IRole{ static abstract string Name { get; } static abstract string? Description { get; } static abstract IReadOnlyList<string> DefaultPermissions { get; }}Example:
public sealed class ShowcaseAdmin : IRole{ public static string Name => "admin"; public static string? Description => "Full system access"; public static IReadOnlyList<string> DefaultPermissions => ["*"];}Roles are consumed at runtime when the user has a role claim matching the role’s Name. The RoleExpansionProvider queries the IRolePermissionStore to resolve the role’s permissions.
IRoleDefinition — Module Permission Templates
Section titled “IRoleDefinition — Module Permission Templates”IRoleDefinition defines a reusable permission bundle. Definitions are defined in modules and compose into application roles at the host.
public interface IRoleDefinition{ static abstract string Name { get; } static abstract string? Description { get; } static abstract IReadOnlyList<string> Permissions { get; }}Example:
// In Showcase.Booking modulepublic 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, // "booking.reservation.*" BookingPermissions.Guest.All, // "booking.guest.*" BookingPermissions.GuestPreferences.All // "booking.guestpreferences.*" ];}
public sealed class BookingReader : IRoleDefinition{ public static string Name => "booking-reader"; public static string? Description => "Read-only access to booking data"; public static IReadOnlyList<string> Permissions => [ BookingPermissions.Reservation.Read, BookingPermissions.Guest.Read, BookingPermissions.RoomAssignment.Read, BookingPermissions.StaffAssignment.Read ];}The Distinction
Section titled “The Distinction”| Aspect | IRole | IRoleDefinition |
|---|---|---|
| Defined in | Host | Module |
| Purpose | Application role (mapped to user claim) | Permission template (building block) |
| Used in | authz.MapRole<T>() | roleBuilder.IncludeDefinition<T>() |
| At runtime | Resolved by RoleExpansionProvider via role claim | Not resolved directly |
| Naming | Application-specific ("admin", "booking-manager") | Module-specific ("booking-operator", "catalog-reader") |
Modules publish permission templates. The host composes application roles from those templates. This separation means modules do not dictate application role names.
IGroup — Role Aggregation
Section titled “IGroup — Role Aggregation”IGroup aggregates roles into a group. Users with a group claim get all the roles (and permissions) of that group.
public interface IGroup{ static abstract string Name { get; } static abstract string? Description { get; } static abstract IReadOnlyList<string> DefaultRoles { get; }}Example:
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, // "booking-manager" CatalogViewerRole.Name // "catalog-viewer" ];}RoleBuilder API
Section titled “RoleBuilder API”RoleBuilder provides fluent configuration when mapping roles. It supports additive, subtractive, and replacement operations.
WithPermissions
Section titled “WithPermissions”Adds explicit permissions:
authz.MapRole<ReceptionistRole>(r => r .WithPermissions( BookingPermissions.Reservation.Read, BookingPermissions.Reservation.Create, BookingPermissions.Reservation.Update));IncludeDefinition
Section titled “IncludeDefinition”Includes all permissions from a module IRoleDefinition:
authz.MapRole<BookingManagerRole>(r => r .IncludeDefinition<BookingOperator>() // booking.reservation.*, booking.guest.*, booking.guestpreferences.* .IncludeDefinition<CatalogReader>()); // catalog.amenity.read, catalog.property.read, ...Multiple definitions can be combined in a single role. All permissions are additive.
WithAllPermissions
Section titled “WithAllPermissions”Grants the global wildcard *:
authz.MapRole<AdminRole>(r => r.WithAllPermissions());WithAllPermissions<TBoundary>
Section titled “WithAllPermissions<TBoundary>”Grants all permissions for a specific boundary. The boundary slug is derived by stripping the "Boundary" suffix and lowercasing:
authz.MapRole<BookingAdmin>(r => r.WithAllPermissions<BookingBoundary>());// Adds: "booking.*"WithOperation<TBoundary>(CrudOperation)
Section titled “WithOperation<TBoundary>(CrudOperation)”Grants a specific CRUD operation across all entities in a boundary:
authz.MapRole("auditor", r => r .WithOperation<BookingBoundary>(CrudOperation.Read) // "booking.*.read" .WithOperation<BillingBoundary>(CrudOperation.Read) // "billing.*.read" .WithOperation<CatalogBoundary>(CrudOperation.Read)); // "catalog.*.read"CrudOperation values: Read, Create, Update, Delete.
WithoutPermissions
Section titled “WithoutPermissions”Removes specific permissions from the set (including defaults from IRole.DefaultPermissions):
authz.MapRole<LimitedRole>(r => r .IncludeDefinition<BookingOperator>() .WithoutPermissions("booking.reservation.delete"));Wildcard safety: If you try to exclude a permission that is covered by a wildcard grant, the builder throws InvalidOperationException at startup. This prevents silent failures where the wildcard would override the exclusion.
// This THROWS at startup:authz.MapRole<BadRole>(r => r .WithPermissions("booking.*") .WithoutPermissions("booking.reservation.delete"));// InvalidOperationException: Cannot exclude 'booking.reservation.delete' -- covered by wildcard 'booking.*'.// Use explicit permissions instead of wildcards when using WithoutPermissions.Use explicit permissions instead of wildcards when you need exclusions.
ClearDefaults
Section titled “ClearDefaults”Clears all default permissions from IRole.DefaultPermissions. Only permissions added after this call apply:
authz.MapRole<OverriddenRole>(r => r .ClearDefaults() .WithPermissions("only.these.permissions"));Resolution Order
Section titled “Resolution Order”The RoleBuilder.Resolve(defaults) method produces the final permission set:
- Start with
IRole.DefaultPermissions(unlessClearDefaults()was called). - Add all permissions from
WithPermissions()andIncludeDefinition<T>(). - Remove permissions from
WithoutPermissions()(with wildcard safety check).
GroupBuilder API
Section titled “GroupBuilder API”GroupBuilder provides fluent configuration for groups.
WithRoles
Section titled “WithRoles”Adds roles by name:
authz.MapGroup("operations", g => g.WithRoles("front-desk", "catalog-viewer"));WithRole<T>
Section titled “WithRole<T>”Adds a strongly-typed role:
authz.MapGroup<ExtendedGroup>(g => g.WithRole<ExtraRole>());Mapping Roles by Name
Section titled “Mapping Roles by Name”For roles that do not need a class (e.g., defined in JSON or external systems), use the string overload:
authz.MapRole("auditor", r => r .WithOperation<BookingBoundary>(CrudOperation.Read) .WithOperation<BillingBoundary>(CrudOperation.Read) .WithOperation<CatalogBoundary>(CrudOperation.Read));Similarly for groups:
authz.MapGroup("operations", g => g.WithRoles("front-desk", "catalog-viewer"));JSON Seeding
Section titled “JSON Seeding”The roles.pragmatic.json file provides a declarative way to define additional roles and groups without code. The SG reads the file at compile-time and generates a SeedFromJson() extension method.
File Format
Section titled “File Format”{ "roles": { "role-name": { "description": "Human-readable description", "permissions": ["permission.one", "permission.two"], "inherits": ["other-role-name"] } }, "groups": { "group-name": { "description": "Human-readable description", "roles": ["role-one", "role-two"] } }}Inheritance
Section titled “Inheritance”Roles can inherit from other roles via the inherits array. The SG expands inheritance at compile-time, producing flattened permission sets in the generated code. Circular references are detected and handled safely.
authz.SeedFromJson();This calls the SG-generated RoleSeedingExtensions.SeedFromJson(), which calls MapRole(name, r => r.WithPermissions(...)) for each role and MapGroup(name, g => g.WithRoles(...)) for each group.
IPermission — Custom Permissions
Section titled “IPermission — Custom Permissions”For operations beyond standard CRUD, define IPermission:
public interface IPermission{ static abstract string Name { get; } static abstract string? Description { get; } static abstract string? Category { get; }}Example:
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 available to IPermissionCatalog.GetAllPermissionsAsync().
SG-Generated Permission Constants
Section titled “SG-Generated Permission Constants”The source generator generates nested static classes with permission constants for each entity. The naming follows the convention {boundary}.{entity}.{operation}:
// Generated: BookingPermissionspublic 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"; // ... }}Use these constants in [RequirePermission] attributes and IRoleDefinition.Permissions for compile-time safety.
Runtime Resolution Flow
Section titled “Runtime Resolution Flow”User arrives with claims: { "role": ["booking-manager"], "group": ["customer-care"] }
1. RoleExpansionProvider (Order 100): "booking-manager" -> IRolePermissionStore -> permissions from MapRole configuration
2. GroupExpansionProvider (Order 200): "customer-care" -> IGroupRoleStore -> ["booking-manager", "catalog-viewer"] For each role -> IRolePermissionStore -> permissions
3. CachedPermissionResolver merges all provider results (union)
4. HasPermission("catalog.property.read") -> check resolved setShowcase Example
Section titled “Showcase Example”The Showcase application demonstrates the full composition model:
Modules define templates:
BookingOperator(booking module) — full CRUD on reservations and guestsBookingReader(booking module) — read-only booking accessCatalogReader(catalog module) — read-only catalog accessCatalogEditor(catalog module) — full catalog CRUD
Host defines roles:
ShowcaseAdmin—["*"]BookingManagerRole—BookingOperator + CatalogReaderCatalogViewerRole—CatalogReaderCatalogEditorRole—CatalogEditorReceptionistRole— explicit booking permissions (read, create, update) +CatalogReaderBillingClerkRole—BillingPermissions.All + BookingReaderFinanceManagerRole—BillingPermissions.All + BillingPermissions.Invoice.Refund + BookingReader"auditor"(string) —booking.*.read + billing.*.read + catalog.*.read
Host defines groups:
CustomerCareGroup—BookingManagerRole + CatalogViewerRole
JSON-seeded roles:
front-desk—booking.reservation.read,booking.reservation.create,booking.guest.readrevenue-manager—catalog.*,booking.reservation.read
JSON-seeded groups:
operations—front-desk + catalog-viewer