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

Source page: https://appointme.dev/docs/concepts/permissions/

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

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

*Figure 1. The permission set is built once per principal and read in two places.*

1. **Default grants** — each module's `IDefaultGrantPolicy`

2. **Company overrides** — `RolePermissionOverride` rows, cached

3. **PermissionResolver** — votes per permission, `DenyWinsPolicy` decides

4. Then, in parallel:

   * **Handlers** — `principal.Require(...)` or `403`
   * **The SPA** — `GET /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

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

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

```cs
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

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

```cs
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

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

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

```cs
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

| 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

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

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

src/AppointMe.Shared/Authorization/Principals/PrincipalAuthorizationExtensions.cs

```cs
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](/docs/concepts/identity-and-principal/)). To branch instead of fail, call `principal.HasPermission(...)`, as `UpdateEmployeeRolesCommandHandler` does with `EmployeePermissions.ManageOwners`.

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

src/AppointMe.Frontend/src/components/auth/use-permission.ts

```ts
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](/docs/frontend/auth-and-tenancy/).

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

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

### CRM

| 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

| 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

| 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](/docs/guides/add-a-permission/) guide walks through it.

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

Gotcha

The cache is cleared by an event, which a durable queue delivers after the save commits. For a moment after saving, a request can still see the old permissions.

### Why check in handlers instead of \[Authorize] policies on endpoints?

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?

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.

## Next

* [Add a permission](/docs/guides/add-a-permission/) (Guide)
* [Identity & principal](/docs/concepts/identity-and-principal/) (Concept)
* [Organizations](/docs/modules/organizations/) (Module)
