"""Unified role model — single source of truth for user roles. HARDENING (P0 batch): every role string used by seeds, RBAC checks, admin user management, login, and JWT validation derives from this module so the system can never silently drift between role vocabularies. Canonical roles are the human-readable taxonomy the whole product already uses (PRD §4, ``app/services/seed.py``, the frontend nav in base.html): Admin/Jerome, Admin/Wahab, CS Rep, CS Manager, FM Dispatcher, Tech, CEO, Director Legacy databases created under the pre-P0 open-registration builds can carry lowercase/nickname role strings (``technician``, ``cs``, ``fm``, ``ceo``, ``admin``, ``superadmin`` …) and underscore variants of the space-separated ones (``cs_rep``, ``cs_manager``, ``fm_dispatcher``). ``ROLE_ALIASES`` maps the *unambiguous* nicknames onto a canonical role so startup normalization (see ``app/services/seed.py::normalize_legacy_user_roles``) can converge the data. ``admin`` / ``superadmin`` are deliberately NOT aliased: they are identity-ambiguous (they cannot be attributed to Jerome or Wahab) and were mintable by anyone during the open-registration window, so they are treated as unknown and fail closed — the operator must remediate those rows manually (role cleanup on the live DB is owned by the deployer). """ from __future__ import annotations # ── Canonical taxonomy ──────────────────────────────────────────────── # Order is cosmetic; membership is what matters. CANONICAL_ROLES: tuple[str, ...] = ( "Admin/Jerome", "Admin/Wahab", "CS Rep", "CS Manager", "FM Dispatcher", "Tech", "Sub-contractor", "CEO", "Director", ) # Roles that pass admin gates (ticket DELETE, user management, …). ADMIN_ROLES: tuple[str, ...] = ("Admin/Jerome", "Admin/Wahab") # Roles shown to the frontend nav/assignment helpers as "technician" pool. TECHNICIAN_ROLE = "Tech" # External subcontractor labour (client directive 2026-09: Wahab). Sub-contractors # are assignable work resources tracked "under tech" — the FM coordinator owns # their tickets — so they must surface in the Assign-to picker next to Techs. SUBCONTRACTOR_ROLE = "Sub-contractor" # The exact roles the assignment pickers offer (active users only). # Client directive 2026-09-28: sub-contractors appear in the "Assign to" # list alongside the technicians — external labour tracked under the FM # coordinator, so they are a sub-tier of the Tech pool, not a separate # workflow lane. ASSIGNEE_POOL_ROLES: tuple[str, ...] = (TECHNICIAN_ROLE, SUBCONTRACTOR_ROLE) # ── Legacy alias → canonical mapping (case-insensitive) ─────────────── # Keys are lowercased. Unambiguous nicknames from legacy/early seeds and the # brief's role model ("technician/cs/fm/ceo") converge onto canonical roles. ROLE_ALIASES: dict[str, str] = { "technician": TECHNICIAN_ROLE, "tech": TECHNICIAN_ROLE, "cs": "CS Rep", "cs rep": "CS Rep", "cs_rep": "CS Rep", "cs representative": "CS Rep", "cs manager": "CS Manager", "cs_manager": "CS Manager", "fm": "FM Dispatcher", "fm dispatcher": "FM Dispatcher", "fm_dispatcher": "FM Dispatcher", "sub-contractor": SUBCONTRACTOR_ROLE, "sub contractor": SUBCONTRACTOR_ROLE, "subcontractor": SUBCONTRACTOR_ROLE, "sub_contractor": SUBCONTRACTOR_ROLE, "ceo": "CEO", "director": "Director", } # Aliases that are explicitly NOT auto-mapped (identity-ambiguous and/or # mintable by the old open register). They stay unknown → denied at login # and JWT validation until an operator remediates the row. _BLOCKED_LEGACY_ROLES = frozenset({"admin", "superadmin", "administrator"}) def normalize_role(role: str | None) -> str | None: """Return the canonical role for *role*, or ``None`` when unrecognised. ``Admin/Jerome`` → ``Admin/Jerome``; ``technician`` → ``Tech``; ``superadmin`` → ``None`` (unknown; caller must fail closed). """ if not role: return None stripped = role.strip() if stripped in CANONICAL_ROLES: return stripped return ROLE_ALIASES.get(stripped.lower()) def is_known_role(role: str | None) -> bool: """True when *role* is canonical or maps to a canonical role.""" return normalize_role(role) is not None def is_admin_role(role: str | None) -> bool: """True when *role* is one of the canonical administrator roles.""" return normalize_role(role) in ADMIN_ROLES def is_blocked_legacy_role(role: str | None) -> bool: """True for legacy ``admin``/``superadmin`` rows that need remediation. Such rows are not canonical, are not auto-mapped, and must not pass any authorization gate; an operator should reassign or remove them. """ return bool(role) and role.strip().lower() in _BLOCKED_LEGACY_ROLES