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
- Revocación: mantener el borrado como única vía (status quo de Tanda 5) vs. añadir un claim
epochal 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 deAgentInstance) y es la única vía que revoca sin perder config, historial ni rol. - Rotación del refresh: blocklist de
jtien Valkey (descartada en Tanda 5 por exigir almacén persistente) vs. guardar eljtivigente 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 sinjti(flota ya instalada) siguen valiendo hasta la segunda rotación, para no dejar a la flota en producción sin ventana de migración. - 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
UniqueConstraintparcial (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
- Un Agente comprometido se puede congelar sin perder su ficha: revocar ya no exige borrar; el admin re-autoriza con
/reauthcuando corresponda. - Un refresh viejo (filtrado o robado) deja de valer y queda rastro
[SECURITY]enSystemLog, en vez de seguir sirviendo hasta 180 días. - Coste por request sin cambio relevante: la comparación de
epochviaja en la misma consulta que ya hacía la verificación de existencia del Agente. - Sin botón en la UI todavía:
revoke-tokenssolo se acciona por API; el botón en la pantalla de Fleet queda para el siguiente ciclo — deuda declarada, no oculta. - Doble ventana de migración: los refresh legacy sin
jtivalen hasta la segunda rotación (no rompe la flota ya instalada); el candado dereauth-selfsolo se activa tras una revocación explícita, nunca retroactivamente. - 10 tests nuevos (
tests/api/test_terminal_auth_t5b.py); los 29 del contrato de Tanda 5 pasan sin tocar — el cambio es aditivo sobre el contrato existente. - Deuda que sigue abierta (no tocada por este commit):
install_secretdereauth-self(prueba de posesión, cierrasa2-G1del todo) y el botón de revocación en la UI.
Status
accepted — v1.98.0 (commit 8080941d, PR #486).
Véase también
- [[decision—20260831—tanda-5-auth-agente-backend]]
- [[entity—terminal—endpoint—fleet]]
- [[entity—terminal—service—agent-auth-js]]
- [[entity—static—module—agent-auth]]
- [[feature—terminal—auth-local-agent-fase1]]
- [[feature—terminal—auth-local-fase-2]]
- [[concept—saas—multi-tenancy]]