Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Permissions and roles

Identity plugin @orthacms/identity-server@orthacms/identity-admin

The complete permission catalogue, the role matrix, and how token scopes map onto it.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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

KeyAdminContributorViewerread tokenfull 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: PERMISSIONS in packages/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

RoleCanCannot
AdministratorEverything—
ContributorCreate, edit, publish, approve, delete, export and import content; upload, edit and delete media; share a saved viewManage users, mint tokens, read the audit log, write alarm or protection rules, manage segments, see webhooks, change the content model
ViewerRead content, media, users, workspaces, alarm findings and segments; save private views; use Ortha CMS AI read-onlyChange anything

Grants worth knowing

GrantWhy
copilot:use → every roleThe 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 → adminA skill’s prompt runs for everyone in the workspace. Using one needs only copilot:use.
alarms:read, segments:read → viewerEditors must see findings and restrictions. The manage halves are configuration, admin-only.
content:approve → contributorApproving is editorial. No token scope carries it — a token names nobody.
protection:manage → admin, no protection:readWhether this entry needs approvals comes from the entry under content:read.
activity:manage → admin, no token scopeRetrying a parked outbox event re-runs another plugin’s side effect.
views:share → contributorSaving a private view needs nothing extra; sharing makes it workspace navigation.
content:delete, media:delete → contributorRetracting is the same editorial act as publishing. Both are audited.
webhooks:* → admin, even readEndpoints are global and the log shows where the install talks to.
schema:manage → admin, no token scopeAn 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.

ScopeGrants
readcontent:read, media:read, segments:read
fullread + content:create, content:update, content:publish, content:delete, media:create, segments:manage
  • Both read media: a content read hands back asset URLs.
  • Only full uploads (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:manage or schema:manage.
  • A read token 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:update covers 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.