The hidden button is a courtesy. The guard is the API

Every permission question about a CMS is really the same question: what happens when somebody calls the endpoint directly. Here the answer is that the check runs on the server, on every route, and the interface hiding the button is a nicety on top of it.

For whoever owns the user list and has to sign off on access

Where the check actually happens

Every protected route declares the permission keys it needs, and a guard resolves the caller’s grants and checks them before the handler runs. The admin then asks the same question a second time to decide whether to render a control — so a reader without permission to publish sees no publish button, and a determined one who posts to the endpoint anyway gets a 403.

Those two checks read from one source. The role definitions are a single TypeScript file: the permission catalogue, the three roles, and what each is granted. The guard reads from it, the seed that populates the database reads from it, and the tests read from it. There is no second place where a role is described.

The seeder reconciles in both directions. Removing a key from the file removes the grant — which matters more than it sounds: an earlier version was additive only, and a deleted permission lingered in every existing database and kept being reported for years after the feature it gated was gone.

Twelve of the 34 permission keys against the three roles. An administrator holds every key; a contributor may create, update, publish, approve and delete content and media, but not manage users, tokens or webhooks; a viewer holds only reads — and, like every role, may use the assistant.
Permission AdministratorContributorViewer
content:read Yes Yes Yes
content:create Yes Yes No
content:update Yes Yes No
content:publish Yes Yes No
content:approve Yes Yes No
content:delete Yes Yes No
media:delete Yes Yes No
users:create Yes No No
tokens:create Yes No No
webhooks:manage Yes No No
activity:read Yes No No
copilot:use Yes Yes Yes
Twelve of the 34, chosen because they are the ones people ask about. content:delete on a contributor and copilot:use on a viewer are the two that look wrong until you know why.

The three roles

Fixed, built in, and seeded on boot from the file that defines them.

Role What it holds
Administrator All 34 keys, enumerated rather than a wildcard — so a new permission has to be granted to admin explicitly, and the seed test fails if somebody forgets.
Contributor Eighteen keys: read workspaces and users; read, create, update, publish, approve, delete, export and import content; read, create, update and delete media; read alarm findings and audiences; share a saved view; use the assistant. Nothing about users, tokens, rules or webhooks.
Viewer Seven keys: read workspaces, users, content, media, alarm findings and audiences, and use the assistant. Nothing that writes.

The 34 permission keys

The whole catalogue. Every one of them is a string a route can require.

Area Keys
Workspaces workspaces:create · workspaces:read · workspaces:update · workspaces:delete
Users users:read · users:create · users:update · users:delete
Content content:read · content:create · content:update · content:publish · content:approve · content:delete · content:export · content:import
Media media:read · media:create · media:update · media:delete
API tokens tokens:read · tokens:create · tokens:delete
Assistant copilot:use · copilot:skills:manage
Audit activity:read
Alarms alarms:read · alarms:manage — reading findings reaches contributor and viewer; writing the rules stays with admin.
Segments segments:read · segments:manage — who may read a published entry.
Webhooks webhooks:read · webhooks:manage — both admin-only, because an endpoint spans every workspace it names and holds a signing secret.
Publication protection protection:manage — admin-only, and the one key with no read half: writing a rule decides who may ship a content type, while what one entry needs is answered by the entry itself.
Saved views views:share — sharing one, not saving one.

Publishing is its own key, separate from update, and approving is its own key again. Somebody may be trusted to write and not to make it live, or to vouch for somebody else’s work without editing it — distinctions most permission models collapse into one.

Decisions worth knowing about

Contributors can delete, and that is deliberate
Removing the draft that should never have existed, or the wrong upload, is the same editorial act as writing it — and a role that can publish to the world but cannot retract is the more dangerous of the two. Both deletes are audited, and a content type that keeps a tombstone can be restored. The keys are still real: a non-tombstoned entry and an asset’s bytes are gone.
Every role can use the assistant, viewers included
That looks wrong until you see why: the assistant holds exactly the permissions of the person who started it, so a viewer’s assistant is provably read-only. Excluding the largest group of people from the feature would buy no safety at all. Cost is handled with per-role rate limits instead.
Authoring an assistant skill is admin-only
A skill’s instructions are prompt text that then runs for everyone in the workspace, which makes writing one a configuration decision rather than a content one. Using a skill needs nothing beyond the permission every role already holds.
Accounts are invite-only
There is no public registration and no sign-up form. The only route into an account is an administrator’s invite, and the link that carries it is one-time and cannot be used to probe for other live invites.
One role per person, globally
A role is a property of the account, not of a workspace. Where somebody may act is a separate question, answered by workspace membership.

The question your security reviewer will ask

"Show me what a viewer can do." It is one file. Seven permission keys, none of which write, and a test asserting that the set has not grown. They will ask it about the AI too, and the answer is the same file — because a run holds the caller’s keys and there is no service account anywhere to check.

Questions before an access review

Can I make a custom role?

The three are built in and fixed, which is what makes an access review a one-page exercise: there is one list to read and it is the same on every installation. Finer distinctions are usually reachable through workspaces, which decide where each person may act at all.

Can I restrict somebody to one content type?

Not through roles, but partly through workspaces: a workspace is granted specific content types, and membership decides which workspaces somebody reaches. So "this person only touches the careers site" is expressible, and "this person may edit articles but not authors within the same workspace" is not.

What does a refused API call tell the caller?

For a tool call, the name of the permission it wanted. That leaks the shape of the permission model deliberately: the tool set is derived from the caller’s own scope and says nothing about anyone else, and an undiagnosable failure costs more than the key name.

How do API tokens fit?

A token carries a scope — read or full — rather than a role, and is granted to specific workspaces. It is the credential an external site, a script or an MCP agent uses, and it never carries a person’s identity.

Every feature is included, free

The core is MIT licensed and every feature is in it — none of them is paid-only. Community runs free in production; a plan buys room and governance, not a different product.