CT115 (Mumuni relay #747) — two live-instance defects after PR #12:
1. GET /api/whatsapp/mock-log 500'd with a valid admin token:
'no such column: whatsapp_log.message_text'. The model gained
message_text/wa_message_id/ticket_number (and dropped command) in
4afdc36 with no migration, so legacy DBs keep the (command, ...) shape.
ensure_legacy_schema (startup, app/main.py) now adds the three missing
columns idempotently and backfills legacy command bodies into
message_text before dropping the obsolete NOT NULL command column, so
both the mock-log read path and the ORM write path work on healed DBs.
New producer/consumer regression (tests/test_whatsapp_log_legacy_heal.py)
reproduces the exact OperationalError, then asserts 200 + data.
2. ROLE_ALIASES gap: underscore legacy roles (cs_rep, cs_manager,
fm_dispatcher) were not mapped, so normalize_legacy_user_roles could not
converge rows like user 18 (test@denya.com, role 'cs_rep') and the
frontend stranded them on /tickets. Added the underscore aliases; tests
assert normalize_role('cs_rep') == 'CS Rep' and a cs_rep row converges
and authenticates.
104 lines
4.0 KiB
Python
104 lines
4.0 KiB
Python
"""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",
|
|
"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"
|
|
|
|
# ── 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",
|
|
"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
|