CreaRack-SL

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:

  • register_agent exige fleet:admin (mismo permiso que sus hermanos reauth_agent y get_local_token).
  • agent_self_reauth cambia el contador a cache.add + cache.incr (INCR de Valkey, atómico).
  • targets_updated lee el rol vigente con _current_role(), una consulta a BD por agent_id, en vez de fiarse del atributo cacheado en connect.
  • _db_register y _db_failover (terminal/fleet_lifecycle.py) corren dentro de transaction.atomic() con el advisory lock por organización.
  • El failover pone role="secondary" explícito en el Primary saliente y registra el cambio con record_role_change — salvo en modo manual, donde el rol lo fija el admin y debe sobrevivir a una desconexión.
  • reauth, delete y set_fleet_config escriben en SystemLog vía log_action (el de reauth a nivel WARNING con prefijo [SECURITY], por paridad con agent_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 de register_agent o reauth, que sí crean la fila). 14 tests nuevos en tests/api/test_terminal_auth_t5.py.
  • Coste por request: una consulta indexada adicional en cada verificación de token de Agente (microsegundos, agent_id es unique + db_index) y una consulta a BD por lote de targets_updated en vez de leer un atributo en memoria.
  • Sin migración y sin tocar el .exe del 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_epoch para revocar sin borrar, el índice único parcial del invariante de Primary, y la caché del sensor de liveness de list_fleet ya están resueltos. Sigue abierto solo install_secret (prueba de posesión en agent_self_reauth, cierra sa2-G1 del 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