AuralisDocs

Roles and permissions

Two independent axes: the workspace role a caller holds, and the scopes a key carries. Both must clear.

Authorisation here has two axes, checked separately, and a request has to clear both:

  1. The workspace role of the person the call acts as — viewer, editor, admin or owner. Every guarded operation declares a floor.
  2. The scopes on the credential, if it is an API key. Scopes decide which operations a key may reach; they never grant authority.

A key that holds * but belongs to a viewer still cannot create a ticket. A person who is an owner still cannot use a key scoped only to tickets:read to read customers. Neither axis substitutes for the other.

The four roles

They are a strict ladder. Each role can do everything the one below it can.

RoleRankIn practice
viewer0Reads everything in the workspace. Changes nothing.
editor1The working agent: creates and edits tickets, replies, applies macros, manages todos and time.
admin2Configuration and people: deletes things, mints keys, manages members, reads audit and billing.
owner3Everything an admin can do, plus one rule below.

A floor means "this role or higher". The role is re-read from the membership record on every request — there is no cached role and no token claim to trust.

What an owner can do that an admin cannot

Exactly one rule, and it is worth knowing because it is not a list of routes:

Nobody may grant a role above their own.

An admin cannot make someone an owner, and cannot invite someone as an owner. Both attempts are refused identically:

{
  "role": "admin",
  "required_role": "owner",
  "error": "insufficient_role",
  "message": "role 'admin' cannot grant role 'owner'"
}

There is no owner-only route. No billing gate, no workspace-delete operation — an admin reaches everything an owner reaches. The difference is only in what role they may confer.

Two related protections, both 409:

  • The last owner cannot be demoted — cannot demote the last owner.
  • The last owner cannot be removed — cannot remove the last owner.

The three refusals, and how to tell them apart

ResponseMeaning
401 identity_requiredThe operation needs a person and none was resolved.
403 insufficient_roleA known member, below the floor. Carries role and required_role.
404 user_not_foundNot a member of this workspace — or the workspace does not exist. The two are byte-identical on purpose.

A read needs a workspace, but not necessarily a person

At the viewer floor no actor is required at all: a request that names a workspace and no user is served. Every floor above viewer demands a resolvable identity. This is why a broken user id gives you 404 on a read but 401 on a write.

The floors, by group

Every published operation and the floor it sits at.

Two floors in the generated reference are being corrected

POST /v1/tickets/bulk is enforced at editor, not admin, and GET /v1/csat/aggregate is enforced at admin, not viewer. The table below states the enforced floors. Where an operation page still disagrees, trust this page — these were verified against the running runtime.

Tickets and the ticket rail

FloorOperations
viewerlist_tickets, get_ticket, list_todos, list_time_logs
editorcreate_ticket, patch_ticket, add_event, merge_ticket, reopen_ticket, bulk_update, create_todo, patch_todo, delete_todo, create_time_log, delete_time_log

Customers

FloorOperations
viewerlist_customers, get_customer
editorcreate_customer, patch_customer
admindelete_customer

Helpdesk operations — teams, macros, SLA, saved views, squads

FloorOperations
viewerevery list_* and get_*
editorevery create_* and patch_*, plus apply_macro and set_team_members
adminevery delete_*, plus team_deletion_impact

The pattern is consistent: read at viewer, write at editor, delete at admin.

Channels

FloorOperations
viewerlist_channels, get_channel, get_inbound_address, get_mailbox, get_sender, get_sending_domain
editorupsert_channel_connection
admineverything that mints, rotates, deletes or verifies — addresses, mailboxes, sending domains, OAuth
publicingest_channel_message, google_oauth_callback

Workspace administration

FloorOperations
viewerlist_workspace_members, settings_overview, list_members, list_phone_numbers
adminaudit events, billing, sessions, the security allowlist, member patch and remove, MCP access

Invitations

FloorOperations
publicpreview_invitation, claim_invitation
identityaccept_invitation
adminlist_invitations, issue_invitation, resend_invitation, revoke_invitation

Everything else

FloorOperations
viewernotifications (including mark_read), get_my_tenant, time_tracking_summary, all help-centre staff reads
editorpatch_my_tenant_onboarding, patch_article, post_adopt_conflict, team_round_robin_assign
adminpatch_my_tenant, put_brand, the four API-key operations, csat_aggregate
publicall seven public help-centre reads, submit_csat, ingest_email, widget_create_ticket, receive_webhook

Scopes

Scopes apply only to API keys. A session token has no scopes; it is bounded by role alone.

The grammar

A scope is derived from the operation's tag and an action:

<tag>:read      <tag>:write      <tag>:*      *

There is no fixed list of scope strings in the product — the set is derived from the live route table, so it is <tag>:read, <tag>:write or <tag>:* for any of the runtime's 67 tags, plus * for everything. That is a little over two hundred valid strings.

The 17 tags in this reference are a subset. A key can be scoped to tags this reference does not document.

Which scope an operation needs

The action follows the role floor, not the HTTP method:

  • Floor is viewer → the operation needs <tag>:read.
  • Floor is anything higher → the operation needs <tag>:write.

An admin-floored GET needs a write scope

This catches people. GET /v1/workspace/audit-events sits at the admin floor, so its scope is workspace-admin:write even though it only reads. A key built to read audit events must hold a write scope. The same applies to the API-key operations (admin-resources:write), invitations (invitations:write) and team_deletion_impact (helpdesk-ops:write).

How grants match

  • * grants everything.
  • <tag>:* grants both actions on that tag.
  • <tag>:write implies <tag>:read — on the same tag only.
  • Nothing implies anything across tags.
# A key scoped ["tickets:write"] only:
GET  /v1/tickets    # 200 — write implies read
POST /v1/tickets    # 200 — direct match
GET  /v1/customers  # 403 api_key_out_of_scope: needs 'customers:read'

Scopes are case-sensitive, and a key may carry at most 64 of them.

A key with no scopes reaches nothing

Minting a key without a scopes array gives it [], and [] is refused at every operation before routing even happens:

{
  "error": "api_key_unscoped",
  "message": "this api key carries no scopes and can reach no operation"
}

That is fail-closed by design. It is not a misconfiguration you will discover slowly.

A key never outranks its owner

This is the most important property of the key model, and it is worth stating without hedging because it has been verified end to end:

A key borrows its owner's live workspace role. It holds no authority of its own.

Concretely, with one unchanged key holding *, as its owner's membership was moved underneath it:

Owner's stateThe same request
adminGET /v1/api-keys → 200
demoted to editorGET /v1/api-keys → 403 insufficient_role
demoted to viewerPOST /v1/tickets → 403 insufficient_role
account suspendedany request → 403 user_inactive
membership removedany request → 403 api_key_owner_not_member
membership restoredworks again immediately

There is no cache, no grace period, and nothing to revoke separately. Demoting a person demotes their keys on the very next request.

A key's identity cannot be overridden by headers

Presenting a key replaces any X-User-Id and X-Tenant-Id on the request with the key's own owner and workspace. You cannot borrow a key and point it at a different person, and pointing it at another workspace is refused with api_key_workspace_mismatch.

The honest limits of an API key

Four things a key can never do, no matter how it is scoped.

It cannot reach an operation that answers "for a person"

Two published operations are unreachable by any key at any scope:

  • GET /v1/me/workspaces
  • POST /v1/invitations/{token}/accept

Both answer about a user across workspaces, and a key acts inside exactly one. The refusal is its own identifier, because no scope is the remedy:

{
  "error": "api_key_identity_only",
  "message": "this operation answers for a user, and an api key acts in one workspace"
}

It cannot span workspaces

One key, one workspace, permanently. There is no workspace parameter that widens it and no scope that crosses the boundary.

It cannot exceed its owner

* is a grant over surface, not over authority. See the table above.

It cannot be recovered

The secret is returned exactly once, in the create response, and no operation ever returns it again. Lose it and you mint a new one.

Revoke against delete

Both stop the key immediately and neither is reversible. They differ in what they leave behind:

RevokeDelete
Response200 {"revoked": true}204, no body
The holder sees401 api_key_revoked401 api_key_invalid
Row keptYes — name, prefix, who minted it, revoked_atNo
Visible afterwardsOnly with ?include_revoked=trueNever

Revoke when you want the audit trail — it keeps the record that the key existed and was deliberately killed. Delete when the key was a mistake you want gone.

`last_used_at` is always null

The API-key record carries a last_used_at field and nothing ever writes to it. It is null for every key, including keys that have authenticated thousands of requests. Do not use it to find unused or misconfigured keys — it cannot tell you that. Use the audit log instead.

Refusal order

When a key is presented, checks run in this order. The first failure wins, so this tells you what a given refusal has already ruled out:

api_key_invalid          (401)  unknown prefix, wrong secret, or malformed
api_key_revoked          (401)
api_key_expired          (401)
user_inactive            (403)  the owner's account is switched off
api_key_unscoped         (403)  no scopes at all
api_key_workspace_mismatch (403)
api_key_owner_not_member (403)
--- routing happens here ---
api_key_identity_only    (403)
api_key_out_of_scope     (403)
insufficient_role        (403)  the owner's role is below the floor

api_key_invalid deliberately covers three different failures, so a caller holding no valid key learns nothing from it. Everything below it is only reachable after the secret has matched, so each of those refusals tells its legitimate holder something they are entitled to know.

On this page