Volver a la wiki

Auditoría Suprema 2 · Tanda 5, 2ª pasada: revocar sin borrar, rotación del refresh y candado del invariante Primary

Contexto

La Tanda 5 de la Auditoría Suprema 2 ([[decision—20260831—tanda-5-auth-agente-backend]], commit 420d505f, v1.95.0) cerró 5 hallazgos ALTA y 2 MEDIA de la autenticación del Agente, pero dejó explícitamente 4 ítems como “deuda de la segunda pasada” por necesitar almacén persistente o migración de base de datos: rotación jti del refresh (detección de reutilización), token_epoch para revocar sin borrar, un índice único parcial como red de seguridad del invariante “1 Primary por organización”, y cachear el sensor de liveness del fleet. Este commit (v1.98.0, PR #486) cierra los cuatro.

Motivación operativa: hasta ahora la única forma de cortarle el acceso a un Agente comprometido era borrarlo de la flota, perdiendo config, historial y rol. Y cualquier refresh token emitido en los últimos 180 días seguía sirviendo — una copia de seguridad filtrada de hace meses bastaba para acuñar tokens hoy.

Opciones consideradas

  1. Revocación: mantener el borrado como única vía (status quo de Tanda 5) vs. añadir un claim epoch al JWT comparado contra un contador por Agente (token_epoch) — subirlo invalida al instante todos los tokens vivos sin tocar la fila. Elegida la segunda: no exige almacén nuevo (vive en la misma fila de AgentInstance) y es la única vía que revoca sin perder config, historial ni rol.
  2. Rotación del refresh: blocklist de jti en Valkey (descartada en Tanda 5 por exigir almacén persistente) vs. guardar el jti vigente y el anterior directamente en la fila del Agente (refresh_jti / refresh_prev_jti) — elegida la segunda. Ventana de dos: absorbe una respuesta de red perdida (el Agente que reintenta con el jti anterior recibe tokens frescos) sin abrir una ventana real de reutilización. Los refresh legacy sin jti (flota ya instalada) siguen valiendo hasta la segunda rotación, para no dejar a la flota en producción sin ventana de migración.
  3. Invariante “1 Primary por organización”: confiar solo en el advisory lock de Tanda 5 (ya lo garantiza en las vías conocidas: WS connect, failover) vs. blindarlo también en BD con un UniqueConstraint parcial (condition=Q(role="primary")) — elegida como red de seguridad ante cualquier vía nueva que el lock no cubra. Antes de aplicar el constraint, una migración de datos degrada los duplicados que pudieran quedar de la época sin lock.

Decisión elegida

Punto único de acuñado: issue_agent_tokens() (terminal/api/auth.py) sustituye las llamadas sueltas a generate_agent_token/generate_refresh_token en los 5 sitios que minteaban tokens (register, /refresh, reauth de admin, reauth-self, push por WebSocket) — rota el jti y sella el epoch vigente en cada emisión, así que ningún caller puede olvidarse de una de las dos protecciones.

POST /api/agent/fleet/{id}/revoke-tokens (permiso fleet:admin, endpoint nuevo) sube token_epoch, limpia la cadena de jti y echa el candado reauth_locked sobre /reauth-self: sin el candado, la revocación por epoch era papel mojado, porque reauth-self acuña tokens solo con el par agent_id+tenant_id que se lee del payload del propio JWT robado (sin cifrar) — el ladrón se re-acuñaría solo. El candado únicamente lo levanta /fleet/{id}/reauth, la vía de un admin.

La migración terminal/0009 fija el GUC app.current_org_id a '0' (bypass admin transaction-local) antes de deduplicar los Primaries: terminal_agentinstance tiene FORCE ROW LEVEL SECURITY, y sin ese GUC el RunPython de deduplicación vería 0 filas y el AddConstraint posterior podría fallar sobre datos que no llegó a limpiar.

De rendimiento: sentinel_liveness() (terminal/api/fleet.py) se cachea 60 s por organización — el sensor mide ventanas de 1 h y la UI lo sondeaba varias veces por minuto, cada vez con hasta 3 consultas a VictoriaMetrics.

Consecuencias

Status

accepted — v1.98.0 (commit 8080941d, PR #486).

Véase también

Subir