Auditoría Suprema 2 · Tanda 5: revocación real del token del Agente y roles de flota sin condiciones de carrera
Contexto
El JWT del Agente es autocontenido: cualquier endpoint que lo verificaba (get_agent_from_request) solo comprobaba la firma y el tipo (access vs refresh), nunca si el AgentInstance seguía dado de alta. La Auditoría Suprema 2 encontró, en el mismo fichero de autenticación del Agente, 5 hallazgos ALTA y 2 MEDIA que compartían la misma raíz — falta de verificación contra el estado real de la flota, o falta de serialización al escribirlo:
POST /api/agent/registersin gate de permiso: bastaba pertenecer a la organización — unreadonly, que ni siquiera puede LISTAR la flota, podía acuñar un JWT de Agente, que abre toda la superficie Agent-facing (credenciales SSH/SNMP descifradas de la organización).- Revocación cosmética: borrar un Agente del Fleet Manager no le cortaba el acceso. El portátil robado seguía leyendo credenciales 72 h y renovándose por
/refreshdurante 180 días; el único apagado real era rotarAGENT_JWT_SECRET, que tumba a la flota entera. - Tope de
agent_self_reauthno atómico:cache.get+cache.setes un read-modify-write — N peticiones concurrentes leían todas 0 y pasaban todas, y ese tope de 3/hora es la única mitigación declarada de un endpoint que acuña tokens sin prueba de posesión (hallazgo previo sa2-G1). targets_updateddecidía por un rol cacheado en memoria (self._agent_role, escrito solo enconnect): tras un promote/demote o un failover, un Agente degradado a Secondary seguía recibiendo el lote de targets con credenciales SNMP en claro — justo lo que el guard A53 (auditoría #261) debía evitar.- Invariante “1 solo Primary online” sin transacción ni lock: dos Agentes reconectando a la vez (típico tras un deploy, cuando el WebSocket de toda la flota cae junto) leían ambos
has_primary=Falsey se promocionaban los dos. - [MEDIA] El failover no retiraba el rol al Primary saliente: quedaba
offlinepero conrole="primary"— dos filas con ese rol, y el promote manual (que degrada con.first()) solo arreglaba una. - [MEDIA] Mutaciones sensibles de flota sin auditoría:
reauth(emite un par de tokens de 180 días con acceso a todas las credenciales),deleteyset_fleet_config(ponermanualapaga el failover automático de toda la organización) solo dejaban unlogger.info, noSystemLog.
Opciones consideradas
- Rotar
AGENT_JWT_SECRETcomo única vía de revocación (status quo). Funciona pero es un interruptor de central: invalida a TODA la flota de TODAS las organizaciones para cortar el acceso a un solo Agente. - Lista de revocación (blocklist) de
jticonsultada en cada request. Cierra el mismo agujero pero exige almacén persistente adicional y no estaba disponible en esta tanda — queda anotado como deuda de la segunda pasada. - Verificar contra el estado vivo del
AgentInstanceen cada request (opción elegida): sin nuevo almacén, reutiliza una consulta ya indexada (agent_ides unique + db_index) y convierte el borrado en la fuente de verdad de la revocación.
Para las condiciones de carrera de rol (Primary/Secondary), se evaluó select_for_update() sobre las filas de AgentInstance y se descartó: un lock de filas no protege a la organización que todavía no tiene ninguna fila — que es justo el caso de dos altas simultáneas compitiendo por ser el primer Primary. Se eligió un advisory lock de PostgreSQL (pg_advisory_xact_lock) namespaced por organization_id, que serializa aunque no haya filas que bloquear.
Decisión elegida
Contrato nuevo: un token de Agente solo vale si su Agente sigue en la flota. _agent_exists() (terminal/api/auth.py) se añade a get_agent_from_request y al endpoint /refresh; delete_agent pasa a ser revocación real, no cosmética.
El resto de fixes, todos en el mismo commit:
register_agentexigefleet:admin(mismo permiso que sus hermanosreauth_agentyget_local_token).agent_self_reauthcambia el contador acache.add+cache.incr(INCR de Valkey, atómico).targets_updatedlee el rol vigente con_current_role(), una consulta a BD poragent_id, en vez de fiarse del atributo cacheado enconnect._db_registery_db_failover(terminal/fleet_lifecycle.py) corren dentro detransaction.atomic()con el advisory lock por organización.- El failover pone
role="secondary"explícito en el Primary saliente y registra el cambio conrecord_role_change— salvo en modomanual, donde el rol lo fija el admin y debe sobrevivir a una desconexión. reauth,deleteyset_fleet_configescriben enSystemLogvíalog_action(el de reauth a nivelWARNINGcon prefijo[SECURITY], por paridad conagent_self_reauth).
Consecuencias
- Recupera la garantía de que “Delete” en el Fleet Manager corta el acceso de verdad, sin tener que rotar el secreto de toda la flota.
- Cambia el contrato de los tests Agent-facing: un token solo es válido si existe su
AgentInstance— 13 ficheros de test se actualizaron para crear la fila antes de minar el JWT (sin relajar ningún assert: en producción todo token nace deregister_agentoreauth, que sí crean la fila). 14 tests nuevos entests/api/test_terminal_auth_t5.py. - Coste por request: una consulta indexada adicional en cada verificación de token de Agente (microsegundos,
agent_ides unique + db_index) y una consulta a BD por lote detargets_updateden vez de leer un atributo en memoria. - Sin migración y sin tocar el
.exedel Agente instalado: todo el cambio vive en el backend. - Deuda diferida a propósito — CERRADA en la 2ª pasada ([[decision—20260901—tanda-5-auth-agente-2a-pasada]], commit
8080941d, v1.98.0):jti+ detección de reutilización del refresh,token_epochpara revocar sin borrar, el índice único parcial del invariante de Primary, y la caché del sensor de liveness delist_fleetya están resueltos. Sigue abierto soloinstall_secret(prueba de posesión enagent_self_reauth, cierrasa2-G1del todo), pendiente por la ventana de migración de Agentes ya instalados.
Status
accepted — v1.95.0 (commit 420d505f, PR #481).
Véase también
- [[decision—20260710—auditoria-2026-07-fase-1]]
- [[decision—20260829—sa4-g2-command-smuggling-cerrado]]
- [[decision—20260823—racks-settings-broken-access-control]]
- [[entity—terminal—service—agent-auth-js]]
- [[entity—static—module—agent-auth]]
- [[feature—terminal—auth-local-agent-fase1]]
- [[entity—terminal—endpoint—fleet]]
- [[concept—saas—multi-tenancy]]
- [[decision—20260901—tanda-5-auth-agente-2a-pasada]] — 2ª pasada, cierra la deuda diferida de esta decisión