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
ownerEverything admin holds, plus project:delete. The project's top authority.
adminRuns the project: catalog, secrets, apply, tokens, and the member roster. Cannot delete the project.
writeReads the catalog; reads and writes data; cannot change the catalog, secrets, tokens, or apply.
readRead-only across catalog and data. No writes, no secrets, no tokens.

These four names are the project_role enum, and they scope project membership and project API tokens. Organization membership uses a separate vocabulary — owner, admin, member — owned by the auth layer, not by this matrix. The two namespaces are deliberately distinct: an org admin is not a project admin, and an org role grants no project-scoped access at all (projectAuthMiddleware resolves the role from the caller's project membership or token, never from their org role). Org role is consulted only on the org-scoped project-administration routes — deleting a project and managing its member roster — where an org owner acts with project owner capability and an org admin with project admin capability.

Scoped membership narrows access further

A project member can additionally be restricted to specific cells (cellScopes). This scope is orthogonal to the role: it limits 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 the catalog document (GET /catalog) — entity types, cells, relationship types, actions, deployment targets and sets, secret declarations — plus catalog revisions and the audit log.
data:readRead entities, relationships, action runs, stats, drift events.
actions:triggerTrigger an action — collection-scope (actions/:name/run) or on one entity. Emits change intent to your execution surface.
actions:cancelCancel a pending action run.
observations:writeImport an observation payload; stamp an entity's last sync.
metrics:writeSet a manual / push metric value on an entity.
drift:resolveAccept, reapply, or snooze a drift event.
conformance:triageIgnore or un-ignore a conformance finding.
import-sources:writeRegister or rescan an import source; approve or reject its drift proposals.
relationships:finalizeForce-finalize a relationship deletion, skipping the remaining grace period.
secrets:readRead whether each secret has a value set (never the plaintext). A secret's declaration — its name and description — is part of the catalog document and reads through catalog:read.
secrets:set-valueSet a secret's encrypted value. Admin and owner only.
apply:writeRun apply, roll back a catalog revision, nuke the project. Admin and owner only.
tokens:manageCreate / list / revoke project API tokens. Admin and owner only.
graph:readRead saved graph views. Granted to every role.
graph:writeCRUD personal saved views. write and above.
graph:manageProject-scope saved-view CUD. Admin and owner only.
members:manageAdd / remove project members. Admin and owner only — and capped: a member-manager can neither grant a role senior to their own nor touch a member who already holds one.
project:deleteDelete the project and everything in it. Owner only — this single permission is the whole difference between admin and owner.

The role-to-permission matrix

Each ✓ is a permission the role holds. The roles are declared as composed sets in PROJECT_ROLE (@terrantula/types) — read ⊂ write ⊂ admin ⊂ owner — and the permission guard every route's contract() declaration derives imports that declaration rather than keeping its own copy.

Permissionowneradminwriteread
catalog:read✓✓✓✓
data:read✓✓✓✓
actions:trigger✓✓✓
actions:cancel✓✓✓
observations:write✓✓✓
metrics:write✓✓✓
drift:resolve✓✓✓
conformance:triage✓✓✓
import-sources:write✓✓✓
relationships:finalize✓✓✓
secrets:read✓✓
secrets:set-value✓✓
apply:write✓✓
tokens:manage✓✓
graph:read✓✓✓✓
graph:write✓✓✓
graph:manage✓✓
members:manage✓✓
project:delete✓

So in plain terms:

  • read can read everything in the catalog and data plane and view saved graph views — nothing more.
  • write adds data writes (entities, relationships, running actions, resolving drift events) and personal saved-view CRUD, but still cannot touch the catalog schema, secrets, tokens, apply, or the member roster.
  • admin holds every permission except project:delete.
  • owner holds every permission.

How routes are gated

Every project-scoped route is built with contract() (packages/api/src/lib/contract.ts) — a single declaration per route that is simultaneously its OpenAPI documentation and its enforcement. There is no middleware to mount by hand: a route declares its permission, and contract() derives the guard from that declaration and runs it after projectAuthMiddleware has set the caller's project role on the request context.

// Apply is write-only and admin+.
const applyContract = {
  operationId: 'applyChanges',
  auth: 'project',
  permission: 'apply:write',
  // ...
} satisfies ProjectContract
app.post('/:o/:p/apply', ...contract(applyContract), handler)

A route whose read/write split falls cleanly along the HTTP method declares the permission per method — one contract() call per operation, each with its own permission:

// GET needs secrets:read (admin and above); PUT needs secrets:set-value.
const getStatusContract = { operationId: 'getSecretValueStatus', auth: 'project', permission: 'secrets:read', /* ... */ } satisfies ProjectContract
const setValueContract = { operationId: 'setSecretValue', auth: 'project', permission: 'secrets:set-value', /* ... */ } satisfies ProjectContract
app.get('/:o/:p/secrets/:name/value', ...contract(getStatusContract), handler)
app.put('/:o/:p/secrets/:name/value', ...contract(setValueContract), handler)

The permission guard itself (requirePermission) is not a public export of any package — it lives inside packages/api/src/lib/require-permission.ts, package-private to packages/api, and contract() is its only caller. A route file can declare a permission; it cannot mount the guard by hand.

Permission-to-route map

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

Route familyReadWrite
catalogcatalog:read— (the catalog is written by apply, never here)
apply—apply:write
catalog-revisionscatalog:readapply:write (rollback)
audit-eventscatalog:read—
membership, placementdata:read— (both are projections; there is nothing to write)
secrets/values, secrets/:name/valuesecrets:readsecrets:set-value (PUT)
entitiesdata:readmetrics:write (metrics) / observations:write (sync-stamp) / actions:trigger (instance actions) — direct CRUD writes were removed; entity rows change through apply and Actions
relationshipsdata:readrelationships:finalize (finalize-deletion) — same story: relationship rows change through apply and Actions
action-runsdata:readactions:cancel (cancel)
drift-eventsdata:readdrift:resolve (accept / reapply / snooze)
conformance-findingsdata:readconformance:triage (ignore / un-ignore)
import-sourcesdata:readimport-sources:write (register / rescan / approve / reject); apply:write to delete
observations, importdata:readobservations:write
actions/:name/run—actions:trigger
statsdata:read—
apply—apply:write
tokens—tokens:manage
nuke—apply:write
graph-viewsgraph:readgraph:write (+ graph:manage for project-scope)

Two more permissions are enforced off this surface, on the org-scoped project routes (/orgs/:orgId/projects/:projectId…), because they act on the project itself rather than on anything inside it. They are checked inside the handler rather than by requirePermission middleware, but the answer comes from the same PROJECT_ROLE declaration:

RoutePermission
DELETE /orgs/:orgId/projects/:projectIdproject:delete — org owner only, since org admin maps to project admin, which does not hold it
POST / DELETE …/projects/:projectId/membersmembers:manage — org owner/admin, or a project admin/owner, capped at the caller's own role. The target must already be a member of the org.
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 is the PROJECT_ROLE declaration in @terrantula/types, which the permission guard contract() derives (packages/api/src/lib/require-permission.ts), the schema registry, and the UI all read; 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 owner differ by exactly one permission owner is admin plus project:delete. Nothing else separates them, and admin is composed from write the same way, so a role cannot silently drift out of the ladder. A project admin deliberately cannot delete the project — and, because their permission set can never dominate owner's, they also cannot grant, demote, or remove a project ownervia the member routes.

Resolving drift never escalates infrastructure access

Resolving a drift event needs only drift:resolve. 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.