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.ReadorMembers.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) andUSER(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.
- System roles are the same in every organization and managed by the platform admin on
- 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_ADMINrole, stored as theisPlatformAdminflag 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 withX-Organization-Id(see Organizations and units). Code that needs one organization still answers them 400ORGANIZATION_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:
| Method | Verb |
|---|---|
GET | Read |
POST | Create |
PATCH, PUT | Update |
DELETE | Delete |
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 permission | Widened by |
|---|---|
<E>.ReadOne | <E>.ReadAny |
<E>.Update | <E>.UpdateAny |
<E>.Delete | <E>.DeleteAny |
<E>.<X>Any | itself |
<E>.Read, <E>.Create, every other | nothing: 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.