Skip to content
StarLive demo →
ConceptChecked against main · 31 Oct 202611 min read

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 as customers:view
Discovered
From static *Permissions classes
Conflicts
A deny wins (DenyWinsPolicy)
You touch it when
You add a feature or a role

Defaults per role, then the company’s overrides, then a conflict policy: the result is the UserPrincipal’s permission set.

Default grantseach module's IDefaultGrantPolicy
Company overridesRolePermissionOverride rows, cached
PermissionResolvervotes per permission, DenyWinsPolicy decides
Handlersprincipal.Require(...) or 403
The SPAGET /api/v1/me/access feeds <Can>
Figure 1. The permission set is built once per principal and read in two places.
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.

A public static readonly Permission field on a static class whose name ends in Permissions is all it takes.

src/CRM/AppointMe.Crm/Customers/CustomerPermissions.csView source ↗
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:

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)!);
}

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.

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:

src/CRM/AppointMe.Crm/Authorization/CrmDefaultGrantPolicy.csView source ↗
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.

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:

  1. Start with the union of the default grants of every role the user holds.
  2. Drop overrides for roles the user doesn’t hold.
  3. 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.
  4. The conflict policy turns the votes into a decision, and the permission is added or removed.
src/Organizations/AppointMe.Organizations/Infrastructure/PermissionResolver.csView source ↗
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:

src/Organizations/AppointMe.Organizations/Settings/Permissions/Infrastructure/CompanyPermissionsChangedEventHandler.csView source ↗
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>().

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.

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.

Call principal.Require(...) first. If any listed permission is missing, it throws.

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

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="…">:

src/AppointMe.Frontend/src/components/auth/use-permission.tsView source ↗
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.

Nineteen permissions across three modules. Identity defines none.

O, M, S and R are Owner, Manager, Staff and Receptionist. A ✓ is a default grant.

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 ✓ ✓ — —
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 ✓ ✓ — —
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.

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.