One global role per user, and a flat catalogue of resource:action keys.
Workspace membership is a separate axis: which workspaces a user can reach.
The catalogue
| Key | Admin | Contributor | Viewer | read token | full token |
|---|---|---|---|---|---|
workspaces:create | ✅ | — | — | — | — |
workspaces:read | ✅ | ✅ | ✅ | — | — |
workspaces:update | ✅ | — | — | — | — |
workspaces:delete | ✅ | — | — | — | — |
users:read | ✅ | ✅ | ✅ | — | — |
users:create | ✅ | — | — | — | — |
users:update | ✅ | — | — | — | — |
users:delete | ✅ | — | — | — | — |
users:manage | ✅ | — | — | — | — |
activity:read | ✅ | — | — | — | — |
activity:manage | ✅ | — | — | — | — |
content:read | ✅ | ✅ | ✅ | ✅ | ✅ |
content:create | ✅ | ✅ | — | — | ✅ |
content:update | ✅ | ✅ | — | — | ✅ |
content:publish | ✅ | ✅ | — | — | ✅ |
content:approve | ✅ | ✅ | — | — | — |
content:delete | ✅ | ✅ | — | — | ✅ |
content:export | ✅ | ✅ | — | — | — |
content:import | ✅ | ✅ | — | — | — |
media:read | ✅ | ✅ | ✅ | ✅ | ✅ |
media:create | ✅ | ✅ | — | — | ✅ |
media:update | ✅ | ✅ | — | — | — |
media:delete | ✅ | ✅ | — | — | — |
tokens:read | ✅ | — | — | — | — |
tokens:create | ✅ | — | — | — | — |
tokens:delete | ✅ | — | — | — | — |
copilot:use | ✅ | ✅ | ✅ | — | — |
copilot:skills:manage | ✅ | — | — | — | — |
alarms:read | ✅ | ✅ | ✅ | — | — |
alarms:manage | ✅ | — | — | — | — |
segments:read | ✅ | ✅ | ✅ | ✅ | ✅ |
segments:manage | ✅ | — | — | — | ✅ |
views:share | ✅ | ✅ | — | — | — |
webhooks:read | ✅ | — | — | — | — |
webhooks:manage | ✅ | — | — | — | — |
protection:manage | ✅ | — | — | — | — |
schema:manage | ✅ | — | — | — | — |
37 keys. Admin holds all 37, contributor 18, viewer 7. schema:manage came
with the schema builder.
- Source of truth:
PERMISSIONSinpackages/identity/server/src/lib/rbac/system-roles.ts. - The seeder reconciles keys and grants on every boot, both directions — a new key reaches an existing install by deploying.
- Admin holds an enumerated list, not a wildcard: adding a key never widens anyone’s access by accident.
Checking what you hold
curl -b 'orthacms_session=…' https://cms.example.com/api/auth/me{
"id": "66666666-…",
"email": "grace@example.com",
"name": "Grace",
"roleId": "55555555-…",
"status": "active",
"permissions": ["workspaces:read", "users:read", "content:read", "content:create", "…"]
}A missing permission is a 403:
{ "statusCode": 403, "message": "Insufficient permissions", "error": "Forbidden" }The roles
| Role | Can | Cannot |
|---|---|---|
| Administrator | Everything | — |
| Contributor | Create, edit, publish, approve, delete, export and import content; upload, edit and delete media; share a saved view | Manage users, mint tokens, read the audit log, write alarm or protection rules, manage segments, see webhooks, change the content model |
| Viewer | Read content, media, users, workspaces, alarm findings and segments; save private views; use Ortha CMS AI read-only | Change anything |
Grants worth knowing
| Grant | Why |
|---|---|
copilot:use → every role | The copilot has no authority of its own: a viewer’s copilot can only offer read tools. Cost is bounded per run (default 30 steps, 5 minutes, 400,000 tokens), and the copilot is off until enabled. |
copilot:skills:manage → admin | A skill’s prompt runs for everyone in the workspace. Using one needs only copilot:use. |
alarms:read, segments:read → viewer | Editors must see findings and restrictions. The manage halves are configuration, admin-only. |
content:approve → contributor | Approving is editorial. No token scope carries it — a token names nobody. |
protection:manage → admin, no protection:read | Whether this entry needs approvals comes from the entry under content:read. |
activity:manage → admin, no token scope | Retrying a parked outbox event re-runs another plugin’s side effect. |
views:share → contributor | Saving a private view needs nothing extra; sharing makes it workspace navigation. |
content:delete, media:delete → contributor | Retracting is the same editorial act as publishing. Both are audited. |
webhooks:* → admin, even read | Endpoints are global and the log shows where the install talks to. |
schema:manage → admin, no token scope | An apply runs DDL, rewrites the repository and changes every workspace at once. It opens nothing on a server that may not edit — production included. Reading the model needs only content:read. |
API token scopes
A token’s scope becomes a permission set, evaluated by the same policy as a user’s role.
| Scope | Grants |
|---|---|
read | content:read, media:read, segments:read |
full | read + content:create, content:update, content:publish, content:delete, media:create, segments:manage |
- Both read media: a content read hands back asset URLs.
- Only
fulluploads (a media field needs an asset id) and sets an entry’s audiences. - No scope carries
media:update,media:delete,content:export,content:import,content:approve,activity:manageorschema:manage. - A
readtoken can never ask for drafts.
Mint one (admin API, tokens:create):
curl -X POST https://cms.example.com/api/api-tokens \
-b 'orthacms_session=…' -H 'Content-Type: application/json' \
-d '{
"name": "Marketing site build",
"workspaceIds": ["7d0c…"],
"scope": "read",
"expiresAt": "2027-01-01T00:00:00.000Z"
}'{
"id": "…",
"name": "Marketing site build",
"workspaceIds": ["7d0c…"],
"scope": "read",
"lookupPrefix": "…",
"expiresAt": "2027-01-01T00:00:00.000Z",
"lastUsedAt": null,
"revokedAt": null,
"createdAt": "…",
"secret": "orthacms_…"
}secret is returned once. workspaceIds takes 1–100 ids; omit expiresAt
for a token that never expires (a past date is a 400).
Enforcing permissions in a plugin
Server — declare the key on the route; the guard evaluates it:
import { Controller, Get, UseGuards } from '@nestjs/common';
import { PERMISSIONS, PermissionsGuard, RequirePermissions } from '@orthacms/identity-server';
import { CurrentWorkspace, WorkspaceGuard } from '@orthacms/workspaces-server';
@UseGuards(PermissionsGuard, WorkspaceGuard)
@Controller('reports')
export class ReportsController {
@RequirePermissions(PERMISSIONS.CONTENT_READ)
@Get()
list(@CurrentWorkspace() workspaceId: string) {
return { workspaceId };
}
}@RequirePermissions(a, b)needs all;@RequireAnyPermission(a, b)needs any.- Keys come from the catalogue; a plugin does not add its own.
Admin — gate the UI with the same key:
import { useHasPermission } from '@orthacms/identity-admin';
const canPublish = useHasPermission('content:publish');Hiding a control is a courtesy; the server decides. Do both. See server plugins.
What the model cannot express
One role, everywhere
- No per-workspace roles — a contributor is a contributor in every workspace they belong to.
- No custom roles — no role editor, no role API.
- No per-type, per-entry or per-field permissions.
content:updatecovers every type in every workspace the user reaches. - Approving is a permission, not a role —
content:approve; a protection rule counts approvals from anyone who holds it except the author of that version.
To narrow what someone reaches, use workspace membership: put the content in a workspace they are not a member of.