Volver a la wiki

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:

  1. POST /api/agent/register sin gate de permiso: bastaba pertenecer a la organización — un readonly, 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).
  2. 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 /refresh durante 180 días; el único apagado real era rotar AGENT_JWT_SECRET, que tumba a la flota entera.
  3. Tope de agent_self_reauth no atómico: cache.get + cache.set es 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).
  4. targets_updated decidía por un rol cacheado en memoria (self._agent_role, escrito solo en connect): 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.
  5. 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=False y se promocionaban los dos.
  6. [MEDIA] El failover no retiraba el rol al Primary saliente: quedaba offline pero con role="primary" — dos filas con ese rol, y el promote manual (que degrada con .first()) solo arreglaba una.
  7. [MEDIA] Mutaciones sensibles de flota sin auditoría: reauth (emite un par de tokens de 180 días con acceso a todas las credenciales), delete y set_fleet_config (poner manual apaga el failover automático de toda la organización) solo dejaban un logger.info, no SystemLog.

Opciones consideradas

  1. Rotar AGENT_JWT_SECRET como ú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.
  2. Lista de revocación (blocklist) de jti consultada 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.
  3. Verificar contra el estado vivo del AgentInstance en cada request (opción elegida): sin nuevo almacén, reutiliza una consulta ya indexada (agent_id es 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:

Consecuencias

Status

accepted — v1.95.0 (commit 420d505f, PR #481).

Véase también

Subir