Volver a la wiki

Decisión de arquitectura — Mitigación de Broken Access Control en vistas HTMX del Observatory

Contexto

Auditoría sa6 (monitoring, sesión s108): 9 hallazgos confirmados en vistas HTMX + realtime del Observatory. El hallazgo ALTA más grave:

Las 4 vistas (wireless_view, observatory_view, ups_view, signage_view) auto-provisionan MonitoringTarget/DeviceProfile — escriben en BD. Esas escrituras estaban bajo protección de solo @login_required, sin validación de rol.

Un usuario con permiso observatory:view (solo lectura) podía abrir la página y disparar esas altas automáticas, violando el principio de separación de permisos.

Paralelamente, en los commits #64/#65 se había cerrado el mismo hueco en los endpoints Ninja de monitoring (require_perm aplicado). Las vistas server-rendered quedaron fuera del perímetro.

Problema

  1. Broken Access Control: @login_required sin role check → cualquier usuario autenticado puede causar escrituras.
  2. Writes en GET: la auto-provisión ocurría durante el render de la página (GET), no idempotente — cada reload refrescaba los datos.
  3. Duplicación: la lógica de provisión era idéntica (~110 LOC) en 4 vistas hermanas → difícil de mantener y asegurar en todos lados.
  4. Guarda org=None débil: get_current_org puede devolver None; las vistas pasaban ese valor a queries sin validar.

Decisión

1. Extraer lógica a un servicio (monitoring/services/provisioning.py)

Beneficio: la lógica de escritura vive en un solo lugar. El caller puede decidir si ejecutarla o no, y puede gatearla detrás de un permiso.

Implementación:

# En vistas:
if has_permission(user, "observatory", "edit"):
    provision_profile_targets(org, profiles, scope)

Ahora el caller es responsable de validar el permiso antes de llamar. El servicio no valida (separación de responsabilidades).

2. Gate en las vistas: observatory:edit

Las 4 vistas ahora:

  1. Obtienen org = get_current_org(request) — rechaza si org is None.
  2. Validan has_permission(user, "observatory", "edit") antes de any write.
  3. Solo si ambas condiciones son verdad, llaman provision_profile_targets(...).
  4. Un usuario readonly (observatory:view) sigue viendo la página íntegra pero no escribe.

Beneficio: seguridad defensiva en capas — el permiso es el principal gate; la validación en el servicio es secundaria.

3. Refactorizar monitoring/consumers.py

WebSocket realtime ya tenía aislamiento correcto (no había fuga cross-tenant). Se reforzó:

4. Cumplir Regla 5: módulo >500 LOC

views.py tenía 677 LOC. Extrayendo la provisión y deduplicando, bajó a 446 LOC.

Implementación

Commit f263a03

Cambios:

  1. monitoring/services/provisioning.py (new, 89 LOC):

    • provision_profile_targets(org, profiles, scope) — auto-provisiona batch de targets.
    • build_snmp_config(profile) — compila dict SNMP desde credenciales.
  2. monitoring/views.py (modified, 677 → 446 LOC):

    • _can_provision(user, org) helper — valida has_permission(user, "observatory", "edit") + org ≠ None.
    • Reemplaza 4 copias del loop inline con una llamada a provision_profile_targets(...) dentro del guard.
    • Elimina ~110 LOC de duplicación.
  3. monitoring/consumers.py (modified, 285 LOC):

    • _coerce_target_ids(raw) — convierte client input a ints limpios.
    • Validación de payload: json.loads en try/except, tipo check isinstance(data, dict).
    • Logging en rechazos de conexión.
    • Rama else para tipo desconocido.
  4. tests/api/test_monitoring.py (modified):

    • +4 tests (TestObservatoryViewsProvisioningGate):
      • Readonly user no provisiona.
      • Admin user sí provisiona.
      • User sin org no rompe render.
      • WebSocket coerce de target_ids.

Alternativas consideradas

❌ No extraer — dejar la lógica duplicada en views

Más simple en el corto plazo. Pero:

❌ Mover a un job Huey asíncrono

Más robusto a largo plazo (no bloquea el request). Pero:

✅ Extraer + gatear en vistas (elegido)

Riesgos mitigados

RiesgoMitigación
User readonly dispara writesGate observatory:edit + validación de rol en cada vista
Concurrent race: dos provisiones simultáneasbulk_create(..., ignore_conflicts=True) → idempotente
Org nula → integrity errorHelper _can_provision rechaza org is None
Payload malformado en WebSocket → crashTry/except + validación de tipo
N+1 en provision (mt.save() por iteración)Extrayendo a servicio y usando bulk_update

Métricas

Próximos pasos (Etapa 3)

  1. Mover provisión a job Huey (async, fuera del request).
  2. Hardening en profundidad: nombre de grupo WebSocket target_<org_id>_<id> (redundancia).
  3. Auditar sa3, sa4, sa5 de monitoring (IA, ITSM, CNS).

Véase también

Subir