CreaRack-SL

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ó:

  • Validación de payload JSON + coerción de target_ids → evita crash por junk.
  • Logging de rechazos (anónimo, sin org) → auditoría.
  • Rama else para tipo de mensaje desconocido → no caiga en silencio.

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:

  • Asegurar el gate en 4 lugares simultáneamente es error-prone.
  • Cambios futuros (p.ej., cifrar credenciales SNMP) requieren editar 4 archivos.
  • Vulnerabilidad a un nuevo hueco en una vista que olvidemos gatear.

❌ Mover a un job Huey asíncrono

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

  • Aumenta latencia percibida (usuario abre página, ve vacío, espera a que se popule).
  • Requiere mensaje de confirmación/UI que diga “descubriendo dispositivos…”.
  • Se deja para Etapa 3 como carve-out.

✅ Extraer + gatear en vistas (elegido)

  • Lógica centralizada → fácil de mantener y auditar.
  • Gate explícito en cada vista que escribe.
  • Sin cambios de UX (provisión ocurre igual, pero validada).
  • Puente a futuro: si se mueve a Huey, el servicio ya está extraído.

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

  • Hallazgos arreglados: 2 ALTA + 4 MEDIA = 6 / 9 confirmados.
  • Líneas de código: -110 LOC (dedup) → views.py: 677 → 446.
  • Tests agregados: +4 (TestObservatoryViewsProvisioningGate).
  • Tokens consumidos (auditoría): 1.33M.
  • Complejidad futura: reducida (lógica centralizada).

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

  • [[feature—monitoring—auditoria-suprema-sa6-observatory-realtime]]
  • [[entity—monitoring—service—provisioning]]
  • [[concept—security—role-based-access-control]]
  • [[decision—20260415—require-perm-monitoring-endpoints]]