Add a permission
Add a permission to an AppointMe .NET 10 module. Declare it, grant it to roles by default, enforce it in a Wolverine handler, gate the React UI with it and let each company override it.
- You'll build
products:create, enforced in the API and SPA- Time
- About 15 minutes
- Touches
- 2 C# classes, 1 handler, 1 SPA file
- Before you start
- A module that calls
AddPermissions(...)
Declare it and grant it
Section titled “Declare it and grant it”A permission is a static readonly field. Assembly scanning finds it, and a default-grant policy gives it to roles.
This page is the recipe. How defaults, overrides and conflicts combine is on Permissions. The examples use the Inventory module from Add a module, and every step was built and tested in a copy of the repo.
-
Declare the permission in a static class next to the feature. The code is
resource:action, so this one isproducts:create.src/Inventory/AppointMe.Inventory/Products/ProductPermissions.cs using AppointMe.Shared.Authorization.Permissions;namespace AppointMe.Inventory.Products;public static class ProductPermissions{private const string Products = "products";public static readonly Permission View = new(Products, "view");public static readonly Permission Create = new(Products, "create");}There’s nothing to register.
AddPermissions(ModuleAssembly.Instance)in the module’sAdd*Module()scans the assembly with these rules:src/AppointMe.Shared/Authorization/Permissions/PermissionsServiceCollectionExtensions.csView source ↗ private static bool IsPermissionContainer(Type type){return type is { IsAbstract: true, IsSealed: true }&& type.Name.EndsWith("Permissions", StringComparison.Ordinal);}private static IEnumerable<Permission> GetPermissionsFromType(Type type){return type.GetFields(BindingFlags.Public | BindingFlags.Static).Where(fieldInfo => typeof(Permission).IsAssignableFrom(fieldInfo.FieldType)).Select(fieldInfo => (Permission)fieldInfo.GetValue(null)!);}Rule Because The class is staticA static class is the only kind that is both abstract and sealed Its name ends in PermissionsOrdinal match, so ProductPermsis skippedThe field is public static, typedPermissionorSystemPermissionGetFields(Public | Static)and anIsAssignableFromcheckThe code is unique across every module PermissionRegistrybuilds a dictionary keyed by code, and a duplicate key throws when the registry is created -
Grant it to roles by default. Add an
IDefaultGrantPolicyto the module.AddPermissionsregisters every implementation in the assembly, includinginternalones.src/Inventory/AppointMe.Inventory/Authorization/InventoryDefaultGrantPolicy.cs using AppointMe.Inventory.Products;using AppointMe.Shared.Authorization.Permissions.DefaultGrants;using AppointMe.Shared.Authorization.Roles;namespace AppointMe.Inventory.Authorization;public sealed class InventoryDefaultGrantPolicy : IDefaultGrantPolicy{public IReadOnlyCollection<RolePermissionGrant> DefaultGrants =>[new(Role.Owner, ProductPermissions.View, ProductPermissions.Create),new(Role.Manager, ProductPermissions.View, ProductPermissions.Create),new(Role.Staff, ProductPermissions.View),new(Role.Receptionist, ProductPermissions.View),];}These are the four roles that ship.
Owneris aSystemRole, which means a company can’t change its permissions:src/AppointMe.Shared/Authorization/Roles/Role.csView source ↗ public static readonly SystemRole Owner = new("Owner");public static readonly Role Manager = new("Manager");public static readonly Role Staff = new("Staff");public static readonly Role Receptionist = new("Receptionist");public static IReadOnlyCollection<Role> BuiltIn { get; } = [Owner, Manager, Staff, Receptionist];public static IReadOnlyCollection<Role> Configurable { get; } = BuiltIn.Where(role => role is not SystemRole).ToArray();
Enforce it in the handler
Section titled “Enforce it in the handler”Call principal.Require(...) at the top of the handler. A missing permission becomes 403 with the codes in the message.
-
Check it. Take
IPrincipal principalas a handler parameter, and Wolverine’sPrincipalContextPolicysupplies it. The CRM handlers are the pattern:src/CRM/AppointMe.Crm/Customers/GetCustomerById/GetCustomerByIdQueryHandler.csView source ↗ public async Task<CustomerDto> HandleAsync(GetCustomerByIdQuery query, CompanyId companyId,IPrincipal principal, CancellationToken cancellationToken){principal.Require(CustomerPermissions.View);return await repository.LoadById(new CustomerId(query.CustomerId), companyId, cancellationToken);}In the new module it’s
principal.Require(ProductPermissions.Create);inAddProductCommandHandler.Requireneeds every permission you pass:src/AppointMe.Shared/Authorization/Principals/PrincipalAuthorizationExtensions.csView source ↗ public static void Require(this IPrincipal principal, params Permission[] permissions){if (permissions.Length == 0){throw new ArgumentException("At least one permission must be specified.", nameof(permissions));}if (!permissions.All(principal.HasPermission)){throw new AccessDeniedException($"The following permissions are required: {string.Join(", ", permissions.Select(permission => permission.Code))}.");}}AccessDeniedExceptionHandlerturns the exception into a403problem response withtitle: "Access denied".Where the code runs Who the principal is Put the check there? Command or query handler called from an endpoint The signed-in user, in the company from X-Company-IdYes Endpoint Not resolved yet. The fallback policy only requires a registered user. No Handler of an IDomainEventSystemPrincipal, whoseHasPermissionalways returnstrueNo: it always passes -
Test the grants. Build a
PermissionRegistryfrom the module’s assembly and ask it what each role gets by default:src/Inventory/AppointMe.Inventory.Tests/Authorization/InventoryDefaultGrantPolicyTests.cs // … usings: Shared.Authorization.Permissions, Shared.Authorization.Roles, Microsoft.Extensions.DependencyInjectionpublic class InventoryDefaultGrantPolicyTests{private static readonly PermissionRegistry Registry = new ServiceCollection().AddPermissions(ModuleAssembly.Instance).AddSingleton<PermissionRegistry>().BuildServiceProvider().GetRequiredService<PermissionRegistry>();[Fact]public void should_discover_the_module_permissions(){Assert.True(Registry.Contains("products:view"));Assert.True(Registry.Contains("products:create"));}[Fact]public void should_let_staff_view_but_not_create_products(){Assert.True(Registry.IsGrantedByDefault(Role.Staff, "products:view"));Assert.False(Registry.IsGrantedByDefault(Role.Staff, "products:create"));}}To test the handler’s check, pass a stub
IPrincipalwhoseHasPermissionreturnsfalseand assertAccessDeniedException.GetDashboardStatsQueryHandlerTestsin the Booking tests does exactly that.
Use it in the SPA
Section titled “Use it in the SPA”The SPA reads the user’s permission codes from GET /api/v1/me/access and hides what they can’t do. The API still enforces every check.
-
Regenerate the client from
src/AppointMe.Frontend. The build-time OpenAPI document lists every registered permission, and orval turns it into thePermissiontype:Terminal window npm run generate:apisrc/AppointMe.Frontend/src/api/client.schemas.ts 'appointmentsstatistics:view': 'appointments.statistics:view','products:view': 'products:view','products:create': 'products:create',} as const; -
Label it.
PERMISSION_LABELSis aRecord<Permission, string>, sonpm run checkfails until every code has a label:x typescript(TS2739): Type '{ … }' is missing the following properties from type| 'Record<Permission, string>': "products:create", "products:view"src/AppointMe.Frontend/src/app/settings/permissions/permission-labels.ts const RESOURCE_LABELS: Record<string, string> = {// …'customers.statistics': 'Customer statistics',products: 'Products',};const PERMISSION_LABELS: Record<Permission, string> = {// …'appointments.statistics:view': 'View appointment statistics','products:view': 'View products','products:create': 'Add products',};A resource without a label shows its raw name (
products) as the group heading on the permissions screen. -
Gate the UI. Wrap a control in
<Can>, or callusePermissionfor a boolean. An array means any of the codes.src/AppointMe.Frontend/src/app/customers/customers.tsxView source ↗ <Can permission='customers:create'><Button asChild size='sm'><Link to='/customers/new' aria-label='Add customer'><PlusIcon /><span className='hidden sm:inline'>Add customer</span></Link></Button></Can>A sidebar entry takes the same codes in its
permissionfield, andNavMainhides it whenhasPermissionfails:src/AppointMe.Frontend/src/app/layouts/sidebar/nav-data.tsView source ↗ {title: 'Customers',url: '/customers',navId: 'customers',icon: UsersIcon,permission: 'customers:view',},
The codes come from GET /api/v1/me/access, which UserAccessProvider loads once a company is selected (the user’s first, by default). GET /api/v1/me is a different call, and it carries no permissions:
| Endpoint | Returns | Depends on X-Company-Id |
|---|---|---|
GET /api/v1/me |
isAuthenticated, userId, name, initials, email, companies, hasPendingInvitations, hasMembership, isSuperAdmin |
No |
GET /api/v1/me/access |
companyId, roles, permissions (the codes) |
Yes: the principal is built for that company |
public GetCurrentUserAccessResponse Handle(GetCurrentUserAccessQuery query, IPrincipal principal) { if (principal is not UserPrincipal userPrincipal) { throw new AccessDeniedException("User is not authenticated"); }
return new GetCurrentUserAccessResponse { CompanyId = userPrincipal.CompanyId.Value, Roles = userPrincipal.Roles.Select(role => role.Name).ToArray(), Permissions = userPrincipal.Permissions.Select(permission => permission.Code).ToArray() }; }Let a company override it
Section titled “Let a company override it”Your permission appears on the Settings → Permissions screen with no extra code. A company can grant it to, or take it from, Manager, Staff and Receptionist.
The screen calls three endpoints in the Organizations module:
| Method | Route | Needs | Does |
|---|---|---|---|
GET |
/api/v1/settings/permissions |
permissions:view |
Every non-system permission, grouped by resource, with each configurable role’s effective grant and whether it’s overridden |
PATCH |
/api/v1/settings/permissions |
permissions:manage |
Applies { "grants": [{ "permissionKey": "products:create", "role": "Staff", "isGranted": true }] } |
DELETE |
/api/v1/settings/permissions/overrides |
permissions:manage |
Drops every override, back to the defaults |
Only differences from the defaults are stored, one row per company, role and code in organizations.RolePermissionOverrides. A PATCH that sets a value back to its default deletes the row. The validation decides what can’t be overridden:
foreach (var grant in grants) { if (!registry.Contains(grant.PermissionKey)) { throw new ValidationException($"Unknown permission key: '{grant.PermissionKey}'", code: "unknown_permission"); }
if (registry.Get(grant.PermissionKey) is SystemPermission) { throw new ValidationException( $"Permission '{grant.PermissionKey}' is system-managed and cannot be overridden.", code: "system_managed_permission"); }
if (grant.Role is SystemRole) { throw new ValidationException($"Permissions for the {grant.Role.Name} role cannot be modified.", code: "role_permissions_immutable"); } }| You want | Do |
|---|---|
| A permission no company can change | Declare it as SystemPermission, like EmployeePermissions.ManageOwners. It’s hidden from the screen and a PATCH for it fails with system_managed_permission. |
| A grant to win when a user’s roles disagree | Register GrantWinsPolicy instead of DenyWinsPolicy in OrganizationsModule.cs. By default a deny on any of the user’s roles wins. |
| Changes to apply at once | They nearly do. Overrides are cached per company for an hour, and the CompanyPermissionsChanged event clears that company’s entry after a save. |
Verify it works
Section titled “Verify it works”Run these after each step, without starting the app.
| Check | Command | Expect |
|---|---|---|
| Discovery and default grants | dotnet test src/Inventory/AppointMe.Inventory.Tests |
The two tests above pass |
| The API publishes the code | dotnet build AppointMe.sln, then grep -o '"products:create"' src/AppointMe.Api/obj/openapi.json |
"products:create" |
| The SPA type-checks | npm run generate:api then npm run check in src/AppointMe.Frontend |
Found no warnings, lint errors, or type errors |
In the running app, an Owner’s GET /api/v1/me/access lists products:create, and a Staff user’s doesn’t.
Troubleshooting
Section titled “Troubleshooting”Most failures are a declaration the scanner skips, or a role the policy leaves out.
| Symptom | Cause | Fix |
|---|---|---|
403 The following permissions are required: products:create. |
None of the caller’s roles grants it, after overrides | Add the role to the default-grant policy, or grant it on the permissions screen |
An Owner gets 403 |
Role.Owner is missing from the policy, and Owner can’t be overridden |
Add an Owner grant |
The code is missing from openapi.json and from the screen |
The class isn’t static, its name doesn’t end in Permissions, or the module never calls AddPermissions |
Fix the declaration or the registration |
npm run check fails with TS2739 in permission-labels.ts |
A new code has no label | Add it to PERMISSION_LABELS |
npm run check fails with TS2322 on a permission= prop |
The code was renamed or removed, and the regenerated Permission type no longer has it |
Update the component |
After you rename or remove a permission, every request that needs a principal fails with 500 for some users |
A stored override still names the old code, and PermissionRegistry.Get throws Unknown permission '<code>' |
Delete those rows from organizations.RolePermissionOverrides, as in Remove a module |
Why must the SPA label every permission?1 min · optional
PERMISSION_LABELS is typed Record<Permission, string>, and Permission is generated from the API. A permission added on the server therefore breaks npm run check until someone writes the text a company owner reads on the permissions screen.
The alternative is a raw products:create on that screen in production. The cost is one more file to touch for each new permission, which the type checker reminds you about.