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 | Everything admin holds, plus project:delete. The project's top authority. |
admin | Runs the project: catalog, secrets, apply, tokens, and the member roster. Cannot delete the project. |
write | Reads the catalog; reads and writes data; cannot change the catalog, secrets, tokens, or apply. |
read | Read-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.
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.
Permissions are flat resource:action strings. The full vocabulary:
| Permission | Grants |
|---|---|
catalog:read | Read 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:read | Read entities, relationships, action runs, stats, drift events. |
actions:trigger | Trigger an action — collection-scope (actions/:name/run) or on one entity. Emits change intent to your execution surface. |
actions:cancel | Cancel a pending action run. |
observations:write | Import an observation payload; stamp an entity's last sync. |
metrics:write | Set a manual / push metric value on an entity. |
drift:resolve | Accept, reapply, or snooze a drift event. |
conformance:triage | Ignore or un-ignore a conformance finding. |
import-sources:write | Register or rescan an import source; approve or reject its drift proposals. |
relationships:finalize | Force-finalize a relationship deletion, skipping the remaining grace period. |
secrets:read | Read 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-value | Set a secret's encrypted value. Admin and owner only. |
apply:write | Run apply, roll back a catalog revision, nuke the project. Admin and owner only. |
tokens:manage | Create / list / revoke project API tokens. Admin and owner only. |
graph:read | Read saved graph views. Granted to every role. |
graph:write | CRUD personal saved views. write and above. |
graph:manage | Project-scope saved-view CUD. Admin and owner only. |
members:manage | Add / 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:delete | Delete the project and everything in it. Owner only — this single permission is the whole difference between admin and owner. |
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.
| Permission | owner | admin | write | read |
|---|---|---|---|---|
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.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.
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:
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.
The permission each project-scoped route family demands. (The API Reference lists the exact permission on every endpoint.)
| Route family | Read | Write |
|---|---|---|
catalog | catalog:read | — (the catalog is written by apply, never here) |
apply | — | apply:write |
catalog-revisions | catalog:read | apply:write (rollback) |
audit-events | catalog:read | — |
membership, placement | data:read | — (both are projections; there is nothing to write) |
secrets/values, secrets/:name/value | secrets:read | secrets:set-value (PUT) |
entities | data:read | metrics:write (metrics) / observations:write (sync-stamp) / actions:trigger (instance actions) — direct CRUD writes were removed; entity rows change through apply and Actions |
relationships | data:read | relationships:finalize (finalize-deletion) — same story: relationship rows change through apply and Actions |
action-runs | data:read | actions:cancel (cancel) |
drift-events | data:read | drift:resolve (accept / reapply / snooze) |
conformance-findings | data:read | conformance:triage (ignore / un-ignore) |
import-sources | data:read | import-sources:write (register / rescan / approve / reject); apply:write to delete |
observations, import | data:read | observations:write |
actions/:name/run | — | actions:trigger |
stats | data:read | — |
apply | — | apply:write |
tokens | — | tokens:manage |
nuke | — | apply:write |
graph-views | graph:read | graph: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:
| Route | Permission |
|---|---|
DELETE /orgs/:orgId/projects/:projectId | project:delete — org owner only, since org admin maps to project admin, which does not hold it |
POST / DELETE …/projects/:projectId/members | members: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. |
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 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.
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 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.
data:read / drift:resolve gate on the drift surface.