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

Source page: https://appointme.dev/docs/guides/add-a-permission/

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

**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](/docs/concepts/permissions/). The examples use the `Inventory` module from [Add a module](/docs/guides/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

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

   | 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

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

   ```cs
       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();
   ```

Gotcha

`Owner` isn’t a superuser. It gets exactly what the default-grant policies list for it, and a company can’t add to it with an override. Leave `Role.Owner` out of your policy and nobody can ever hold the permission as an owner.

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

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

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

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

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

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:

   ```bash
   npm run generate:api
   ```

   src/AppointMe.Frontend/src/api/client.schemas.ts

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

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

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

   ```tsx
                           <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.ts

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

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

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

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

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

**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](/docs/guides/remove-a-module/) |

### Why must the SPA label every permission?

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

## Next

* [Permissions](/docs/concepts/permissions/) (Concept)
* [Add a module](/docs/guides/add-a-module/) (Guide)
* [Auth & tenancy in the SPA](/docs/frontend/auth-and-tenancy/) (Guide)
