← Documentation

Access Control

Three separate models — vendor-dashboard roles, API key scopes, and the product-IdP-admin authority — gate access. Roles are enforced: every account-scoped route names the minimum role it requires. Scopes are enforced on some modules and validated on create and update.

Vendor dashboard: account membership and role

A vendor user's access to an account is an AccountMembership row joining userId and accountId, with a role of "admin", "member", or "viewer" (default "admin" — the user who creates an account is enrolled as its admin automatically). Every account-scoped route calls a shared requireOrgAccess(request, accountId, { minRole }) check after authenticating the vendor JWT: it looks up the AccountMembership row for that user and account, throws a 403 if there isn't one, and then compares the role that row carries against the minRole the route named — a second 403 if it falls short. The roles are ordered viewer < member < admin, and a route's minRole admits that role and every role above it.

The minRole argument is required, not defaulted, so a route cannot fail to state a tier and silently inherit the widest one — omitting it does not compile. What each tier can do:

RoleWhat it can doWhere the line falls
viewerRead-only.Every account-scoped GET on a vendor-JWT route — accounts, connections, SSO config, agent clients, API key listings, webhooks and their deliveries, observability, audit. Nothing that writes.
memberEverything a membership could do before roles were enforced.Every mutating route the admin tier does not take — among them updating and deleting an account, creating/updating/deleting and testing connections, claiming and verifying a domain, agent access and agent principals, creating and revoking agent clients, webhook create/update/delete/test/redeliver, SSO client provisioning and secret rotation, and generating an admin-portal magic link.
adminEverything a member can do, plus capabilities of its own.Setting an account’s connection-error policy (POST /api/v1/accounts/:id/connection-error-policy) and minting an API key (POST /api/v1/api-keys). Updating an existing key currently requires admin too — see the note below; that one is not settled.

The tiers are a real boundary, not a label: a viewer is refused on every write to an account they hold viewer on, and a member is refused on the admin capabilities above. The role is read from the database on every request rather than carried as a JWT claim, so the row is the authority and an already-issued token cannot outrank it. And enforcement fails closed on the column itself — the role column is a plain string with no database constraint, so a row holding anything other than those three names is refused for every tier, viewer included, rather than being read as the highest one.

Where the admin tier stops short of the decision behind it

There is no membership-management API. The decision behind these tiers also lists managing memberships as an admin capability, but no route anywhere in the server adds a member, removes one, or changes anyone's role — so there is nothing for the tier to gate. The only AccountMembership row the API ever writes is the one created alongside a new account, enrolling its creator as admin. Until that surface exists, changing who is on an account is a database operation, not an API call, and nothing on this page should be read as promising otherwise.

Two API-key routes are not settled — and what follows describes account-scoped keys. A platform-level key (accountId null) takes a creator-only branch where no membership is consulted and no role applies. Minting a key — POST /api/v1/api-keys — requires admin, and that is decided. Managing a key that already exists is not. Today DELETE /api/v1/api-keys/:id (revoke) requires member, on the reasoning that the tier running an account day to day should be able to kill a leaked key without first finding an admin, while PATCH /api/v1/api-keys/:id — which can re-scope or re-enable an existing key, and so hand out authority — requires admin. Where those two belong is an open question, still being decided; treat this paragraph as current behaviour rather than a settled policy, and expect it to be the thing that changes.

API keys: scoped, hashed, account-bound (or platform-level)

Programmatic access uses an ApiKey record instead of a vendor session. A key is generated as ss_<64 hex chars> and shown to the vendor exactly once, at creation; only its SHA-256 keyHash and an 8-character keyPrefix (for display/identification) are stored — the raw key itself is never persisted. Presenting it via the X-API-Key header is checked against keyHash, and the key must be enabled and unexpired.

Each key also carries a scopes array, validated against a fixed allow-list:

connections:read      connections:write      connections:delete
agent-clients:read    agent-clients:write
webhooks:read         webhooks:write
observability:read
audit:read
platform:accounts:create   (platform-level keys only)
*  (all account-scoped scopes — never platform:*)

An unrecognized scope is rejected with a 400 on both create and update. Account-scoped keys are bound to a single accountId — using one against a different account's data fails requireOrgAccess() the same way an out-of-account vendor JWT would. A key is not mapped onto a membership role: it has no AccountMembership row, so minRole is not consulted on the key path at all. A key's authority is exactly two things — the account it was minted for, and the scopes it carries. Platform-level keys (accountId null) are a separate credential class: they may only hold platform:accounts:create, and they exist so a caller with zero accounts can bootstrap its first one via POST /api/v1/accounts. A platform-level key cannot be handed account-scoped scopes, and an account-scoped key cannot be handed platform:* — the two assignable sets are deliberately disjoint.

Create and update both validate the allow-list

POST /api/v1/api-keys and PATCH /api/v1/api-keys/:id both call assertAssignableScopes() whenever scopes are present. An unknown string, a scope from the wrong level (platform vs account), or a mix that crosses the partition is rejected with 400 VALIDATION_ERROR, and the stored scopes are left untouched. Do not cite older docs that claimed PATCH wrote scopes straight through — that hole was closed with the platform-level keys work on the server.

Scope checks are real, but they do not cover every module yet

A shared requireApiKeyScopes() guard runs at the request boundary and throws a 403 when a key lacks the scope a route requires. It is wired into Connections, SSO Integration, Agent Clients, Webhooks, Observability, Audit, and the platform POST /api/v1/accounts bootstrap path. Connections/SSO routes require connections:read, connections:write, or connections:delete; agent client routes require agent-clients:read / agent-clients:write; webhook reads require webhooks:read and every webhook mutation — including test and redeliver, both of which cause an outbound send — requires webhooks:write; observability reads require observability:read and audit reads audit:read; the bootstrap path requires platform:accounts:create (and does not honour * as a substitute). The guard applies to API keys only — a vendor JWT carries no scopes and is unaffected by it.

Every scope in the list above is now a working grant on the module it names — that was not true until 2026-09-07, and older copies of this page said so. Five strings that had only ever been declared intent — accounts:read, accounts:write, scim:read, scim:write and admin-portal:generate — were deleted from the registry rather than wired up, because no route checked them and none was going to: SCIM uses its own per-account bearer token and the Admin Portal its own session token, so neither is reachable with an API key at all. Naming one of the five on create or update is now a 400. Minting a key and generating an admin-portal link both stay vendor-JWT only.

Admin portal: a third, separate path

The customer-facing admin portal doesn't use vendor roles or API scopes at all. A vendor generates a time-limited magic-link session (AdminPortalSession, tied to one account and one email); the token is stored only as a hash and presented back via the X-Admin-Token header, which the server consumes atomically on first use. This produces the "customer_admin" actor type seen in audit log entries, and is documented further in Magic Link Authentication.

Product-IdP-admin: one read, and nothing else

A vendor user can hold a ProductIdpAdmin row for one specific product. The row unlocks exactly one read — GET /api/v1/products/:id/handout for one named account — and nothing else. It grants no mutation, no provisioning, no secret, and no unfiltered listing; those stay platform-operator-only. A call made on this authority must name an account, so the role cannot be used to enumerate customers.

Two boundaries are worth stating exactly. The guard refuses an API key outright before it reads anything, so this authority can never be held by a credential left on a script host. And the role is mutually exclusive with account membership: a user who holds the row and any AccountMembership is refused with its own error code and its own audit line, re-decided on every request rather than trusted from grant time. The row is re-read per request, so revoking it takes effect on the holder's next call. See Identity Gateway for what the authority is for.

MechanismIdentifiesScoped toGranularity enforced today
Vendor JWT + roleA vendor userAccounts they belong toMembership, plus the role on it: every account-scoped route names a minimum role (viewer < member < admin), re-read from the database per request. An unrecognised stored role is refused at every tier
Product-IdP-admin rowA vendor user, for one productOne product, and one named account per callExactly one read — the product handout. Refuses API keys outright; mutually exclusive with any account membership, re-checked every request
API key + scopesAn integrationOne account (or platform-level: none)Account binding (or platform bootstrap), plus requireApiKeyScopes on Connections, SSO, Agent Clients, Webhooks, Observability, Audit and the platform accounts:create bootstrap — every scope in the registry is enforced on the module it names
Admin portal sessionA customer admin (by email)One accountSingle-use bootstrap token, then a short-lived session token; expiry on both

For the CORS, rate-limiting, and header hardening that sits in front of every one of them, see Security Overview.