ADR · Autenticación local cross-origin del Local Agent (Auditoría Suprema · terminal · Raíz 3)
Contexto
La Auditoría Suprema del dominio terminal (s122, ~18k LOC, el mayor del proyecto) confirmó 6 raíces. Cinco ya están rectificadas (backend raíces 1+2 en s123, PRs #96/#97; Agent raíces 4/5/6 en s124, Tanda B1, PR #98 / Agent 2.0.22). Queda la Raíz 3, la más prolífica del dominio: la API HTTP local del Agent (FastAPI, localhost:5050) no tiene autenticación y el CORS está abierto de par en par.
Hallazgo (terminal-sa4.json, 12 ALTA confirmados, 3/3 votos):
terminal/agent/main.pyL608-650create_app():FastAPI(...)sindependencies=global; los 11 routers (health/saas/sentinel/network/terminal/cluster/metrics/traps/admin/signage/licenses) se montan sin ningúnDependsde auth.terminal/agent/main.pyL629-635:CORSMiddleware(allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])+X-Frame-Options: ALLOWALL.- Bind a
127.0.0.1:5050(L714) — limita pero no elimina: conallow_origins=["*"]+allow_credentials=True, Starlette refleja elOriginrecibido, de modo que cualquier web que el usuario visite mientras el Agent corre puede invocar endpoints cross-origin (clase CSRF / DNS-rebinding) y leer las respuestas.
Impacto: endpoints alcanzables sin credenciales que ejecutan comandos en dispositivos (/cluster/execute, /cluster/script, /network/ssh-show), abren SSH/SFTP (/sftp/*), escanean la red interna del cliente (/check, /network/*), despliegan signage y hasta desinstalan el Agent (/admin/uninstall). La denylist de handle_ai_remediate (main.py L414-436) es teatro de seguridad (prefix-match evadible y solo cubre el remediador IA, no los endpoints HTTP).
Decisión
Implementar un esquema de token de autenticación local mediado por el SaaS, aplicado como dependencia global de FastAPI en el Agent, + cierre del CORS al origin del SaaS + guard de Origin/Host anti DNS-rebinding.
El secreto lo crea el Agent, lo custodia el SaaS, y el SaaS solo lo entrega a una sesión web autenticada del tenant correcto. Una web ajena no está logueada en el backend del tenant → nunca obtiene el token; y con el CORS cerrado ni siquiera puede leer las respuestas del Agent.
Naturaleza del token (decidido)
Token opaco aleatorio (secrets.token_urlsafe(32)), no JWT firmado. Validación = comparación en tiempo constante (hmac.compare_digest), sin criptografía, sin claves que distribuir en el .exe, sin relojes/caducidad que cuadrar (se rota al re-emparejar). Es por-agente, nunca una clave global embebida en el binario distribuido.
Flujo
Emparejamiento (una vez · reusa el canal que YA existe):
- El Agent genera
local_session_token = secrets.token_urlsafe(32)y lo guarda cifrado con DPAPI (igual quecredentials.encencore/auth.py). - Lo registra en el SaaS por el canal autenticado existente (el WebSocket Agent↔SaaS /
POST /saas/setup), quedando ligado alagent_id+organization(tenant) enAgentInstance.
El canal de confianza frontend→Agent ya existe: hoy
POST localhost:5050/saas/setup(routes/saas.pyL112-162) recibe del frontend logueado el JWT del usuario (token+refresh_token) y el Agent lo persiste cifrado. No hay que inventar el emparejamiento: ya está montado.
Uso (cada interacción del frontend con el Agent):
3. El navegador (sesión Django válida del tenant) pide el token a un endpoint nuevo del backend: GET /api/agent/local-token → responde solo con sesión autenticada y si el agente pertenece a ese tenant (devuelve 403 en otro caso).
4. El frontend lo envía en Authorization: Bearer <local_session_token> en cada fetch a localhost:5050.
5. El Agent valida (comparación en tiempo constante) + comprueba que el Origin es https://*.crearack.com (guard anti DNS-rebinding).
Custodia del secreto en el SaaS
Campo nuevo en AgentInstance (terminal/models.py:27), p.ej. local_token_enc, cifrado con CREDENTIAL_ENCRYPTION_KEY (la clave dedicada del Plan Hardening E, ya en PROD). El SaaS lo guarda recuperable (no solo hash) porque debe entregarlo en claro al frontend autenticado; el cifrado en reposo cubre el caso de fuga de BD.
Aplicación en el Agent
app = FastAPI(dependencies=[Depends(require_local_token)]) → cubre los 11 routers de un plumazo, salvo /health (latido de liveness, único endpoint no autenticado). Esto cierra los 12 ALTA simultáneamente, porque todos comparten la misma raíz “alcanzable sin credenciales”.
Plan de despliegue por fases (orden crítico)
El Agent no está en CI (Windows-only) → gate = smoke manual de Edu. El orden evita dejar el terminal inservible durante la transición:
- Fase 1 — Backend SaaS (PR normal, inerte): migración con
local_token_encenAgentInstance+ endpointGET /api/agent/local-token(sesión autenticada + scope de tenant) + recepción/custodia del secreto que registra el Agent. No afecta a nadie hasta la Fase 2. - Fase 2 — Frontend manda el token (deploy): centralizar el envío del
Authorizationenstatic/js/modules/agent_connector.js(un único punto), en lugar de tocar los ~35 ficheros JS que llaman a:5050. Inocuo para.exeviejos (un Agent viejo ignora el header). Dejar propagar (caché del navegador). - Fase 3 — Agent que EXIGE el token (Agent 2.0.25+, rebuild + release): cuando el frontend ya manda el token en PROD, publicar el
.exeque lo exige + cierra CORS a*.crearack.com+ check de Origin.build_agent.bata mano en el PC de Edu →CreaRackAgent.exe→ release lo publica el agente.
Riesgo residual: frontend viejo cacheado + Agent nuevo. Como el frontend lo sirve el SaaS y se refresca al recargar, la ventana es corta; mantener margen temporal entre Fase 2 y Fase 3.
Carve-out (cambian contrato, fuera de esta Raíz pero del mismo dominio)
Pendientes que tocan contrato y se tratan aparte (no son la Raíz 3 pura): agent_self_reauth (acuña tokens solo con agent_id+tenant_id sin prueba de posesión — SA2), códigos de la ingesta de métricas, JWT en query-param del WS (get_websocket_url en core/auth.py L291-295), token en download_url.
Hardening backend diferido (PR normal, no urge)
UniqueConstraint parcial de Primary + carrera de consumers._register_and_assign_role (requieren migración + dedup de datos previo).
Alternativas descartadas
- El Agent valida el JWT del SaaS (el frontend manda su credencial del SaaS al Agent): acopla el Agent a la criptografía/llaves del SaaS y expone el token del SaaS al proceso local. Descartada por acoplamiento.
- Emparejamiento manual (PIN): el usuario pega un código una vez. Más simple en backend pero añade fricción de onboarding y carga de soporte. Descartada por UX.
- JWT corto firmado por-agente (en vez de opaco): añade gestión de relojes/skew y refresco continuo en el frontend para el mismo objetivo. Descartada por piezas móviles.
Consecuencias
- ✅ Cierra los 12 ALTA de la Raíz 3 con una dependencia global + cierre de CORS.
- ✅ Reusa el canal Agent↔SaaS existente; sin clave global en el binario; validación local (offline-friendly).
- ⚠️ Requiere despliegue coordinado en 3 fases con grace window (el
.exedistribuido + frontend deben convivir). - ⚠️ El secreto vive cifrado en la BD del SaaS (acepta el trade-off; mitigado por
CREDENTIAL_ENCRYPTION_KEY).
Estado
Aceptada (10-06-2026). Esquema y naturaleza del token decididos por Edu. Pendiente: implementación Fase 1 tras luz verde sobre este ADR.
Véase también
- [[feature—terminal—auth-local-agent-fase1]]
- [[feature—agent—fase-3-auth-local]]
- [[decision—20260611—fase-3-auth-local-enforcement-on]]