Skip to content

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.

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 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, // "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
];
}
AspectIRoleIRoleDefinition
Defined inHostModule
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
NamingApplication-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 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 provides fluent configuration when mapping roles. It supports additive, subtractive, and replacement operations.

Adds explicit permissions:

authz.MapRole<ReceptionistRole>(r => r
.WithPermissions(
BookingPermissions.Reservation.Read,
BookingPermissions.Reservation.Create,
BookingPermissions.Reservation.Update));

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.

Grants the global wildcard *:

authz.MapRole<AdminRole>(r => r.WithAllPermissions());

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.*"

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.

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.

Clears all default permissions from IRole.DefaultPermissions. Only permissions added after this call apply:

authz.MapRole<OverriddenRole>(r => r
.ClearDefaults()
.WithPermissions("only.these.permissions"));

The RoleBuilder.Resolve(defaults) method produces the final permission set:

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

GroupBuilder provides fluent configuration for groups.

Adds roles by name:

authz.MapGroup("operations", g => g.WithRoles("front-desk", "catalog-viewer"));

Adds a strongly-typed role:

authz.MapGroup<ExtendedGroup>(g => g.WithRole<ExtraRole>());

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"));

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.

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

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.

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().

The source generator generates nested static classes with permission constants for each entity. The naming follows the convention {boundary}.{entity}.{operation}:

// 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";
// ...
}
}

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

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 set

The Showcase application demonstrates the full composition model:

Modules define templates:

  • BookingOperator (booking module) — full CRUD on reservations and guests
  • BookingReader (booking module) — read-only booking access
  • CatalogReader (catalog module) — read-only catalog access
  • CatalogEditor (catalog module) — full catalog CRUD

Host defines roles:

  • ShowcaseAdmin["*"]
  • BookingManagerRoleBookingOperator + CatalogReader
  • CatalogViewerRoleCatalogReader
  • CatalogEditorRoleCatalogEditor
  • ReceptionistRole — explicit booking permissions (read, create, update) + CatalogReader
  • BillingClerkRoleBillingPermissions.All + BookingReader
  • FinanceManagerRoleBillingPermissions.All + BillingPermissions.Invoice.Refund + BookingReader
  • "auditor" (string) — booking.*.read + billing.*.read + catalog.*.read

Host defines groups:

  • CustomerCareGroupBookingManagerRole + CatalogViewerRole

JSON-seeded roles:

  • front-deskbooking.reservation.read, booking.reservation.create, booking.guest.read
  • revenue-managercatalog.*, booking.reservation.read

JSON-seeded groups:

  • operationsfront-desk + catalog-viewer