> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Roles, teams, and permissions

> Orbit's role vocabulary - ten built-in seat roles ranked in a hierarchy, tenant-defined custom roles with strict deny-by-default grants, and the Teams API that backs routing-rule team assignment.

# 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](/concepts/subaccount-organization-model). For the credentials a member presents, see [Authentication and session model](/concepts/authentication-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.

| Role         | Tier | What it is for                                                                                                                                            |
| ------------ | ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `owner`      | 100  | Full authority. Can do everything, including hold at least one reserved path (see Section 4). There is always an owner.                                   |
| `admin`      | 80   | Day-to-day administration without the irreversible paths.                                                                                                 |
| `developer`  | 60   | Builder-tier: send messages, manage flows and campaigns, mint API keys.                                                                                   |
| `billing`    | 40   | Finance-side read and money-moving on billing only. Orthogonal to the hierarchy: no plain-role reach into billing-only routes, and no billing reach back. |
| `viewer`     | 20   | Read-only operator: dashboard and read APIs of nearly everything.                                                                                         |
| `supervisor` | 15   | Contact-center supervisor: flips/barges/monitors live calls on the voice console, queue control, eval scorecards.                                         |
| `marketer`   | 13   | Campaign and data-quality seat.                                                                                                                           |
| `analyst`    | 12   | Computed-traits / analytics read seat.                                                                                                                    |
| `agent`      | 10   | Voice-console agent: queues, call park/flip, hot-desking, paging groups, shared lines.                                                                    |
| `user`       | 5    | Base authenticated seat: push/telemetry self-service.                                                                                                     |

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:

| Method and path                              | What it does                                              | Gate           |
| -------------------------------------------- | --------------------------------------------------------- | -------------- |
| `GET /api/v1/teams`                          | List this org's teams (up to the per-org cap)             | Any org member |
| `POST /api/v1/teams`                         | Create a team (name + optional description + active flag) | `team:invite`  |
| `PATCH /api/v1/teams/{id}`                   | Rename, redescribe, activate/deactivate                   | `team:invite`  |
| `DELETE /api/v1/teams/{id}`                  | Delete a team, cascading its members                      | `team:invite`  |
| `GET /api/v1/teams/{id}/members`             | Keyset-paginated member list with a total badge           | Any org member |
| `POST /api/v1/teams/{id}/members`            | Add a member (`user_id` in the body)                      | `team:invite`  |
| `DELETE /api/v1/teams/{id}/members/{userId}` | Remove a member                                           | `team:invite`  |

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:

| Key               | The role editor shows it as | Why it's sensitive       |
| ----------------- | --------------------------- | ------------------------ |
| `contacts:read`   | View contacts               | No; membership only      |
| `contacts:export` | Export contacts             | Full-list exfiltration   |
| `numbers:release` | Release numbers             | Permanent number removal |
| `billing:read`    | View billing                | No; billing visibility   |
| `billing:manage`  | Manage billing              | Money-moving             |
| `messages:send`   | Send messages               | Outbound write           |
| `campaigns:send`  | Send campaigns              | Bulk launches            |
| `apikeys:create`  | Create API keys             | Credential issuance      |
| `settings:update` | Update settings             | Account-wide mutation    |
| `team:invite`     | Invite team members         | Member management        |

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](/concepts/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](/concepts/authentication-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](/concepts/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

* [The subaccount organization model](/concepts/subaccount-organization-model) - the org-scope sibling.
* [Authentication and session model](/concepts/authentication-model) - the credential-resolution sibling.
* [Tenant isolation](/concepts/tenant-isolation) - the hard boundary underneath.
