-
Notifications
You must be signed in to change notification settings - Fork 0
Role and 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.
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.
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).
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/superadminimport { isTechnician } from '@/constants/roles';
if (isTechnician(auth.user?.role)) { ... } // accepts both spellingsif role == "tech" or role == "technician": ... # ✗ scatters variant handling
DISPATCH = {"dispatcher", "admin", "owner"} # ✗ new ad-hoc role set — use core/roles.pyA 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.
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.
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.
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.
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. billing →
invoices.read_all, users → users.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).
- Pick the gating permission:
- Has a natural fine-grained perm already in the catalog? Use it.
- Otherwise choose the tier:
nav.office(office-wide) ornav.admin(admins only). Field-tier (visible to everyone, incl. techs)? Omitpermission.
- 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.
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.