CreaRack-SL

Auditoría Suprema monitoring sa6 — páginas del Observatory + tiempo real (s108)

Descripción

Sesión s108 (2026-06-04): auditoría de profundidad sobre la sub-área 6 del dominio monitoring — vistas HTMX del Observatory (Wireless, UPS, Signage, Network) y el canal WebSocket de tiempo real que activa gráficos en vivo.

Alcance auditado: ~960 LOC (monitoring/views.py 677 + monitoring/consumers.py 285).

Resultado: 9 hallazgos confirmados tras verificación adversarial escalonada:

  • 2 ALTA (seguridad crítica)
  • 4 MEDIA (importante pero acotado)
  • 3 BAJA (mejora menor)

Todos los hallazgos de riesgo alto/medio ya están corregidos en este commit.

Lo que se arreglaba

Broken Access Control en vistas del Observatory (ALTA)

Las 4 vistas (wireless_view, observatory_view, ups_view, signage_view) auto-provisionaban MonitoringTarget/DeviceProfile (escrituras en BD) bajo protección de solo @login_required, sin gate de rol. Un usuario con permisos de solo lectura (observatory:view) que abría la página disparaba esas altas automáticas — incidencia idéntica a la que se selló en los endpoints Ninja en #64/#65, pero las vistas server-rendered quedaban fuera del perímetro.

Además, la auto-provisión ocurría dentro del render GET (no idempotente): un prefetch del navegador, bot o recarga refrescaba los objetivos cada vez.

Fix: la auto-provisión ahora requiere has_permission(user, "observatory", "edit") + org ≠ None. Un usuario readonly ve la página íntegra pero ya no escribe. Se extrajo la lógica duplicada (110 LOC) al nuevo servicio monitoring/services/provisioning.py, permitiendo al caller situar el gate antes de cualquier escritura.

Guarda org=None (MEDIA)

get_current_org(request) puede devolver None (usuario sin organización). Las vistas pasaban directamente a filter(..., organization=None) o .create(..., organization=None) → riesgo de IntegrityError o datos huérfanos.

Fix: el helper _can_provision ahora exige que org sea no-nula antes de autorizar cualquier escritura.

N+1 en el repair del estado (down→unknown) (MEDIA)

El bucle que compila el estado de los objetivos hacía un .save() por iteración (mt.save() dentro del loop) — múltiples UPDATE de una sola tabla. Además, eso ocurría en el render GET, amplificado si la página se abría múltiples veces.

Fix: se acumula el estado en memoria y se persiste con un único bulk_update(...) — una sola query de escritura, solo si el usuario tiene permiso de edición.

Módulo > 500 LOC (Regla 5) + duplicación (MEDIA)

monitoring/views.py tenía 677 LOC; el patrón de auto-provisión era idéntico en las 4 vistas (wireless/ups/signage + Observatory general). Deuda de mantenibilidad.

Fix: se extrajo a monitoring/services/provisioning.py:

  • provision_profile_targets(org, profiles, scope) — auto-provisiona los MonitoringTarget para un lote de perfiles de dispositivos.
  • build_snmp_config(profile) — arma el dict de config SNMP desde credenciales del perfil.
  • _profile_port_map — mapa de puertos reutilizable.

views.py bajó a 446 LOC.

Hardening del WebSocket consumer (MEDIA + BAJA)

Validación de payload: receive() asumía que text_data parseaba bien y que los tipos eran correctos. Un payload malformado de un cliente autenticado provocaba ValueError o DataError sin atraparse.

Sanitización de target_ids: el cliente controla la lista de IDs a los que suscribirse. Se agregó _coerce_target_ids() — convierte a enteros limpios, descarta junk — de forma que nada malformado llega a la query (batch_check_access).

Aislamiento cross-tenant: ya era correcto (verificado en auditoría — no había fuga). connect() rechaza anónimos, batch_check_access filtra por org antes de cada group_add. Se agregó logging para visibilidad.

Rama else para tipos desconocidos: antes, un msg_type no reconocido caía en silencio. Ahora devuelve un error.

Fix detallado en monitoring/consumers.py:

# Valida que sea JSON válido
if not isinstance(data, dict):
    await self.send(...)
    return

# Coerce target_ids a ints limpios
target_ids = self._coerce_target_ids(data.get("target_ids"))

# batch_check_access() filtra por org ANTES de group_add
accessible_ids = await self.batch_check_access(target_ids)

# Log en rechazos de conexión
logger.warning("WS monitoring: rejected user %s without organization", ...)

Tests

Se agregaron 4 tests en tests/api/test_monitoring.py (TestObservatoryViewsProvisioningGate):

  1. Usuario readonly no provisiona al abrir la página.
  2. Usuario admin sí provisiona.
  3. Usuario sin organización no rompe el render (org guard).
  4. Coerción de target_ids del WebSocket sanea valores junk.

Suite de API: 262 passed / 1 skip, sin migraciones.

Métricas de la auditoría

  • Finders: 2 (ws-realtime, http-views) en paralelo.
  • Hallazgos crudos: 17.
  • Dedup: 17 → 11 (6 duplicados fundidos en grupos).
  • Verificación adversarial:
    • ALTA: 3 lentes (precision-código, impacto-real, novedad-validez).
    • MEDIA: 2 lentes (precision, impacto).
    • BAJA: 1 lente (precision).
  • Confirmados: 9 / 11.
  • Tokens consumidos: 1.33M (sesión económica).

Carve-outs para Etapa 3 (backlog):

  • Mover la auto-provisión a un job Huey (async, fuera del request).
  • Naming defensivo en profundidad: grupo WebSocket target_<org_id>_<target_id> (además de RLS y permisos).

Avance del dominio monitoring

Auditorías cerradas en monitoring:

  • ✅ sa1 (modelos, endpoints CRUD basics)
  • ✅ sa2 (endpoints Ninja + operaciones)
  • ✅ sa6 (vistas HTMX + realtime) ← Este commit

Pendiente: sa3, sa4, sa5 (IA, ITSM, CNS).

Véase también

  • [[entity—monitoring—service—provisioning]]
  • [[entity—monitoring—consumer—realtime-websocket]]
  • [[decision—20260604—broken-access-control-vistas-observatory]]
  • [[concept—saas—multi-tenancy]]
  • [[concept—security—role-based-access-control]]