Busflow Docs

Internal documentation portal

Skip to content

ADR-005: Multi-Tenant JWT & Session Management ​

Status: Proposed β€” pending architect approval Impacts: schema-backoffice.md Β§user_tenant_assignments, roles.md, domain-driven-design.mdAuth Provider: Nhost Auth β€” all user identity and authentication delegated to Nhost


Context ​

A single user (auth.users) can belong to multiple Operators via user_tenant_assignments. All Hasura permission rules filter by x-hasura-tenant-id. This ADR specifies the JWT structure, the tenant context selection at login, how tenant switching works, and the capability resolution process.

Decision ​

JWT Claim Structure ​

json
{
  "sub": "<user_id>",
  "email": "user@example.com",
  "https://hasura.io/jwt/claims": {
    "x-hasura-user-id": "<user_id>",
    "x-hasura-tenant-id": "<active_tenant_id>",
    "x-hasura-role": "<tenant_role>",
    "x-hasura-allowed-roles": ["manager", "dispatcher", "driver"],
    "x-hasura-default-role": "<tenant_role>",
    "x-hasura-plan": "[tier_placeholder]"
  }
}
  • x-hasura-tenant-id: The currently active tenant. The system sets this during login or tenant switch.
  • x-hasura-role: The user's default_role within the active tenant (from user_tenant_assignments.default_role).
  • x-hasura-allowed-roles: All roles the user can assume. The system populates these from their default_role across all tenant assignments.
  • x-hasura-plan: Target tenant's subscription tier. Limits premium feature access. See ADR-030.
  • For busflow_staff: x-hasura-role = busflow_staff, x-hasura-plan = [highest_tier_placeholder], x-hasura-tenant-id is omitted (unrestricted).
  • TTL: JWT: 15 min. Refresh token: 7 days (single-use, rotation).

Auth Flow β€” Nhost Delegation ​

Nhost Auth's built-in endpoints handle login and refresh. Busflow injects tenant context via Nhost's custom JWT claims mechanism.

EndpointOwnerPurpose
POST /signin/email-passwordNhost AuthStandard email/password login
POST /tokenNhost AuthRefresh token rotation
POST /auth/select-tenantNestJS (custom)Multi-tenant: select active tenant after login
POST /auth/switch-tenantNestJS (custom)Switch active tenant context

Login Flow ​

  1. User authenticates via Nhost Auth (email + password / SSO).
  2. Nhost issues an initial JWT. Custom claims webhook queries user_tenant_assignments for the authenticated user.
  3. Single tenant: JWT includes x-hasura-tenant-id and x-hasura-role immediately.
  4. Multiple tenants: JWT includes x-hasura-tenant-id: null. Frontend detects this and renders a tenant picker populated from the user's assignments. User selects a tenant β†’ calls /auth/select-tenant.
  5. Frontend stores the JWT and the active_tenant_id in local state.

Nhost Custom Claims Hook Implementation ​

The custom claims webhook fires on every JWT issuance (login) and every 15-min refresh. It enriches the JWT with Hasura session variables by querying the database for tenant context and subscription tier.

Trigger: JWT issuance (login) + 15-min refresh per Β§Security Constraints.

Pseudocode:

typescript
export async function customClaimsHook(userId: UUID, selectedTenantId?: UUID): Promise<JWTClaims> {
  const baseAuth = {
    sub: userId,
    "https://hasura.io/jwt/claims": {
      "x-hasura-user-id": userId,
      "x-hasura-allowed-roles": ["manager", "dispatcher", "driver"],
    }
  };

  // Check if busflow_staff (unconditional ENTERPRISE bypass)
  const user = await db.query("SELECT default_role FROM auth.users WHERE id = $1", [userId]);
  if (user?.default_role === "busflow_staff") {
    baseAuth["https://hasura.io/jwt/claims"]["x-hasura-role"] = "busflow_staff";
    baseAuth["https://hasura.io/jwt/claims"]["x-hasura-plan"] = "ENTERPRISE";
    // No x-hasura-tenant-id for staff (cross-tenant access)
    return baseAuth;
  }

  // Resolve tenant context (null for multi-tenant picker)
  const activeTenantId = selectedTenantId || (await getDefaultTenant(userId));
  if (!activeTenantId) {
    // Multi-tenant picker mode: no tenant context yet
    baseAuth["https://hasura.io/jwt/claims"]["x-hasura-tenant-id"] = null;
    baseAuth["https://hasura.io/jwt/claims"]["x-hasura-role"] = null;
    baseAuth["https://hasura.io/jwt/claims"]["x-hasura-plan"] = null;
    return baseAuth;
  }

  // Read user's role in this tenant
  const uta = await db.query(
    "SELECT default_role FROM user_tenant_assignments WHERE user_id = $1 AND tenant_id = $2",
    [userId, activeTenantId]
  );
  if (!uta) throw new UnauthorizedError("TENANT_NOT_ASSIGNED");

  baseAuth["https://hasura.io/jwt/claims"]["x-hasura-tenant-id"] = activeTenantId;
  baseAuth["https://hasura.io/jwt/claims"]["x-hasura-role"] = uta.default_role;

  // Read subscription tier (denormalized from tenant_subscriptions.plan_id)
  const tierRow = await db.query(
    "SELECT subscription_tier FROM operators WHERE id = $1",
    [activeTenantId]
  );
  // Fallback to CORE if NULL (pessimistic, safe default)
  baseAuth["https://hasura.io/jwt/claims"]["x-hasura-plan"] = tierRow?.subscription_tier || "CORE";

  return baseAuth;
}

Fallback behavior: If operators.subscription_tier IS NULL, the hook injects x-hasura-plan = CORE. Pessimistic assumption: user loses access to premium features. See L3-6.2.1 Β§4.1 for prevention strategy.

Staff privilege: When auth.users.default_role == "busflow_staff":

  • x-hasura-role = busflow_staff
  • x-hasura-tenant-id = omitted (no tenant scoping; staff accesses all tenants)
  • x-hasura-plan = ENTERPRISE (unconditional; staff is not subject to plan gating)

This allows staff to perform onboarding, debugging, and support actions without being blocked by customer plan restrictions. All staff mutations are audited via change_events(scope='CONFIG') per ADR-019.


/auth/select-tenant (NestJS) ​

FieldValue
AuthRequires valid Nhost JWT (tenant context may be null)
Input{ tenant_id: UUID }
Success200 { jwt: string, refresh_token: string } β€” new JWT with selected tenant context
Errors403 TENANT_NOT_ASSIGNED Β· 403 USER_DISABLED Β· 410 TOKEN_EXPIRED
New (L3-6.2.1)JWT re-issue via custom claims hook: The handler invokes the Nhost custom claims webhook with the selected tenant_id to mint a new JWT. The hook re-evaluates operators.subscription_tier for the selected tenant, ensuring the returned JWT's x-hasura-plan reflects the selected tenant's plan tier (not the previously active tenant's).

/auth/switch-tenant (NestJS) ​

FieldValue
AuthAuthenticated (valid JWT with active tenant)
Input{ target_tenant_id: UUID }
Success200 { jwt: string } β€” new JWT with target tenant's context
Errors403 TENANT_NOT_ASSIGNED Β· 403 USER_DISABLED
Side effectAudit log: { user_id, from_tenant_id, to_tenant_id, timestamp } in change_events
Rate limit10 switches / hour per user
New (L3-6.2.1)JWT re-issue via custom claims hook: Same as /auth/select-tenant β€” the handler invokes the Nhost custom claims webhook with the target tenant_id. The hook re-evaluates operators.subscription_tier for the target tenant, ensuring the returned JWT's x-hasura-plan reflects the new tenant's plan tier.

Frontend contract: On successful switch, the client MUST (1) replace the stored JWT, (2) invalidate the Apollo cache (client.clearStore()), (3) reload the workspace view.

Hasura Actions β€” User Management ​

InviteUser ​

FieldValue
AuthMANAGER role within the tenant
Input{ email: string, default_role: MANAGER | DISPATCHER | DRIVER, tenant_id: UUID }
Success{ user_id: UUID, assignment_id: UUID }
ErrorsINSUFFICIENT_PERMISSIONS Β· INVALID_ROLE Β· 409 ALREADY_ASSIGNED Β· 403 USER_DISABLED
Flow(1) Check if auth.users row exists for email. (2a) If yes + not disabled β†’ create user_tenant_assignment. (2b) If yes + disabled β†’ reject with USER_DISABLED. (2c) If no β†’ create Nhost user via Admin SDK β†’ create user_tenant_assignment. (3) Emit UserInvited event β†’ Communications sends invite email with set-password link.

No INVITED status. Nhost creates users immediately. The set-password email flow handles the "invite" concept. The user either has a password set (can login) or hasn't (can't login until they follow the email link).

RemoveUserFromTenant ​

FieldValue
AuthMANAGER role within the tenant
Input{ user_id: UUID, tenant_id: UUID }
Success{ success: boolean }
ErrorsINSUFFICIENT_PERMISSIONS Β· CANNOT_REMOVE_LAST_ADMIN Β· CANNOT_REMOVE_SELF
Flow(1) Validate caller is MANAGER in this tenant. (2) Guard: count remaining MANAGER UTAs for this tenant β€” if this is the last one, reject. (3) Delete user_tenant_assignment row. (4) Delete associated user_access_grants. (5) If user has zero remaining assignments across all tenants β†’ optionally disable via Nhost Admin SDK. (6) Audit via change_events.

Tenant-scoped, not global. An manager of Tenant A can only remove a user from Tenant A. The user retains access to all other tenants. Global user disabling is a busflow_staff-only operation via Nhost Admin SDK.

ManageAccessGrants ​

FieldValue
AuthMANAGER role within the tenant
Grant Input{ user_id: UUID, tenant_id: UUID, capability: string } β†’ { grant_id: UUID }
Revoke Input{ grant_id: UUID } β†’ { success: boolean }
Capability enumDISPATCH, FLEET_MGMT, BOOKING_MGMT, DRIVER_APP, CREW_MGMT, FINANCIAL_REPORTS
Validation(1) capability must be in the allowed enum. (2) Unique constraint (user_id, tenant_id, capability) prevents duplicates at DB level.
ErrorsINSUFFICIENT_PERMISSIONS Β· INVALID_CAPABILITY Β· 409 GRANT_ALREADY_EXISTS Β· 404 GRANT_NOT_FOUND

OnboardCrewMember ​

FieldValue
AuthMANAGER role within the tenant
Input{ crew_member_id: UUID, email: string }
Success{ user_id: UUID, assignment_id: UUID }
ErrorsCREW_MEMBER_NOT_FOUND Β· CREW_MEMBER_ALREADY_LINKED Β· CREW_MEMBER_TERMINATED
Flow(1) Validate crew_member.status β‰  TERMINATED. (2) Check whether crew_members.user_id has a value β†’ if so, reject. (3) Check if auth.users row for email exists. (3a) If yes β†’ set crew_members.user_id, create UTA with DRIVER. (3b) If no β†’ create Nhost user β†’ set crew_members.user_id β†’ create UTA with DRIVER. (4) Emit UserInvited β†’ send set-password email.

Role mapping: crew_members.role (DRIVER/GUIDE/DRIVER_GUIDE) maps to user_tenant_assignments.default_role = DRIVER. Guides also get DRIVER role (same Driver PWA). The crew_members.role field governs dispatch eligibility, not auth.

Capability Resolution ​

Runtime permission check to determine what a user can do within a tenant:

function resolveCapabilities(user_id, tenant_id):
  assignment = user_tenant_assignments.find(user_id, tenant_id)
  if assignment is null β†’ DENY

  if assignment.default_role == MANAGER:
    return ALL_CAPABILITIES

  base = BASE_CAPABILITIES[assignment.default_role]
  grants = user_access_grants.where(user_id, tenant_id).pluck(capability)
  return base βˆͺ grants

Base capabilities per role:

RoleBase Capabilities
MANAGERALL (implicit)
DISPATCHERDISPATCH, BOOKING_MGMT, FLEET_MGMT
DRIVERDRIVER_APP

Resolution layer: Application layer (NestJS guards via @RequiresCapability(DISPATCH) decorator). NOT in the JWT. The system caches capabilities per session and invalidates them on grant change.

WARNING

Hasura Role Grouping. Hasura supports inherited roles β€” grouping multiple roles into a composite role. The capability model could map to Hasura inherited roles (e.g., a dispatcher_with_fleet role inheriting dispatcher + fleet_manager permissions). The team should evaluate this during RBAC implementation as it may simplify both the Hasura metadata and the NestJS capability resolution.

Edge States ​

#Edge CaseResolution
E1User disabled while holding active JWTJWT remains valid until expiry (max 15 min). Refresh returns 403 USER_DISABLED. NestJS middleware checks auth.users.disabled on sensitive mutations.
E2Tenant switch with unsaved form dataFrontend useTenantSwitch() composable checks Pinia formDirtyState. If dirty β†’ confirmation dialog.
E3Manager removes themselves from tenantBlocked: CANNOT_REMOVE_SELF. Another manager must remove them.
E4Last manager removedBlocked: CANNOT_REMOVE_LAST_ADMIN.
E5User invited but never sets passwordNo INVITED status. User simply can't log in until they follow the set-password link. 7-day reminder email. Manager can remove after 30 days.
E6Concurrent capability grantUnique constraint (user_id, tenant_id, capability) β†’ 409 GRANT_ALREADY_EXISTS.
E7CrewMember terminated but user still activeCrewMemberTerminated Event Trigger β†’ remove UTA for this user+tenant (DRIVER role). If zero remaining UTAs β†’ optionally disable user.

Security Constraints ​

ConstraintEnforcement
User can only switch to assigned tenantsBackend validates against user_tenant_assignments
Disabled users cannot switch or refreshCheck auth.users.disabled
JWT expiry15 min + refresh token rotation (7-day TTL, single-use)
Tenant switch auditchange_events log with user_id, from_tenant_id, to_tenant_id
Rate limitingLogin: 5 attempts / 15 min per email. Tenant switch: 10 / hour per user.

Consequences ​

Positive:

  • Single JWT contains all Hasura needs β€” no additional lookup per request
  • Tenant switching is stateless (new JWT, no server-side session)
  • Clean separation: auth.users.default_role = platform, user_tenant_assignments.default_role = tenant
  • Nhost handles all auth β€” no custom login/password logic in NestJS
  • Capability resolution is additive and cached β€” minimal overhead

Negative:

  • Multi-tenant users see a tenant picker at login β€” adds one click
  • Tenant switch requires full frontend cache invalidation β€” brief loading state
  • Short-lived JWTs mean more frequent refresh cycles (rotation mitigates this)
  • Capability cache invalidation on grant changes adds complexity

Related decision: ADR-030 Subscription Tier Gating covers the X-Hasura-Plan injection mechanism (JWT injection vs. per-query resolution) and sync strategy.

Internal documentation β€” Busflow