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 losMonitoringTargetpara 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):
- Usuario
readonlyno provisiona al abrir la página. - Usuario
adminsí provisiona. - Usuario sin organización no rompe el render (org guard).
- Coerción de
target_idsdel 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]]