Files
denya-onecare/app/core/roles.py
T
root 3ae1062d65 P0 security batch: admin-only user mgmt, unified role model, login rate limiting, webhook secret, security headers, pagination caps
- Remove POST /api/auth/register (404); no sign-up UI; users are admin-managed
- Add admin-only POST/PATCH/DELETE /api/auth/users (forced canonical roles,
  self-lockout + reference guards)
- Unify role model in app/core/roles.py; reject unknown roles at creation and
  at login/JWT validation; startup normalizes unambiguous legacy aliases
- Login rate limiting ~5 fails/15 min per IP+email -> 429 (in-process, tunable)
- WhatsApp webhook requires X-Webhook-Secret; fail-closed when env unset;
  GET handshake uses constant-time verify token (403 on mismatch)
- GET /api/whatsapp/mock-log now requires auth
- Security headers middleware: X-Frame-Options DENY, nosniff, CSP on HTML,
  HSTS behind TLS
- Pagination: limit alias for page_size, hard cap enforced, both -> 422
2026-09-08 10:36:16 +00:00

100 lines
3.8 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`` …). ``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 representative": "CS Rep",
"cs manager": "CS Manager",
"fm": "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