Scopes and guards
Every request runs through five guards and then three interceptors, wired as global providers in
security.module.ts. Each stage does one thing and throws an AxisError when it refuses.
The pipeline, in execution order
| # | Stage | What it does | Throws |
|---|---|---|---|
| 1 | RateLimitGuard |
Fixed-window edge limit, bucketed on a hash of the presented credential. Runs before auth. | rate_limit_exceeded (429) |
| 2 | AuthGuard |
Resolves the credential to an AuthContext. @Public() routes short-circuit. |
missing_credentials, invalid_api_key, invalid_jwt |
| 3 | TenantGuard |
Resolves x-axis-tenant to a TenantContext. Skipped for @Public() and for @Operator() routes. |
tenant_missing, tenant_not_authorized |
| 4 | OperatorGuard |
Gates @Operator() routes on stats (+ operator:connections:write for writes). |
insufficient_scope (403) |
| 5 | ScopeGuard |
Enforces @RequireScopes() against the credential's scopes. |
insufficient_scope (403) |
Then, outermost interceptor first:
| Interceptor | Role |
|---|---|
IdempotencyInterceptor |
Reserve-before-handler, persist-before-release. See Idempotency. |
ResponseInterceptor |
Wraps the handler result as { data, meta: { requestId } }. |
BusinessEventInterceptor |
Innermost, so it taps the raw handler return before reshaping, and publishes the route's @Emits event. |
AllExceptionsFilter |
Maps everything thrown to the error contract. See Envelopes and errors. |
Rate limiting running first matters: an unauthenticated flood against a public route is stopped before it costs an Accounts introspection round-trip. It also means a 429 can be the answer to a request that would otherwise have been a 401.
The six scopes
| Local scope | Accounts scope | Grants |
|---|---|---|
connections |
inbox:connections |
Connect, list, disconnect and reconnect accounts |
inbox |
inbox:threads |
Read threads and entries; reply and moderate |
publish |
inbox:publish |
Create, schedule and cancel posts |
admin |
inbox:admin |
Webhook endpoints, webhook inbox, break-glass operations |
stats |
inbox:stats |
Read-only cross-tenant operator figures |
operator:connections:write |
inbox:connections:write |
Cross-tenant operator writes |
Scopes are resource-oriented. Access mode (read vs write) is a property of the credential, not of the
scope — with the one exception of operator:connections:write, which exists precisely to split a
read-only operator from an acting one.
Unknown Accounts scopes are ignored, not rejected. A key spanning several apps legitimately
carries intelligence:* and native Accounts scopes; those grant nothing here and must not fail the
request.
The asymmetry that catches people
admin satisfies every @RequireScopes. admin satisfies nothing on an operator route.
// ScopeGuard
const ok = granted.has('admin') || required.every((s) => granted.has(s));
// OperatorGuard
if (!auth.scopes.includes('stats')) throw new AxisError('insufficient_scope');
The reason is blast radius. admin is tenant-scoped break-glass: it means "do anything inside this
workspace". stats is the only cross-tenant read scope in the vocabulary — it means "see every
workspace of this app, in aggregate". Those are different powers, and an admin key provisioned for
one customer's workspace must never be able to read the whole estate's figures because someone
reasonably assumed admin implies everything.
So an operator credential needs inbox:stats granted explicitly in Accounts. Adding inbox:admin
does nothing for it.
@OperatorWrite() adds a second explicit requirement:
if (isOperatorWriteRoute(...) && !auth.scopes.includes('operator:connections:write')) {
throw new AxisError('insufficient_scope');
}
Checked in OperatorGuard rather than delegated to @RequireScopes — precisely so admin can never
satisfy it. A read-only operator key holding stats alone browses connection requests but cannot
approve one, enter provider credentials, or run a test. operator:connections:write is meaningless
without stats, since a route needs cross-tenant reachability before it can act cross-tenant.
POST /v1/admin/tenants/merge is the only route that needs both paths satisfied at once.
Sessions on the operator surface
A user session is granted stats unconditionally, because tenant-scoped /v1/reports/* needs it. The
scope check alone would therefore let any authenticated workspace member read the entire estate.
OperatorGuard closes that with a third check:
if (auth.user && !auth.user.isOperator) throw new AxisError('insufficient_scope');
isOperator is true only when Accounts reports the user as a member of the app named by
INBOX_OPERATOR_APP_SLUG. Machine keys are unaffected — they carry no user, and their stats grant
was explicit in Accounts.
Leave INBOX_OPERATOR_APP_SLUG unset and no user session can reach /v1/operator/* at all. Not
degraded, not partial: isOperator is always false, so every session-authenticated operator call gets
403. Those routes then need a machine sk_ key holding inbox:stats. That is a defensible default —
fail closed — but it looks exactly like a broken dashboard, so check this env var first when operator
pages return 403 for a user who is plainly an operator.
Match on slug, never on app id. Accounts app UUIDs differ per environment; a hardcoded id works in dev and silently denies everyone in production.
Tenant resolution
Tenant-scoped routes need x-axis-tenant, whose value is ws_<userGroupId>. TenantGuard skips
resolution entirely for @Operator() routes — they are cross-tenant by design and must not be failed
closed for lacking a header they should never send. That is why the operator SDK entrypoint omits
tenant at the type level: a "tenant-scoped operator client" is not a thing the service has.
Resolution upserts. A typo in the tenant header does not 404; it provisions a new, empty workspace. This is the single most common integration bug against inbox.
Development escape hatch
When devSkipAuth is on, AuthGuard fabricates a context with
['publish', 'inbox', 'connections', 'admin', 'stats'] — note that operator:connections:write is
not in that set, so operator write routes still refuse. Never enable this outside local
development.