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:
- 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. - Picks the auth mode in this order:
Authorization: Bearer(JWT) >X-Api-Key(btk_*orbpk_*) > unauthenticated. - 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
| Field | JWT session | btk_ API key | bpk_ public key | Unauthenticated |
|---|---|---|---|---|
tenant_id | from tid claim | from key's tenant | from the matching tenant | none |
account_id | from sub claim | none | none | none |
session_id | from sid claim | none | none | none |
role | from role claim | none | none | none |
app | from app claim | client-asserted x-app (not stripped) | client-asserted x-app | client-asserted x-app |
partnership_id | from pid claim (partner-staff only) | none | none | none |
elevation_jti | set if a valid X-Elevation JWT was attached | n/a | n/a | n/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
| Field | Source | Purpose |
|---|---|---|
ip | X-Real-IP (preferred — set by our nginx ingress), else leftmost of X-Forwarded-For | Session audit, rate-limit bucketing |
user_agent | User-Agent request header | Stored on the session row so the Sessions screen can show device/browser info |
client_version | x-client-version request header (semver string, self-reported) | Drives the minimum-supported-version gate. Spoofable; goal is forced-update UX, not authorization |
device | x-device-platform, x-device-apple-pay, x-device-google-pay headers | Toggles wallet/payment options |
anonymous_session_id | x-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:
- The
tidclaim in a JWT access token. - The
tenant_idrow of the API key (btk_*) that authenticated the call. - 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):
| Role | Set by | Typical scope |
|---|---|---|
platform_admin | BetterSuite ops session | Impersonates any tenant; passes admin-tier gates |
tenant_owner | The single Owner of a tenant | Strict superset of tenant_admin — required for danger-zone actions (API key issue/rotate, billing, ownership transfer) |
tenant_admin | Tenant admin invited by the Owner | Tenant-wide configuration & operations, subject to permission set |
partner_admin | Partner staff session | Their partnership only |
operator | Ops console operator role | Day-to-day operations (dispatch, support) |
driver / merchant / service_provider / park_spot_provider | Service-tier app session | Their own assignments and payouts |
customer | Marketplace customer session | Their 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:
| Code | Meaning |
|---|---|
UNAUTHORIZED | No usable token, or the resolver requires authentication |
PERMISSION_DENIED | Authenticated but the role / permission set doesn't allow this operation |
TENANT_NOT_FOUND | The referenced tenant doesn't exist or is inactive |
STEP_UP_REQUIRED | Operation needs a fresh elevation token; missing, expired, or already-consumed |
SESSION_REVOKED | Refresh attempt on a session whose revoked_at is set |
SESSION_EXPIRED | Refresh attempt past the session's expiry |
INVALID_REFRESH_TOKEN | Signature/JTI mismatch, or the token has already been rotated |
INVALID_CREDENTIALS | Password / OTP failure on a login or step-up |
VALIDATION_ERROR | Input 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_idonly. - JWT Sessions — populates
account_id,session_id,role,app, and (for partner staff)partnership_id. - Rate Limits — bucketed differently by auth kind.