Permissions
AppointMe discovers permissions from static .NET classes, grants them to roles by default, and lets each company override them, with a deny winning when a user's roles disagree.
- Shape
resource:action, such ascustomers:view- Discovered
- From static
*Permissionsclasses - Conflicts
- A deny wins (
DenyWinsPolicy) - You touch it when
- You add a feature or a role
How a user gets their permissions
Section titled “How a user gets their permissions”Defaults per role, then the company’s overrides, then a conflict policy: the result is the UserPrincipal’s permission set.
IDefaultGrantPolicyRolePermissionOverride rows, cachedDenyWinsPolicy decidesprincipal.Require(...) or 403GET /api/v1/me/access feeds <Can>| Piece | Where | What it does |
|---|---|---|
Permission(Resource, Action) |
AppointMe.Shared/Authorization/Permissions |
A record. Code is "{Resource}:{Action}". |
SystemPermission |
Same folder | A Permission no company can override. The settings screen doesn’t list it. |
PermissionRegistry |
Same folder | A singleton holding every discovered permission and the merged default grants per role. |
IDefaultGrantPolicy |
…/Permissions/DefaultGrants |
A module’s DefaultGrants: a list of RolePermissionGrant(Role, params IReadOnlyCollection<Permission>). |
RolePermissionOverride |
AppointMe.Organizations/Settings/Permissions |
One row per company, role and permission code, with IsGranted. |
PermissionResolver |
AppointMe.Organizations/Infrastructure |
Merges defaults and overrides for the roles a user holds. |
IOverrideConflictPolicy |
…/Permissions/OverrideConflicts |
Decides when the user’s roles disagree. DenyWinsPolicy is registered; GrantWinsPolicy ships too. |
Define a permission
Section titled “Define a permission”A public static readonly Permission field on a static class whose name ends in Permissions is all it takes.
public static class CustomerPermissions{ private const string Customers = "customers";
public static readonly Permission View = new(Customers, "view"); public static readonly Permission Create = new(Customers, "create"); public static readonly Permission Update = new(Customers, "update"); public static readonly Permission Delete = new(Customers, "delete"); public static readonly Permission ViewStatistics = new($"{Customers}.statistics", "view");}Each module’s Add<Module>Module() calls .AddPermissions(ModuleAssembly.Instance) (Booking passes BookingModuleAssembly.Instance). It scans the assembly with reflection:
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)!);}IsAbstract plus IsSealed is how a C# static class looks to reflection. Each permission is registered as a singleton, and PermissionRegistry collects them. The same call registers every IDefaultGrantPolicy in the assembly through Scrutor, non-public classes included.
Roles and default grants
Section titled “Roles and default grants”Four roles are built in, and each module states which of them get which of its permissions.
| Role | Type | Company can change its permissions | Notes |
|---|---|---|---|
| Owner | SystemRole |
No | The user who onboards a company gets Owner and Staff. Only a principal with employees:manage_owners can assign or remove Owner. |
| Manager | Role |
Yes | |
| Staff | Role |
Yes | Booking turns every employee with Staff into a service provider. |
| Receptionist | Role |
Yes |
All four are on Role in AppointMe.Shared/Authorization/Roles. Role.Configurable is the built-in roles minus the SystemRole, and it’s what the settings screen offers. A module’s grants look like this:
public sealed class CrmDefaultGrantPolicy : IDefaultGrantPolicy{ public IReadOnlyCollection<RolePermissionGrant> DefaultGrants => [ new(Role.Owner, CustomerPermissions.View, CustomerPermissions.Create, CustomerPermissions.Update, CustomerPermissions.Delete, CustomerPermissions.ViewStatistics ), // … new(Role.Staff, CustomerPermissions.View ), // … ];Organizations splits its grants per feature (EmployeeRolesGrants, InvitationRolesGrants, PermissionRolesGrants) and concatenates them in OrganizationsDefaultGrantPolicy.
Company overrides and conflicts
Section titled “Company overrides and conflicts”A company stores only its differences from the defaults; when two of a user’s roles disagree, a deny wins.
PermissionResolver.Resolve(roles, overrides) works like this:
- Start with the union of the default grants of every role the user holds.
- Drop overrides for roles the user doesn’t hold.
- For each permission that still has an override, every held role votes: its override if it has one, a grant if its defaults include the permission, and otherwise nothing.
- The conflict policy turns the votes into a decision, and the permission is added or removed.
var votes = new List<PermissionVote>();foreach (var role in roles){ if (overridesByRole.TryGetValue(role, out var explicitGrant)) { votes.Add(new PermissionVote(role, explicitGrant)); } else if (registry.GetDefaultGrants(role).Contains(permission)) { votes.Add(new PermissionVote(role, true)); }}
if (votes.Count == 0){ continue;}
var isGranted = conflictPolicy.Resolve(votes);DenyWinsPolicy grants only if every vote is a grant (votes.All(vote => vote.IsGranted)). GrantWinsPolicy grants if any vote is. The tests in AppointMe.Organizations.Tests/Permissions pin the behavior down:
| User’s roles | Override | Result with deny-wins |
|---|---|---|
| Manager | Manager denies a permission it has by default | Removed |
| Staff | Staff is granted a permission it lacks by default | Added |
| Manager and Staff | Manager grants it, Staff denies it | Denied |
| Staff | Manager is granted it | Ignored, because the user isn’t a Manager |
Overrides are read through RolePermissionOverridesCache, a HybridCache entry per company (role-permission-overrides:{companyId}) that expires after an hour. Saving or resetting overrides returns a CompanyPermissionsChanged event, and its handler drops the entry:
internal sealed class CompanyPermissionsChangedEventHandler(RolePermissionOverridesCache cache){ public ValueTask Handle(CompanyPermissionsChanged @event, CancellationToken cancellationToken) { return cache.InvalidateAsync(new CompanyId(@event.CompanyId), cancellationToken); }}To switch policy, change one line in OrganizationsModule: .AddSingleton<IOverrideConflictPolicy, DenyWinsPolicy>().
Edit overrides: API and screen
Section titled “Edit overrides: API and screen”| Method | Route | Requires | Does |
|---|---|---|---|
GET |
/api/v1/settings/permissions |
permissions:view |
Lists the roles and every non-system permission, grouped by resource, with isGranted and isOverridden for each configurable role. |
PATCH |
/api/v1/settings/permissions |
permissions:manage |
Takes grants: [{ permissionKey, role, isGranted }]. A grant that matches the default deletes the override instead of storing one. |
DELETE |
/api/v1/settings/permissions/overrides |
permissions:manage |
Deletes every override for the company. |
The screen is /settings/permissions (app/settings/permissions/permissions.tsx): a role-by-permission matrix with search, Save changes, and Reset to defaults behind a confirmation.
Check a permission
Section titled “Check a permission”Handlers call principal.Require(...) and fail with 403; the SPA reads the same set from GET /api/v1/me/access to hide what the user can’t do.
In a handler
Section titled “In a handler”Call principal.Require(...) first. If any listed permission is missing, it throws.
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 problem-details response with the title Access denied and the message as detail. Endpoints carry no permission metadata: the check always lives in the handler, which gets IPrincipal from a Wolverine policy (see Identity & principal). To branch instead of fail, call principal.HasPermission(...), as UpdateEmployeeRolesCommandHandler does with EmployeePermissions.ManageOwners.
In the SPA
Section titled “In the SPA”GET /api/v1/me/access needs a user principal, so a signed-in user and a company. It returns companyId, roles and permissions (the codes) for that company. UserAccessProvider loads it around the main app layout, and components ask through a hook or <Can permission="…">:
export const hasPermission = (granted: readonly string[], required: Permission | Permission[]): boolean => Array.isArray(required) ? required.some(permission => granted.includes(permission)) : granted.includes(required);
export const usePermission = (permission: Permission | Permission[]) => { const { permissions } = useUserAccess(); return hasPermission(permissions, permission);};The Permission type is generated. PermissionSchemaTransformer writes every code into the OpenAPI document as an enum, and the client generator turns it into a union. More in Auth & tenancy in the SPA.
Every permission that ships
Section titled “Every permission that ships”Nineteen permissions across three modules. Identity defines none.
O, M, S and R are Owner, Manager, Staff and Receptionist. A ✓ is a default grant.
Organizations
Section titled “Organizations”| Code | Checked in | O | M | S | R |
|---|---|---|---|---|---|
employees:view |
Get team | ✓ | ✓ | — | — |
employees:invite |
Invite employee | ✓ | ✓ | — | — |
employees:remove |
Delete employee | ✓ | ✓ | — | — |
employees:update_roles |
Update employee roles | ✓ | ✓ | — | — |
employees:manage_owners (system) |
Assigning or removing Owner, on invite or role update | ✓ | — | — | — |
invitations:resend |
Resend invitation | ✓ | ✓ | — | — |
invitations:cancel |
Cancel invitation | ✓ | ✓ | — | — |
permissions:view |
Get permissions | ✓ | ✓ | — | — |
permissions:manage (system) |
Update and reset permissions | ✓ | — | — | — |
| Code | Checked in | O | M | S | R |
|---|---|---|---|---|---|
customers:view |
Get customers, get customer by id | ✓ | ✓ | ✓ | ✓ |
customers:create |
Register customer | ✓ | ✓ | — | ✓ |
customers:update |
Update customer | ✓ | ✓ | — | ✓ |
customers:delete |
Delete customer | ✓ | — | — | — |
customers.statistics:view |
New-customer stats | ✓ | ✓ | — | — |
Booking
Section titled “Booking”| Code | Checked in | O | M | S | R |
|---|---|---|---|---|---|
appointments:view |
Get appointments, get appointment by id, get service providers | ✓ | ✓ | ✓ | ✓ |
appointments:schedule |
Schedule appointment | ✓ | ✓ | ✓ | ✓ |
appointments:reschedule |
Reschedule appointment | ✓ | ✓ | ✓ | ✓ |
appointments:cancel |
Cancel appointment | ✓ | ✓ | ✓ | ✓ |
appointments.statistics:view |
Dashboard stats, peak hours | ✓ | ✓ | — | — |
Extend it
Section titled “Extend it”| Step | Where | What |
|---|---|---|
| 1. Declare | <Feature>/<Feature>Permissions.cs |
public static readonly Permission Export = new("customers", "export"); |
| 2. Grant | The module’s IDefaultGrantPolicy |
Add the permission to each role that should have it. |
| 3. Check | The handler | Take IPrincipal principal and call principal.Require(...). |
| 4. Regenerate | npm run generate:api |
The Permission union gains the new code. |
| 5. Label | app/settings/permissions/permission-labels.ts |
PERMISSION_LABELS is a Record<Permission, string>, so type-checking fails until you add a label. |
| 6. Gate the UI | Your component | <Can permission="customers:export">…</Can> |
A new module also needs .AddPermissions(...) in its registration. The Add a permission guide walks through it.
Gotchas
Section titled “Gotchas”| Symptom | Cause | Fix |
|---|---|---|
| Denying a permission to Staff also takes it from the company’s owner | The onboarding user holds Owner and Staff. Owner votes grant, Staff votes deny, and a deny wins. | Don’t deny Staff what owners need, or register GrantWinsPolicy. |
The UI shows a control but the API returns 403 |
<Can> with an array means any of them; Require(...) with several means all of them |
Match the two lists. |
A new permission doesn’t appear in settings or in the generated Permission union |
The class isn’t static, its name doesn’t end in Permissions, the field isn’t public static, or the module never calls .AddPermissions(...) (then its default grants aren’t registered either) |
Fix the declaration or the registration. |
400 with error: "Permission '…' is system-managed and cannot be overridden." |
The PATCH included a SystemPermission |
Leave system permissions out. A grant for Owner also fails with 400: “Permissions for the Owner role cannot be modified.” |
| A change is slow to apply on a second instance | HybridCache has no distributed cache here, so each process keeps its own copy for up to an hour, and only the process that handles the event drops it |
Shorten the expirations in RolePermissionOverridesCache before scaling out. |
Why check in handlers instead of [Authorize] policies on endpoints?2 min · optional
Permissions depend on the company in the X-Company-Id header, and they come from the database, not from the token. An endpoint policy would need to resolve the same principal anyway.
The handler is also the one place every caller passes through, including jobs and messages that never touch an endpoint. Endpoints only gate “is this a registered user”, and the handler makes the business decision.
Why does a deny win?1 min · optional
An explicit deny is a deliberate decision by whoever manages the company, and giving someone an extra role shouldn’t quietly undo it. Deny-wins is the least-privilege choice.
The cost is the Owner-plus-Staff case in the gotchas. The rule is one interface, IOverrideConflictPolicy, so you can swap it without touching the resolver.