RBAC roles & permissions

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.

Roles

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.

RoleIntent
ownerFull access. The project's top authority.
adminFull access; identical to owner today, kept distinct for future org-level differentiation.
memberReads the catalog; reads and writes data; cannot change the catalog, secrets, tokens, or apply.
viewerRead-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).

Scoped membership narrows access further

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.

Permission strings

Permissions are flat resource:action strings. The full vocabulary:

PermissionGrants
catalog:readRead entity types, cells, relationship types, actions, secrets metadata, catalog revisions, audit log.
catalog:writeCreate / update / delete catalog kinds (entity types, cells, relationship types, actions, secret slots).
data:readRead entities, relationships, action runs, stats, drift events.
data:writeCreate / update / delete entities and relationships; run actions; resolve drift events.
secrets:readRead secret metadata (never plaintext values).
secrets:writeCreate / update / delete secret slots.
secrets:set-valueSet a secret's encrypted value. Owner/admin only.
apply:writeRun apply, roll back a catalog revision, nuke the project. Admin+ only.
tokens:manageCreate / list / revoke project API tokens. Owner/admin only.
graph:readRead saved graph views. Granted to every role.
graph:writeCRUD personal saved views. Member and above.
graph:manageProject-scope saved-view CUD. Admin/owner only.

The role-to-permission matrix

Each ✓ is a permission the role holds. Grounded in the hardcoded map in @terrantula/rbac.

Permissionowneradminmemberviewer
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.

How routes are gated

Two middleware factories from @terrantula/rbac enforce the matrix. Both run after projectAuthMiddleware has set the caller's project role on the request context.

requirePermission

Demands one explicit permission. The caller's role must include it, or the request gets 403 Forbidden.

// Apply is write-only and admin+.
app.use('/:o/:p/envs/:envName/apply/*', requirePermission('apply:write'))

requireMethodPermission

A convenience for routes whose read/write split falls cleanly along the HTTP method: GET requires the read permission, everything else requires the write permission.

// Reads need data:read (all roles); writes need data:write (member+).
app.use(
  '/:o/:p/envs/:envName/entities/*',
  requireMethodPermission('data:read', 'data:write'),
)

Permission-to-route map

The permission each project-scoped route family demands. (The API Reference lists the exact permission on every endpoint.)

Route familyReadWrite
entity-types, cells, relationship-types, actionscatalog:readcatalog:write
catalog-revisionscatalog:readapply:write (rollback)
audit-eventscatalog:read
secretssecrets:readsecrets:write
secrets/:name/valuesecrets:set-value
entities, relationshipsdata:readdata:write
action-runsdata:read
drift-eventsdata:readdata:write (accept / reapply / snooze)
actions/:name/rundata:write
statsdata:read
applyapply:write
tokenstokens:manage
nukeapply:write
graph-viewsgraph:readgraph:write (+ graph:manage for project-scope)
Webhooks bypass RBAC

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.

Caveats

The matrix is hardcoded and a deploy is the only way to change it

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.

NOTE

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 drift never escalates infrastructure access

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.