Skip to content

Role and Nav Naming Conventions

Doug edited this page Jul 16, 2026 · 2 revisions

Role & Nav Naming Conventions

How we name roles and how nav-module visibility is decided. Read this before touching role strings or adding a nav module. Companion to role_permissions.md.


Part 1 — Role naming

The two-form problem (history)

The same role has been spelled multiple ways:

Concept SHORT (legacy) LONG (canonical)
Field technician tech technician
Shop-floor coordinator dispatch dispatcher
Platform superadmin super_admin (also seen: superadmin, super-admin)

As of #45, users.role stores the LONG (canonical) form. Both user-write paths (routers/users.py, core/tenant_ui.py) normalize input through normalize_role before persisting, and migration 009_canon_user_roles backfilled the remaining short rows. BUILTIN_ROLES keys and tenant_roles.name already use the LONG form. JWTs carry whatever users.role holds (now long). Legacy short rows may still surface from un-migrated tenants, so read sites must still normalize — see the SQL note below.

The rule

Canonical = the LONG form (matches BUILTIN_ROLES keys). There is ONE normalizer on each side; always normalize before an in-memory comparison.

Source of truth Normalizer Predicates
Backend core/roles.py normalize_role(raw) is_dispatch_manager, is_role_admin_actor, is_admin_tier, is_technician
Frontend constants/roles.js normalizeRole(raw) isTechnician, isAdminTier, humanizeRole

constants/roles.js is a mirror of core/roles.py — keep the alias map and canonical constants in sync (the tests test_roles_canonical.py / roles.spec.js lock both ends).

Do

from gdx_dispatch.core import roles
if roles.is_technician(user_role): ...          # accepts 'tech' AND 'technician'
if roles.is_dispatch_manager(user): ...         # owner/admin/dispatcher/manager/superadmin
import { isTechnician } from '@/constants/roles';
if (isTechnician(auth.user?.role)) { ... }      // accepts both spellings

Don't

if role == "tech" or role == "technician": ...  # ✗ scatters variant handling
DISPATCH = {"dispatcher", "admin", "owner"}      # ✗ new ad-hoc role set — use core/roles.py

SQL is the ONE exception

A query that filters a role COLUMN must use that column's stored form — normalize in Python, never in the query:

Column Stored form Example
users.role LONG (since #45) WHERE u.role IN ('technician','tech')
tenant_roles.name LONG WHERE r.name IN ('dispatcher','admin','owner')

users.role is now LONG, but include the legacy short form in role-filter SQL (IN ('technician','tech')) as belt-and-suspenders: an un-migrated tenant or a row written by an old client could still be short. If you compare a role you read from the DB against a canonical constant, normalize it in Python after the fetch.

Resolved — require_role normalizes both sides (#127)

The route gate require_role(...) (core/modules.py) now runs both its declared role list and the caller's role through normalize_role before comparing, so a gate still written with a legacy spelling ("tech") admits a canonical "technician" and vice-versa. (Before #127, four gates listing legacy "tech" 403'd every migrated technician.) normalize_role only collapses known aliases, so this never broadens access. Still write new gates with canonical spellings — the normalization is a safety net, not a style license. Note the separate require_role in core/auth_dispatcher.py operates on an already-canonical Principal and does not need this.

Resolved — one user-write vocabulary (#45)

users.role used to be written in two vocabularies (routers/users.py wrote SHORT, core/tenant_ui.py wrote LONG), so a row could be 'tech' or 'technician'. As of #45 both write paths run the role through normalize_role before persisting (canonical = LONG), and migration 009_canon_user_roles backfilled existing short rows. Reads should still normalize (normalize_role / isTechnician) — defense for any un-migrated legacy row — but the column is now single-vocabulary by construction.


Part 2 — Nav module visibility

Single source of truth = permissions

Nav visibility is permission-driven. There is no longer a hardcoded role allowlist. (The old FIELD_TECH_MODULES / OFFICE_MODULES Sets in useModuleSections.js, which decided visibility by role STRING in parallel to permissions, were deleted — they conflicted with the RBAC catalog.)

A user sees a module iff they hold the module's permission. Modules with no permission are field tier — visible to every role.

The three tiers

Every module in constants/modules.js is one of:

Tier permission: on the module Who sees it
field (none) everyone (all roles)
office nav.office (or its own fine-grained perm) dispatcher, sales, accounting, viewer, admin, owner
admin nav.admin (or its own fine-grained perm) admin, owner

nav.office / nav.admin are nav-visibility permissions in core/permissions.py (category navigation). They gate the nav ONLY — no API route enforces them. Grants:

  • nav.office → dispatcher, sales, accounting, viewer (admin/owner inherit it).
  • nav.admin → admin (via _all_except) and owner (wildcard) only.

A module that already has a meaningful fine-grained permission (e.g. billinginvoices.read_all, usersusers.read) keeps it instead of a nav.* tier perm. That means a role holding the fine permission sees the module even if it's admin-tier — this is intentional (e.g. the accounting role sees Expenses / Payroll / Budget; a read-only viewer sees Users / Settings).

Adding a nav module

  1. Pick the gating permission:
    • Has a natural fine-grained perm already in the catalog? Use it.
    • Otherwise choose the tier: nav.office (office-wide) or nav.admin (admins only). Field-tier (visible to everyone, incl. techs)? Omit permission.
  2. That's it — visibility is now editable per role in the Roles & Permissions UI (/role-permissions) by granting/revoking the permission.

The migration guard useModuleSections.spec.js enforces "every module is ungated (field) or carries a permission" and that no role loses access.


Why both live together

Roles and nav-permissions are the two halves of "who sees what". Keeping their naming conventions in one place is the point: one canonical role spelling, one permission-driven visibility model, both editable in the UI, no hardcoded parallel lists.

Clone this wiki locally