Skip to content
StarLive demo →
GuideChecked against main · 31 Oct 20269 min read

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

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.

  1. Declare the permission in a static class next to the feature. The code is resource:action, so this one is products: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’s Add*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 static A static class is the only kind that is both abstract and sealed
    Its name ends in Permissions Ordinal match, so ProductPerms is skipped
    The field is public static, typed Permission or SystemPermission GetFields(Public | Static) and an IsAssignableFrom check
    The code is unique across every module PermissionRegistry builds a dictionary keyed by code, and a duplicate key throws when the registry is created
  2. Grant it to roles by default. Add an IDefaultGrantPolicy to the module. AddPermissions registers every implementation in the assembly, including internal ones.

    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. Owner is a SystemRole, 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();

Call principal.Require(...) at the top of the handler. A missing permission becomes 403 with the codes in the message.

  1. Check it. Take IPrincipal principal as a handler parameter, and Wolverine’s PrincipalContextPolicy supplies 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); in AddProductCommandHandler. Require needs 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))}.");
    }
    }

    AccessDeniedExceptionHandler turns the exception into a 403 problem response with title: "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-Id Yes
    Endpoint Not resolved yet. The fallback policy only requires a registered user. No
    Handler of an IDomainEvent SystemPrincipal, whose HasPermission always returns true No: it always passes
  2. Test the grants. Build a PermissionRegistry from 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.DependencyInjection
    public 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 IPrincipal whose HasPermission returns false and assert AccessDeniedException. GetDashboardStatsQueryHandlerTests in the Booking tests does exactly that.

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.

  1. Regenerate the client from src/AppointMe.Frontend. The build-time OpenAPI document lists every registered permission, and orval turns it into the Permission type:

    Terminal window
    npm run generate:api
    src/AppointMe.Frontend/src/api/client.schemas.ts
    'appointmentsstatistics:view': 'appointments.statistics:view',
    'products:view': 'products:view',
    'products:create': 'products:create',
    } as const;
  2. Label it. PERMISSION_LABELS is a Record<Permission, string>, so npm run check fails 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.

  3. Gate the UI. Wrap a control in <Can>, or call usePermission for 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 permission field, and NavMain hides it when hasPermission fails:

    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
src/Organizations/AppointMe.Organizations/UserAccess/GetCurrentUserAccessQueryHandler.csView source ↗
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()
};
}

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:

src/Organizations/AppointMe.Organizations/Settings/Permissions/UpdatePermissions/UpdatePermissions.csView source ↗
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.

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.

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.