Apograph has one global role per user and a flat catalogue of resource:action
permission keys. Workspace membership is a separate axis deciding which
workspaces a user can reach.
The catalogue
| Key | Admin | Contributor | Viewer |
|---|---|---|---|
workspaces:create | ✅ | — | — |
workspaces:read | ✅ | ✅ | ✅ |
workspaces:update | ✅ | — | — |
workspaces:delete | ✅ | — | — |
users:read | ✅ | ✅ | ✅ |
users:create | ✅ | — | — |
users:update | ✅ | — | — |
users:delete | ✅ | — | — |
activity:read | ✅ | — | — |
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 | ✅ | — | — |
Thirty-four keys, three roles: admin holds all thirty-four, contributor
eighteen, viewer seven. content:export and content:import arrived with the
transfer plugin in 0.4.0; the alarms:*,
segments:* and views:share keys came with alarms,
segments and saved views
in 0.4.3, which is also the release that granted contributor the two delete
keys; the webhooks:* pair came with webhooks in 0.5.0;
and content:approve and protection:manage came with publication
protection.
The seeder reconciles the catalogue in both directions on every boot, so both new keys reach an existing installation by deploying — no migration, and no manual step.
The roles at a glance
| Role | Can | Cannot |
|---|---|---|
| Administrator | Everything | — |
| Contributor | Create, edit, publish, approve and delete content; export and import it; upload, edit and delete media; share a saved view | Manage users, mint tokens, read the audit log, write alarm or protection rules, manage audiences, or see webhooks at all |
| Viewer | Read content, media, users and workspaces, and see which alarms and audiences apply | Change anything |
Admin holds the enumerated set rather than a wildcard, by decision. A new permission has to be added both to the catalogue and to admin’s grants — which means adding one cannot silently widen anybody’s access.
The grants worth explaining
copilot:use is granted to every role, viewer included. The copilot has no
authority of its own, so a viewer’s copilot is provably read-only — it can only
ever offer the tools that viewer’s own permissions already allow. Excluding the
largest population from the feature to control cost would be solving the wrong
problem; cost is handled with rate limits.
copilot:skills:manage is admin-only. A skill’s instructions are prompt text
that runs for every member of the workspace, so authoring one is a configuration
decision rather than a content one. Using a skill needs nothing beyond
copilot:use.
alarms:read and segments:read reach the viewer, their manage halves stop
at admin. Both reads are there so an editor is not working blind: findings show
inline in the entry editor, and an editor who cannot see that an entry is
restricted will publish one believing it is public. Both writes are
configuration rather than editing — a rule is editorial policy, and renaming a
segment’s tags changes who every entry naming it is visible to.
content:approve reaches the contributor; protection:manage stops at
admin. Approving is an editorial act — the statement that a second person read
the thing — so it lands on the role that edits rather than on a reviewer role
this model does not have, and it is withheld from viewers for the same reason.
Writing the rule is configuration of the same class as alarms:manage: it
decides who may ship a whole content type. There is no protection:read beside
it, the only asymmetry in this catalogue: a contributor does need to know that
this entry wants two approvals, and that answer comes from the entry under
content:read, not from the workspace’s rule table.
No API token scope grants content:approve. A token names nobody, and
withholding an approve tool from every agent surface buys nothing if minting a
key casts the vote the tool may not. See publication
protection.
views:share gates sharing a saved view, not saving one. Every role can
save a private view; that is a personal bookmark over content they can already
read. A shared view becomes navigation for the whole workspace, which is an
editorial decision. Viewers are not blocked from anything by its absence.
content:delete and media:delete reach the contributor. 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 are audited, and a
paranoid content type deletes to a tombstone that restore undoes; a
non-paranoid entry and an asset’s bytes are gone, so this is a real grant.
Both webhooks:* keys stop at admin — even the read. Stricter than the
other read/manage splits, deliberately: an endpoint is not scoped to a
workspace, it reaches across every workspace it names, and its delivery log
records where this installation talks to on the network. That is
infrastructure configuration in the same family as an API token, not something
an editor is already working on, so contributors do not see the page at all.
API token scopes
A token’s scope is turned into a permission set and evaluated by the same policy that evaluates a user’s. A route’s requirement means the same thing whichever kind of caller arrives.
| Scope | Grants |
|---|---|
read | content:read |
full | The complete content CRUD set, plus media:create and media:read |
media:update and media:delete are withheld from tokens deliberately:
attaching an image to a record is content authoring, but curating the library is
administration.
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. The three are built in; there is no role editor and no way to define a fourth.
- No per-type permissions.
content:updatecovers every content type in every workspace the user belongs to. - No per-entry or field-level permissions. No ownership model, no “edit only what you created”, no locked fields.
- No role for reviewing. Approving is a permission
(
content:approve) rather than a role, and a protection rule counts approvals from anybody who holds it except the author of the version being published — there is no assignment deciding whose approval counts, and no state between draft and published.
Workspace membership is the only tool for narrowing scope: put the content somebody should not reach in a workspace they are not a member of.
Enforcement
Server-side, @RequirePermissions plus a guard, evaluated against a pure policy.
In the admin, a hook gates navigation and controls.
Hiding a control is a courtesy; the server decides. A plugin author should gate the UI and declare the permission on the route — see server plugins.