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.