Skip to main content

Roles, teams, and permissions

Every request you make to Orbit resolves an identity, and every member of your organization carries a role. Between the two the platform decides what that member may do. This page defines the role vocabulary, the permission model that gates routes on it, and the Teams feature that groups members for routing - so when a member is denied with 403 you know which layer refused them, and when you design access you put a person on the right seat. This page governs within-org access control. For which organization a request resolves to, see The subaccount organization model. For the credentials a member presents, see Authentication and session model.

Section 1 - The built-in role vocabulary

Ten named roles exist. Every member of your org is assigned exactly one, and that assignment is the anchor for what the route gates allow. Three design points become intuitive once you see them:
  • The hierarchy is a floor, and sub-viewer roles sit deliberately below the viewer. A route that names one role in requireRole(...) admits every role ranked at or above that role’s level. Because supervisor, marketer, analyst, agent, and user rank below viewer, none of them inherits the viewer-tier reads; each satisfies ONLY the routes that explicitly list it.
  • billing is orthogonal. Its own route set either names billing explicitly or it does not admit it by hierarchy at all - requireRole treats the role as a special case, so a disconnect between “money” and “management” is preserved on both directions.
  • The least-privilege seats were added for operator tiers, and none of them can alter outbound voice/SMS routing. Outbound termination configuration stays on owner/admin/developer alone - the operator seats reach reading and console actions, never provider or trunk configuration.

Section 2 - How gates work: requireRole, requireGranularPermission

Two gate families sit between the route’s route handler and its logic; both read the resolved member’s role from the request context the auth middleware populated. Both return the same 401/403 envelope. requireRole(...) - floor check. The route declares the roles it allows. The gate computes the user’s role level and admits them if that level is at or above the level of any allowed role (billing handled as the special case described in Section 1). Most Orbit routes gate this way. requireGranularPermission("<key>") - capability check, custom-role aware. The route declares a single whitelisted permission key (a resource:action string such as team:invite) and the evaluator decides:
  • If the member is assigned a custom role (see Section 4), evaluation is strict and deny-by-default: the request passes iff the role’s granted set contains the key. The custom role replaces the member’s underlying fixed role for this check - it does not union with it.
  • If the member is on a built-in role, evaluation consults a fixed grant table that mirrors the platform’s canonical permission table for every key that overlaps it, and adds explicit least-privilege grants for new keys. Behaviour for every built-in-role member is identical to what the corresponding built-in role always allowed.
That is the whole point of the layer: wiring the Teams write routes’ requireGranularPermission("team:invite") onto the owner/admin routes was behaviour-preserving for built-in members; it only added the custom-role-aware branch. A super-admin (break-glass / platform staff) always passes either gate, and the 401/403 envelope is identical to every other gate.

Section 3 - Teams: the endpoint vocabulary

A team is a named group of org members. The routing rule action assign_team targets a team, so inbound messages spread across a group rather than defaulting to whoever is unlucky enough to be unassigned at the moment. The endpoints are: Writes all pass through team:invite, so on built-in roles that is owner/admin. Where the custom role is in play, the member’s custom grant must include team:invite. Reads are any-member, so the inbox “assign to team” picker works for your agents too. A routing rule that picks assign_team resolves a target team, finds its active members, and picks the one with the fewest currently-open assigned conversations - a round-robin/fairness ordering by (open count, last_assigned_at, user_id). That picked member becomes assigned_to, and the team’s last_assigned_at bumps as a fairness aid. When the team has no active members the action refuses to mis-assign: it posts an internal note and leaves the conversation unassigned.

Section 4 - Custom roles: deny-by-default grants

A custom role is a tenant-named role you define in the dashboard: you give it a name (for example, “Support read-only”) and a set of whitelisted permission keys it grants. Assign a member to it and the granular gates evaluate them strictly against that set - replacing the member’s built-in role, not unioning with it. Ten high-sensitivity capability keys are whitelisted today, and the set grows only as more sensitive abilities move into it: Two guardrails keep custom roles safe:
  • The route layer refuses to put the org owner on a custom role. The owner must never be locked out of recovery actions, so custom roles are a member-level tool, and assigning one to an owner is rejected.
  • A persisted role can never grant a capability outside the whitelist. The permissions payload you save is re-filtered to the whitelist at write time, so a typo or a rogue payload hash stays inert.
Deny-by-default is the load-bearing property: an empty grant set grants nothing and a member on a custom role can never hold a capability the role did not explicitly check. That is what makes “Support read-only” actually read-only.

Section 5 - Where roles meet the subaccount model

Roles are a user-level tool within one organization; subaccounts are an org-scope tool within one tenant. The two intersect at the org-resolving credential and stay orthogonal from there.
  • The user’s role is scoped to the org their request resolves to. A member admin of one subaccount holds that role only on requests its API key resolves to that subaccount; they do not become admin of the parent.
  • Subaccounts share one tenant schema, so org ownership gates reads/writes; roles gate capabilities. No role widens a caller beyond their org, and the org-ownership gate never admits another org’s row. The two gates stack; they never compose into a wider one.
  • Teams are org-scoped and route-scoped. A team belongs to the org that created it (the table is query-plan scoped to the org’s tenant schema), and the assign_team routing rule that references it belongs to the same org’s routing rules.
Rule of thumb: subaccounts decide which org’s rows a request can reach; roles and teams decide what the member of that org can do with them.

Section 6 - Where this model hands off to other pages

This model is deliberately narrow - capabilities within one org. Three sibling pages own the parts this one references:
  • The subaccount organization model - the org tree, per-org attribution, and cross-subaccount visibility. Subaccounts are soft scope; roles are hard (within-org) scope.
  • Authentication and session model - which credential class a request carries and how the server resolves it. Roles matter only after a credential has resolved a member.
  • Tenant isolation - the hard boundary above all of this. Custom roles and teams live inside one tenant’s schema; they never cross it.

Cross-references