Skip to content

Roles

Overview

The role system is deliberately simple: four roles, two scope types, and pure functions that check them. There's no permission catalog, no entity-level access control, no database queries at check time. Roles live in the JWT, and checking them is a synchronous function call.

This simplicity was a conscious design choice — a previous permission system with ~1500 lines of grammar/catalog/service code was removed in favor of this approach.

The Four Roles

Role Purpose
ADMIN Full system access. Superrole — passes any role check.
STAFF Operations access. Can be global or location-scoped.
USER Default role for all members.
PARTNER External partners (referral programs).

Scoping

Roles can be global (scope_type: null) or location-scoped (scope_type: 'location', scope_id: '<locationId>').

A staff member at one location gets { role: 'STAFF', scope_type: 'location', scope_id: 'loc-123' }. A global staff member gets { role: 'STAFF', scope_type: null, scope_id: null }.

Global ADMIN is a superrole — hasRole(claims, anyRole, anyLocation) always returns true if the user has a global ADMIN entry. This means admin checks don't need to be special-cased throughout the codebase.

Role Claims in the JWT

interface RoleClaims {
  roles: RoleAssignment[]
}
interface RoleAssignment {
  role: string
  scope_type: string | null
  scope_id: string | null
}

Embedded at app_metadata.roles in the Supabase JWT by the custom_access_token_hook Postgres function. See [[auth/overview|Auth]] for the JWT flow.

Role Check Functions

All pure and synchronous, at src/utils/auth/role-check.ts. They operate only on RoleClaims — no database, no async.

Function What it checks
isAdmin(claims) Global ADMIN role
isStaff(claims) Global STAFF role (or global ADMIN)
isStaffAt(claims, locationId) Global ADMIN, global STAFF, or location-scoped STAFF at that location
isStaffOrAbove(claims) Any ADMIN or STAFF entry (ignores scope — used for list endpoints)
isStaffOrAboveAt(claims, locationId) isAdmin OR isStaffAt for that location
hasScopedRoleAt(claims, locationId) Global ADMIN or location-scoped STAFF at that location (not global STAFF)
getScopedLocationIds(claims) All location IDs the user has scoped roles for
hasRole(claims, role, locationId?) The primitive — all above functions call this

The key subtlety: isStaffOrAbove ignores scope_type entirely — a STAFF scoped to any single location satisfies it. This is intentional for list endpoints where any staff member should have access to the list (with results filtered by their scope).

Staleness and Session Revocation

Role changes only take effect at the next JWT refresh (every 15 minutes). The system mitigates this by calling revokeUserSessions(userId) (at src/lib/auth/token-refresh.ts) after any role assignment or removal, which deletes rows from auth.sessions via raw SQL, forcing the user to re-authenticate and get a fresh token.

Code Map

src/utils/auth/role-check.ts                All role-check functions
src/lib/auth/token-refresh.ts               revokeUserSessions
packages/shared-types/src/auth/index.ts     Role enum, RoleClaims, RoleAssignment

See Also

  • [[auth/overview|Auth]] — JWT flow and validateAuthentication
  • [[auth/impersonation]] — How impersonation loads target roles fresh from DB
  • [[users]] — User model and role assignment