Servicio publish_misses — enlaces de publicación de Signage rechazados (v1.124.0, task #276)
Propósito
signage/services/publish_misses.py registra en caché (Valkey) cada petición al enlace público /publish/<token>/ de un SignagePlayer que _get_player() (signage/views_publish.py) RECHAZA — token inexistente, revocado (publish_active=False) o caducado (publish_expires_at en el pasado; ver el hallazgo de seguridad G1 que introdujo esos dos campos, sesión 167). Antes de esta pieza (task #276), ese 404 solo quedaba en el log del contenedor: el CCIB estuvo 9 días con una pantalla sin actualizar tras un restore sin que nadie lo supiera hasta mirar los logs a mano.
Mega-auditoría B-08 (24-09-2026, commit 9e8d4123): un token revocado o caducado SÍ tiene dueño — el SignagePlayer existe y puede reactivarse con el mismo UUID —, así que su entrada guarda el organization_id del reproductor y el endpoint la filtra por la organización de quien pregunta. Antes, la lista era global: el administrador de cartelería de una organización veía los enlaces (y las IP) de las pantallas de otra.
Contrato
def record_publish_miss(token, reason: str, request=None, organization_id=None) -> None:
"""reason: 'unknown' | 'revoked' | 'expired'. organization_id: la del
player cuando el token existe (revoked/expired); None si no tiene dueño
(unknown). Nunca lanza — una caché caída no debe convertir un 404 en un 500."""
def list_publish_misses() -> list[dict]:
"""Enlaces rechazados con actividad en los últimos 7 días, el más
reciente primero. Sin filtrar — uso interno de list_publish_misses_for_org."""
def list_publish_misses_for_org(organization_id) -> list[dict]:
"""Lo que puede ver una organización: sus tokens (organization_id propio)
más los que no tienen dueño (organization_id=None, es decir 'unknown')."""
- Almacén: Valkey (
django.core.cache), NO base de datos — sigue siendo una señal reciente, no un histórico. Losunknownno tienen fila a la que colgarse (no pertenecen a ninguna organización); losrevoked/expiredsí tienen dueño desde B-08, pero se sigue guardando en caché por diseño previo (no hay migración). - TTL: 7 días sin nuevas peticiones a ese token. Tope de 50 tokens trackeados (
MAX_TRACKED) — los más antiguos caen del índice. - Cada entrada acumula
count,first_seen,last_seen,reason,organization_id(nuevo, B-08),remote_ip(primer tramo deX-Forwarded-ForoREMOTE_ADDR) ypath.
Endpoint
GET /api/monitoring/signage/publish-misses (monitoring/api/signage/deploy.py), gateado a require_perm(signage, admin). Desde B-08 ya NO es global: llama a list_publish_misses_for_org(org.id) con la organización de la sesión, así que cada administrador ve solo los enlaces revocados/caducados de su propia organización, más los unknown (sin dueño, visibles a todos). Los tokens listados están muertos: no dan acceso a nada, pero antes de B-08 exponían el UUID reactivable y la IP de pantallas ajenas.
Límite declarado
Los rechazos unknown (token que nunca existió, típico tras una restauración) y las entradas anotadas antes de este cambio (sin organization_id, caducan solas a los 7 días) se siguen mostrando a los administradores de todas las organizaciones, con su IP. Ocultarlos a quien no sea personal de la plataforma choca con el test existente test_admin_sees_list_and_viewer_does_not (exige que un administrador de Signage vea la lista global) — queda como decisión de producto pendiente, no como descuido.
Dependencias entrantes
| Módulo | Descripción |
|---|---|
signage/views_publish.py::_get_player() | Llama a record_publish_miss() en los 3 caminos de rechazo (unknown/revoked/expired); desde B-08 pasa organization_id=player.organization_id en revoked/expired |
monitoring/api/signage/deploy.py::list_publish_misses_api | Desde B-08 llama a list_publish_misses_for_org(org.id) en vez de list_publish_misses() directo |
static/js/pages/signage/SignageDeployManager.js::_refreshUnknownLinks() | Pinta la lista en la pestaña Historial de Signage; un 403 (no-admin) se traduce en ocultar el bloque, sin ruido |
Relación con el restore
Un SignagePlayer CREADO por un restore estrena publish_token (el token se excluye del backup a propósito, ver [[entity—racks—service—apply-full-restore-from-zip]]). El restore avisa de esto en su informe (notices.signage_players_new_links, misma pieza v1.124.0), pero si el aviso se ignora o el aparato no se reconfigura, sus peticiones al enlace viejo empiezan a aparecer aquí — es la red de seguridad de ese aviso.
Tests
tests/signage/test_ronda_0906_tanda3.py(9 tests, cubre las 3 piezas del commit86d6c636).tests/signage/test_mega_T05.py(mega-auditoría B-08, commit9e8d4123): aislamiento por organización delist_publish_misses_for_org, querecord_publish_missguardaorganization_iden revoked/expired y no en unknown, y que el endpoint filtra por la organización de la sesión.
Véase también
- [[entity—signage—model—signageplayer]] — el modelo cuyo
organization_idahora viaja con cada rechazo revoked/expired - [[entity—racks—service—apply-full-restore-from-zip]] — el restore que genera los
publish_tokennuevos que alimentan esta lista - [[decision—20260609—signage-broken-access-control]] — la decisión de control de acceso que este servicio complementa
- [[concept—signage—deployment]] — el flujo de despliegue de cartelería donde vive el enlace público
- [[incident—20260820—restore-roba-medios-signage-org-origen]] — otro caso de fuga entre organizaciones en el mismo flujo de restore
- [[incident—20260823—signage-publisher-cross-tenant-playlist-leak]] — fuga cross-tenant previa en el mismo módulo (
signage/views_publish.py), misma familia de fallo de aislamiento multi-tenant