Skip to content

Permissions and roles

Every API route in an organization checks a permission, and every page in the client can hide what the caller may not do. Read this when you add your first route or page: the permission names come from your code, so there is no list to maintain by hand.

The model

  • A permission is a string such as Todos.Read or Members.Update.
  • A role is a named set of permissions:
    • System roles are the same in every organization and managed by the platform admin on /admin/roles: the four built-ins - PLATFORM_ADMIN (everything), ORG_ADMIN (members, invitations, units, roles, audit, helpdesk, billing, settings; organization-wide only), UNIT_ADMIN (the same minus the organization-wide-only permissions, so audit and helpdesk for the people in its reach; in a unit only) and USER (the default role) - plus any the platform admin creates. Your code gives the built-ins only their initial permissions; after the first boot the database is the truth.
    • Organization roles belong to one organization: its admins create them on the organization’s Roles tab, and only that organization sees and assigns them.
  • A member holds one or more role assignments in an organization (never zero), each for the whole organization or for one unit and the units below it.
  • Platform admin is the PLATFORM_ADMIN role, stored as the isPlatformAdmin flag on a user rather than an assignment; it passes every check, and passes an @Entity() route without an organization: a platform admin has no active organization and their requests reach every organization, or the ones named with X-Organization-Id (see Organizations and units). Code that needs one organization still answers them 400 ORGANIZATION_REQUIRED.
  • The hierarchy holds: ORG_ADMIN ⊇ UNIT_ADMIN ⊇ USER, so each admin can assign the roles below them. Organization-wide-only permissions (Billing.*, Organization.*, Roles.Create/Update/Delete) are never given in a unit.
  • Nobody grants more than they hold, nor takes it away. A role editor can only add or remove permissions they hold organization-wide, and an assigner can only give - or take away, by changing or removing a member - roles whose permissions they hold at that scope (403 PERMISSION_NOT_HELD). The platform admin has no limit.

A caller’s effective permissions are worked out when a token is issued (login, refresh, a member switching organization) and signed into the access token, so a route checks them without a database read. A role change reaches a session at its next refresh, within one access-token lifetime (15 minutes by default, JWT_ACCESS_TTL).

Where permission strings come from

A controller opts in with @Entity(). The entity name comes from the controller’s path (@Controller('todos') → Todos), and each route gets <Entity>.<Verb> from its HTTP method:

MethodVerb
GETRead
POSTCreate
PATCH, PUTUpdate
DELETEDelete

A route that does not fit the pattern names its own verb with @Action():

@Entity({ units: true, owned: true })
@Controller('todos')
export class TodosController {
@Get() list() {} // Todos.Read
@Get('all') @Action('ReadAny') listAll() {} // Todos.ReadAny
@Get(':id') @Action('ReadOne') get() {} // Todos.ReadOne
@Post() create() {} // Todos.Create
@Patch(':id') update() {} // Todos.Update
@Delete(':id') remove() {} // Todos.Delete
}

At boot the API walks every controller and refuses to start when an authenticated route has no guard decision (@Entity(), @Authenticated() for “any signed-in user”, @RequirePlatformAdmin() for platform routes, or @Public()), or when two routes resolve to the same permission. A new endpoint cannot ship unguarded by accident. The four bare verbs (Create, Read, Update, Delete) also exist; holding one satisfies that verb for every entity’s default permission. A bare verb never satisfies an @Action name such as ReadOne or any *Any.

@Entity({ units: true }) lets a permission held in a unit pass the guard too; use it only when the controller’s data is // @owned or // @unit models, so the data is filtered to the caller’s reach (see Tenant data).

Own records and *Any

On an // @owned or // @unit model a route’s permission reaches the caller’s own records (or their direct units’ rows). The matching *Any permission widens it to everything in its reach: organization-wide, the whole organization; held in a unit, the members (or rows) of that unit and its sub-units.

Route’s permissionWidened by
<E>.ReadOne<E>.ReadAny
<E>.Update<E>.UpdateAny
<E>.Delete<E>.DeleteAny
<E>.<X>Anyitself
<E>.Read, <E>.Create, every othernothing: own only

So GET /api/todos lists the caller’s own todos even for an org admin; everyone’s in reach is GET /api/todos/all (Todos.ReadAny), and the same PATCH/DELETE /api/todos/:id routes reach a member’s todo with Todos.UpdateAny/Todos.DeleteAny. @Entity({ owned: true }) adds <E>.ReadAny, <E>.UpdateAny and <E>.DeleteAny to the catalog (they need no route of their own, and count as checked at boot).

Below that reach, any caller can narrow a request with the X-Filter-Unit and X-Filter-Member headers; a value outside the reach shrinks the result, never 403 (Tenant data). A platform admin has no reach limit, only the filters they send.

Give your permissions to roles

New permissions belong to no role until you say so. In product.config.ts, the built-in roles’ initial permissions next to core’s:

systemRolePermissions: {
// Every member: their own records.
USER: ['Todos.Read', 'Todos.ReadOne', 'Todos.Create', 'Todos.Update', 'Todos.Delete'],
// The admins: those, and the members' in their reach.
UNIT_ADMIN: [
'Todos.Read', 'Todos.ReadOne', 'Todos.Create', 'Todos.Update', 'Todos.Delete',
'Todos.ReadAny', 'Todos.UpdateAny', 'Todos.DeleteAny',
],
ORG_ADMIN: [
'Todos.Read', 'Todos.ReadOne', 'Todos.Create', 'Todos.Update', 'Todos.Delete',
'Todos.ReadAny', 'Todos.UpdateAny', 'Todos.DeleteAny',
],
},

The config must keep the hierarchy (ORG_ADMIN ⊇ UNIT_ADMIN ⊇ USER, no organization-wide-only permission in UNIT_ADMIN), and a permission that no route checks fails the boot, so a typo cannot hide. These apply when the role is created (a new database, or a role that is missing): on an existing database a new permission is granted to nobody until the platform admin adds it on /admin/roles, which names the permissions no role holds. Organization admins build their own roles from the permissions they hold.

Guard pages and controls in the client

// A route: only callers holding the permission organization-wide get in.
{ path: 'reports', canActivate: [permissionGuard('Reports.Read')], loadComponent: ... }
<!-- A control: rendered only when the caller holds the permission. -->
<button *libHasPermission="'Todos.Create'">New todo</button>

unitPermissionGuard is the unit-aware variant for a page whose data the API scopes by unit. A sidebar link takes the same permission (and units: true) in PRODUCT_NAV (see UI). The client only hides things; the API is what enforces them.

The first platform admin in production

There is no self-service promotion. In production, make your own account platform admin once with SQL; afterwards a platform admin grants the flag from the Administration → Users page (/users):

UPDATE users SET "isPlatformAdmin" = true WHERE email = 'you@example.com';

Keep at least two platform admins, so one can reset the other’s two-factor authentication.