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 β
{
"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'sdefault_rolewithin the active tenant (fromuser_tenant_assignments.default_role).x-hasura-allowed-roles: All roles the user can assume. The system populates these from theirdefault_roleacross 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-idis 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.
| Endpoint | Owner | Purpose |
|---|---|---|
POST /signin/email-password | Nhost Auth | Standard email/password login |
POST /token | Nhost Auth | Refresh token rotation |
POST /auth/select-tenant | NestJS (custom) | Multi-tenant: select active tenant after login |
POST /auth/switch-tenant | NestJS (custom) | Switch active tenant context |
Login Flow β
- User authenticates via Nhost Auth (email + password / SSO).
- Nhost issues an initial JWT. Custom claims webhook queries
user_tenant_assignmentsfor the authenticated user. - Single tenant: JWT includes
x-hasura-tenant-idandx-hasura-roleimmediately. - 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. - Frontend stores the JWT and the
active_tenant_idin 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:
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_staffx-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) β
| Field | Value |
|---|---|
| Auth | Requires valid Nhost JWT (tenant context may be null) |
| Input | { tenant_id: UUID } |
| Success | 200 { jwt: string, refresh_token: string } β new JWT with selected tenant context |
| Errors | 403 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) β
| Field | Value |
|---|---|
| Auth | Authenticated (valid JWT with active tenant) |
| Input | { target_tenant_id: UUID } |
| Success | 200 { jwt: string } β new JWT with target tenant's context |
| Errors | 403 TENANT_NOT_ASSIGNED Β· 403 USER_DISABLED |
| Side effect | Audit log: { user_id, from_tenant_id, to_tenant_id, timestamp } in change_events |
| Rate limit | 10 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 β
| Field | Value |
|---|---|
| Auth | MANAGER role within the tenant |
| Input | { email: string, default_role: MANAGER | DISPATCHER | DRIVER, tenant_id: UUID } |
| Success | { user_id: UUID, assignment_id: UUID } |
| Errors | INSUFFICIENT_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
INVITEDstatus. 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 β
| Field | Value |
|---|---|
| Auth | MANAGER role within the tenant |
| Input | { user_id: UUID, tenant_id: UUID } |
| Success | { success: boolean } |
| Errors | INSUFFICIENT_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 β
| Field | Value |
|---|---|
| Auth | MANAGER 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 enum | DISPATCH, 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. |
| Errors | INSUFFICIENT_PERMISSIONS Β· INVALID_CAPABILITY Β· 409 GRANT_ALREADY_EXISTS Β· 404 GRANT_NOT_FOUND |
OnboardCrewMember β
| Field | Value |
|---|---|
| Auth | MANAGER role within the tenant |
| Input | { crew_member_id: UUID, email: string } |
| Success | { user_id: UUID, assignment_id: UUID } |
| Errors | CREW_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 touser_tenant_assignments.default_role = DRIVER. Guides also getDRIVERrole (same Driver PWA). Thecrew_members.rolefield 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 βͺ grantsBase capabilities per role:
| Role | Base Capabilities |
|---|---|
MANAGER | ALL (implicit) |
DISPATCHER | DISPATCH, BOOKING_MGMT, FLEET_MGMT |
DRIVER | DRIVER_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 Case | Resolution |
|---|---|---|
| E1 | User disabled while holding active JWT | JWT remains valid until expiry (max 15 min). Refresh returns 403 USER_DISABLED. NestJS middleware checks auth.users.disabled on sensitive mutations. |
| E2 | Tenant switch with unsaved form data | Frontend useTenantSwitch() composable checks Pinia formDirtyState. If dirty β confirmation dialog. |
| E3 | Manager removes themselves from tenant | Blocked: CANNOT_REMOVE_SELF. Another manager must remove them. |
| E4 | Last manager removed | Blocked: CANNOT_REMOVE_LAST_ADMIN. |
| E5 | User invited but never sets password | No 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. |
| E6 | Concurrent capability grant | Unique constraint (user_id, tenant_id, capability) β 409 GRANT_ALREADY_EXISTS. |
| E7 | CrewMember terminated but user still active | CrewMemberTerminated Event Trigger β remove UTA for this user+tenant (DRIVER role). If zero remaining UTAs β optionally disable user. |
Security Constraints β
| Constraint | Enforcement |
|---|---|
| User can only switch to assigned tenants | Backend validates against user_tenant_assignments |
| Disabled users cannot switch or refresh | Check auth.users.disabled |
| JWT expiry | 15 min + refresh token rotation (7-day TTL, single-use) |
| Tenant switch audit | change_events log with user_id, from_tenant_id, to_tenant_id |
| Rate limiting | Login: 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.