RequestContext

The structure the BetterSuite backend assembles from every authenticated call, and how resolvers use it for tenant scoping and authorisation.

Last updated May 19, 2026

Every call into the BetterSuite GraphQL API produces a RequestContext server-side before any resolver runs. This is the canonical view of "who is calling" that every resolver branches on — knowing how it's assembled helps you predict why an operation accepts you, rejects you, or returns less than you expected.

How it's built

The auth middleware sits in front of every GraphQL request. It:

  1. Strips every client-set context header (x-account-id, x-tenant-id, x-session-id, x-role, x-partnership-id, x-elevation-jti). A malicious client can't assert another tenant's id; the server fills these in itself.
  2. Picks the auth mode in this order: Authorization: Bearer (JWT) > X-Api-Key (btk_* or bpk_*) > unauthenticated.
  3. Re-injects the resolved context as internal headers, which the GraphQL handler reads into RequestContext.

So the structure below is server-asserted — by the time your resolver sees it, every field has been derived from a verified token or key.

Fields

pub struct RequestContext {
    pub account_id: Option<AccountId>,
    pub tenant_id: Option<TenantId>,
    pub session_id: Option<SessionId>,
    pub role: Option<RoleKind>,
    pub app: Option<App>,
    pub partnership_id: Option<PartnershipId>,
    pub device: Option<RequestDevice>,
    pub ip: Option<String>,
    pub user_agent: Option<String>,
    pub client_version: Option<String>,
    pub elevation_jti: Option<String>,
    pub anonymous_session_id: Option<uuid::Uuid>,
}

Every field is Option. Resolvers grab what they need via accessors like req_ctx.tenant_id() (returns an error if missing). What gets populated depends on which auth path matched.

Auth-derived fields

FieldJWT sessionbtk_ API keybpk_ public keyUnauthenticated
tenant_idfrom tid claimfrom key's tenantfrom the matching tenantnone
account_idfrom sub claimnonenonenone
session_idfrom sid claimnonenonenone
rolefrom role claimnonenonenone
appfrom app claimclient-asserted x-app (not stripped)client-asserted x-appclient-asserted x-app
partnership_idfrom pid claim (partner-staff only)nonenonenone
elevation_jtiset if a valid X-Elevation JWT was attachedn/an/an/a

app is the one client-asserted field the middleware does not strip on a pre-auth request — pre-auth flows (e.g. loginWithPassword from a specific app) need to know which app sent the call. JWT-authenticated requests still get their app overwritten from the access-token claims, so post-auth resolvers always see the authoritative value.

Context fields

FieldSourcePurpose
ipX-Real-IP (preferred — set by our nginx ingress), else leftmost of X-Forwarded-ForSession audit, rate-limit bucketing
user_agentUser-Agent request headerStored on the session row so the Sessions screen can show device/browser info
client_versionx-client-version request header (semver string, self-reported)Drives the minimum-supported-version gate. Spoofable; goal is forced-update UX, not authorization
devicex-device-platform, x-device-apple-pay, x-device-google-pay headersToggles wallet/payment options
anonymous_session_idx-anonymous-session-id (UUID, malformed values dropped silently)Best-effort per-visitor state for read-only flows (quote preview, catalog browse)

Tenant resolution — the only mechanism

There is no host-based tenant resolution and no client-supplied X-Tenant-Id header. Tenant id comes from exactly one of:

  1. The tid claim in a JWT access token.
  2. The tenant_id row of the API key (btk_*) that authenticated the call.
  3. The tenant matched by the public client key (bpk_*) on a pre-auth call.

Any inbound x-tenant-id header is stripped before the middleware does anything else, so cross-tenant assertion isn't possible from the client side. Calls without one of the three above arrive at tenant_id = None; resolvers that need a tenant return an error.

Roles

RoleKind (snake_case on the wire):

RoleSet byTypical scope
platform_adminBetterSuite ops sessionImpersonates any tenant; passes admin-tier gates
tenant_ownerThe single Owner of a tenantStrict superset of tenant_admin — required for danger-zone actions (API key issue/rotate, billing, ownership transfer)
tenant_adminTenant admin invited by the OwnerTenant-wide configuration & operations, subject to permission set
partner_adminPartner staff sessionTheir partnership only
operatorOps console operator roleDay-to-day operations (dispatch, support)
driver / merchant / service_provider / park_spot_providerService-tier app sessionTheir own assignments and payouts
customerMarketplace customer sessionTheir own orders, public catalog

The is_owner_tier() accessor returns true for tenant_owner and platform_admin; the is_admin_tier() accessor adds tenant_admin to that. Resolvers use these instead of open-coded match arms.

Step-up elevation

elevation_jti is set only after the auth middleware validates a X-Elevation JWT (signature, audience "step-up", tenant + subject must match the bearer JWT). Setting it does not consume it — the gated resolver calls require_elevation(elevation_jti, store), which atomically deletes the JTI from Redis. Single-shot semantics: one successful mutation per token.

If the header is missing, the JWT is malformed, the audience is wrong, the tenant/subject mismatches the bearer, or Redis is unavailable, the resolver returns STEP_UP_REQUIRED.

Error codes

These come back as GraphQL errors (errors[].extensions.code), not HTTP status codes — the HTTP response stays 200 once parsing succeeds. Identity-service codes you'll commonly encounter:

CodeMeaning
UNAUTHORIZEDNo usable token, or the resolver requires authentication
PERMISSION_DENIEDAuthenticated but the role / permission set doesn't allow this operation
TENANT_NOT_FOUNDThe referenced tenant doesn't exist or is inactive
STEP_UP_REQUIREDOperation needs a fresh elevation token; missing, expired, or already-consumed
SESSION_REVOKEDRefresh attempt on a session whose revoked_at is set
SESSION_EXPIREDRefresh attempt past the session's expiry
INVALID_REFRESH_TOKENSignature/JTI mismatch, or the token has already been rotated
INVALID_CREDENTIALSPassword / OTP failure on a login or step-up
VALIDATION_ERRORInput failed domain validation

HTTP-level 401s are reserved for the auth middleware itself (bad JWT signature, unknown / revoked / malformed API key).

What's next

  • API Keys — populates tenant_id only.
  • JWT Sessions — populates account_id, session_id, role, app, and (for partner staff) partnership_id.
  • Rate Limits — bucketed differently by auth kind.

Build the foundation once. Expand without limits.

BetterSuite is built for teams who see on-demand as a business — not a feature.