Skip to content

Roles and permissions

Authorization has two layers that are easy to confuse. Roles belong to users and travel in their tokens. Scopes belong to API keys and bound what a machine caller may do. Both draw from one permission set.

Twenty-one permissions, defined centrally rather than inline at each route:

GroupPermissions
Organizationorg:read, org:write
Membersmembers:read, members:write
Credentialscredentials:read, credentials:issue, credentials:revoke
Auditaudit:read
Webhookswebhooks:read, webhooks:write
API keysapikeys:read, apikeys:write
Agentsagents:read, agents:write
SSOsso:read, sso:write
Log streamslogstreams:read, logstreams:write
Connected accountsconnect:read, connect:write, connect:tokens

* is a wildcard meaning every permission, present and future.

Note that connect:tokens is separate from connect:write. Retrieving a live third-party access token from the vault is a different act from configuring providers, and it is the more sensitive one — so an agent’s key can be scoped to token retrieval alone, without the ability to add a provider or disconnect an account.

Every new organization gets two roles:

RolePermissions
admin*
memberorg:read, members:read

member is deliberately close to read-only: seeing the org and the roster is not the same as being able to issue credentials or mint keys, and making that explicit means a new member cannot quietly inherit issuance rights.

Assign a role through the management API:

POST /v1/organizations/org_01J…/members/usr_…/role
Authorization: Bearer sk_live_…
{ "role": "admin" }

A caller cannot grant a role carrying permissions it does not itself hold. Attempting it returns:

{ "error": "insufficient_scope", "detail": "role grants permissions beyond the caller's" }

When a user authenticates and belongs to an org, their ID token and access token carry both the org and the role:

{
"sub": "usr_01J…",
"org_id": "org_01J…",
"roles": ["admin"],
"scope": "openid profile"
}

Your application authorizes on roles; the platform authorizes its own /v1 surface on API-key scopes. A user’s role does not grant access to /v1 — that always requires a key.

A key is created with an explicit scope list, and two rules bound it.

No escalation. A key may only mint keys at or below its own privilege. Requesting a scope the calling key does not hold returns 403 insufficient_scope. A key without * can never create a key with *.

No unknown scopes. A scope string that is not a known permission returns 400 invalid_scope rather than being stored. A typo fails at creation instead of producing a key that silently authorizes nothing, and it stops a future permission name from being squatted today.

POST /v1/organizations/org_01J…/api_keys
Authorization: Bearer sk_live_…
{ "name": "issuer-service", "scopes": ["credentials:issue", "credentials:read"] }

Omitting scopes copies the calling key’s own scopes, which is convenient and usually too broad — name the permissions the service actually needs.

A key is bound either to one organization or to the platform. An org-bound key that addresses another org is refused with 403 forbidden even when it holds the right permission, and audit queries from it are clamped to its own org regardless of the organization_id parameter passed.

This is enforced at every route, not by convention: the same guard checks the permission and the org binding together, so adding an endpoint without an org check is not something that can be forgotten in one place and remembered in another. See multi-tenancy for the rest of the isolation model.

Users who arrive by SCIM or by SSO just-in-time provisioning are added to the org with the member role.

Directory groups sync as groups — they are stored, listable, and patchable through /scim/v2/Groups — but they do not currently confer a role. Promoting a provisioned user to admin is an explicit grant through POST /v1/organizations/:orgId/members/:userId/role, whether you make it from your own provisioning code or a human makes it in the console.