The operator surface
/v1/operator/* is the cross-tenant read surface — the estate as one dataset, spanning every
workspace of the app. It backs the Axis dashboard's inbox section.
It is defined by the absence of X-Axis-Tenant. There is no "operator mode" flag and no
alternate base URL. TenantGuard skips resolution for @Operator() routes, so no tenantContext is
ever set, and every store query runs unscoped. That single fact explains most of the rest of this
page.
What it takes to get in
The guard runs after TenantGuard and before ScopeGuard, and fails closed.
| Check | Applies to | On failure |
|---|---|---|
| An auth context exists | Everyone | insufficient_scope |
scopes includes stats |
Everyone | insufficient_scope |
user.isOperator |
Session callers only | insufficient_scope |
scopes includes operator:connections:write |
@OperatorWrite() routes |
insufficient_scope |
admin grants nothing here
This is the asymmetry that trips people. ScopeGuard treats admin as satisfying every
@RequireScopes on tenant routes. OperatorGuard does not consult it at all. Cross-tenant access is
a separate, explicit grant, and an admin key confined to a single workspace must never be able to
read the whole estate's figures.
POST /v1/admin/tenants/merge is the only route that needs both paths — it is @Operator() (so it
is cross-tenant and gated on stats) and @RequireScopes('admin').
A human additionally needs isOperator
For a session caller the scope check alone is not enough, and the reason is worth understanding because it was a real leak.
stats is granted to every user session, unconditionally, because the tenant-scoped
/v1/reports/* routes require it and reading your own workspace's aggregates is an ordinary member
action. Withholding it would break workspace reporting for every normal user. So one scope guards two
trust levels — and before the isOperator check existed, any authenticated workspace user could
call /v1/operator/overview and read the entire estate.
isOperator closes it:
const isOperator =
!!this.operatorAppSlug &&
apps.some((a) => a.slug === this.operatorAppSlug);
The caller's Accounts app grants are already fetched during session resolution, so this costs no extra round trip. Matching is by slug, never by id, because Accounts app ids are environment-specific.
INBOX_OPERATOR_APP_SLUGunset means no human has operator access. The expression short-circuits on!!this.operatorAppSlug, so an unset or mistyped variable makesisOperatorfalse for everyone, silently. Machinesk_keys are unaffected — they carry nouser, and theirstatsgrant is explicit in Accounts — so the dashboard's machine-key paths keep working while every human is locked out. That combination is exactly what makes the misconfiguration hard to spot.
Routes
| Controller | Routes |
|---|---|
OperatorController |
GET overview, GET tenants, GET usage |
OperatorStatsController |
GET tenants/:tenantId/summary, GET stats/:domain |
OperatorConnectionsController |
GET connections, GET connections/:id, GET tenants/:tenantId/connections |
OperatorThreadsController |
GET threads, GET threads/:id, GET threads/:id/entries, GET tickets, GET tickets/:id, GET tenants/:tenantId/threads, GET tenants/:tenantId/tickets |
OperatorContactsController |
GET contacts, GET contacts/:id, GET segments, GET tenants/:tenantId/contacts, GET tenants/:tenantId/segments |
OperatorCampaignsController |
GET campaigns, GET campaigns/:id, GET templates, GET tenants/:tenantId/campaigns, GET tenants/:tenantId/templates |
OperatorFlowsController |
GET flows, GET reviews, GET tenants/:tenantId/flows, GET tenants/:tenantId/reviews |
OperatorPlatformController |
GET webhooks, GET webhooks/deliveries, GET imports, GET tenants/:tenantId/imports |
ReportsOperatorController |
GET reports/catalog, GET reports/presets, POST reports/query |
OperatorConnectionRequestController |
GET connection-requests plus the @OperatorWrite() actions: approve, reject, whatsapp/connect, email/connect, test |
TenantMergeController |
POST /v1/admin/tenants/merge |
All prefixed /v1/operator except the last. Every list route pages by offset — { page, limit, total, hasMore }, default 50, max 200 — except threads/:id/entries, which is cursor-paged and
newest-first. GET /v1/operator/tenants is the one route allowing up to 1000 (MAX_TENANT_LIMIT),
and it still defaults to 50. See The operator SDK for the typed client.
What is redacted
The cross-tenant reads expose real customer content to a caller who is by definition not a member of
the workspace that owns it. Credentials, message payloads and raw webhook bodies are redacted
service-side. Contact identifiers are masked in lists and unmasked only by
GET /v1/operator/contacts/:id.
Access is logged
GET /v1/operator/threads/:id/entries is the one route that serves message bodies, and
GET /v1/operator/contacts/:id the one that serves unmasked identifiers. Both emit a structured
operator.read log line carrying actorUserId, actorSessionId, actorKeyId, appId, tenantId,
recordId and count.
It is a log line rather than an audit table deliberately: it must never be able to fail the request
it describes, and a dropped audit row would be worse than a log the platform already ships.
actorUserId is null for machine keys, which are attributable by actorKeyId instead.
This is also the argument for pointing a human-facing dashboard at session rather than a shared
machine key — a session names the person who looked.
Operator writes are not idempotency-protected
if (!req.authContext || !req.tenantContext) return next.handle();
The idempotency interceptor bails when there is no tenant context, and an operator route never has
one. So every @OperatorWrite() route falls straight through it. Approving a connection request,
entering provider credentials, running a test — none of these are replay-protected. Retrying one
re-executes it. Send an Idempotency-Key if you like; nothing reads it.
The cause is structural rather than an oversight: the reservation scope is (appId, tenantId, key)
and an operator route genuinely has no tenant to scope against. If your operator UI retries on
timeout, make the underlying action tolerant of running twice, or do not retry it.