Every project-scoped route is gated by a permission string. A caller's project role maps to a fixed set of permissions; a route demands one permission and rejects any caller whose role does not hold it. The matrix is hardcoded — changing it requires a deploy, which keeps it auditable.
There are four roles and a flat vocabulary of permission strings. This page is the lookup reference for both.
A caller acts on a project with exactly one role, set on their project
membership (or carried by a project API token). The role is resolved by
projectAuthMiddleware before any permission check runs.
| Role | Intent |
|---|---|
owner | Full access. The project's top authority. |
admin | Full access; identical to owner today, kept distinct for future org-level differentiation. |
member | Reads the catalog; reads and writes data; cannot change the catalog, secrets, tokens, or apply. |
viewer | Read-only across catalog and data. No writes, no secrets, no tokens. |
The same four roles also scope organization membership and project API
tokens (the member_role enum: owner, admin, member, viewer).
A project member can additionally be restricted to a single environment
(envId) or to specific cells (cellScopes). These scopes are orthogonal
to the role: they limit which entities a caller sees, while the role limits
what actions they may take. Reads outside a cell scope return 404; writes
outside it return 403.
Permissions are flat resource:action strings. The full vocabulary:
| Permission | Grants |
|---|---|
catalog:read | Read entity types, cells, relationship types, actions, secrets metadata, catalog revisions, audit log. |
catalog:write | Create / update / delete catalog kinds (entity types, cells, relationship types, actions, secret slots). |
data:read | Read entities, relationships, action runs, stats, drift events. |
data:write | Create / update / delete entities and relationships; run actions; resolve drift events. |
secrets:read | Read secret metadata (never plaintext values). |
secrets:write | Create / update / delete secret slots. |
secrets:set-value | Set a secret's encrypted value. Owner/admin only. |
apply:write | Run apply, roll back a catalog revision, nuke the project. Admin+ only. |
tokens:manage | Create / list / revoke project API tokens. Owner/admin only. |
graph:read | Read saved graph views. Granted to every role. |
graph:write | CRUD personal saved views. Member and above. |
graph:manage | Project-scope saved-view CUD. Admin/owner only. |
Each ✓ is a permission the role holds. Grounded in the hardcoded map in
@terrantula/rbac.
| Permission | owner | admin | member | viewer |
|---|---|---|---|---|
catalog:read | ✓ | ✓ | ✓ | ✓ |
catalog:write | ✓ | ✓ | ||
data:read | ✓ | ✓ | ✓ | ✓ |
data:write | ✓ | ✓ | ✓ | |
secrets:read | ✓ | ✓ | ||
secrets:write | ✓ | ✓ | ||
secrets:set-value | ✓ | ✓ | ||
apply:write | ✓ | ✓ | ||
tokens:manage | ✓ | ✓ | ||
graph:read | ✓ | ✓ | ✓ | ✓ |
graph:write | ✓ | ✓ | ✓ | |
graph:manage | ✓ | ✓ |
So in plain terms:
viewer can read everything in the catalog and data plane and view saved
graph views — nothing more.member adds data writes (entities, relationships, running actions,
resolving drift events) and personal saved-view CRUD, but still cannot touch
the catalog schema, secrets, tokens, or apply.admin and owner hold every permission.Two middleware factories from @terrantula/rbac enforce the matrix. Both run
after projectAuthMiddleware has set the caller's project role on the
request context.
requirePermissionDemands one explicit permission. The caller's role must include it, or the
request gets 403 Forbidden.
requireMethodPermissionA convenience for routes whose read/write split falls cleanly along the HTTP
method: GET requires the read permission, everything else requires the write
permission.
The permission each project-scoped route family demands. (The API Reference lists the exact permission on every endpoint.)
| Route family | Read | Write |
|---|---|---|
entity-types, cells, relationship-types, actions | catalog:read | catalog:write |
catalog-revisions | catalog:read | apply:write (rollback) |
audit-events | catalog:read | — |
secrets | secrets:read | secrets:write |
secrets/:name/value | — | secrets:set-value |
entities, relationships | data:read | data:write |
action-runs | data:read | — |
drift-events | data:read | data:write (accept / reapply / snooze) |
actions/:name/run | — | data:write |
stats | data:read | — |
apply | — | apply:write |
tokens | — | tokens:manage |
nuke | — | apply:write |
graph-views | graph:read | graph:write (+ graph:manage for project-scope) |
The /webhooks route family is authenticated externally via HMAC and is not
gated by the role matrix. It carries its own signature check instead. See
Webhooks.
There is no runtime permission editor. The role-to-permission map lives in code
in @terrantula/rbac; altering who can do what requires a code change and a
deploy. This is intentional — it keeps the matrix auditable and prevents
privilege drift.
admin and ownerare identical today
They hold exactly the same permissions. The roles are kept distinct only to
leave room for future org-level differentiation. Don't rely on a behavioral
difference between them.
Resolving a drift event needs only data:write. It writes Terrantula's own
catalog — it never runs terraform apply, so it carries no separate
infrastructure-mutation permission. Pushing a value back to real infrastructure
flows through Actions → PRs → your CI.
data:read / data:write gate on the drift surface.