Saltar a contenido

ADR-012 · Service-to-service auth · shared-key header (no trusted-IP)

Fecha: 2026-04-27 Source: /srv/projects/cis/cis-plan/DECISIONS.md (do not edit here — re-split desde la fuente)


Contexto

Procesos sin sesión OIDC interactiva (pollers, CRONs, workers, microservicios internos) necesitan llamar endpoints HTTP de otros servicios CIS. Hay tres patrones plausibles para autenticarlos:

Patrón Mecanismo Ejemplo
(a) Trusted-IP El backend whitelist-ea IPs específicas (127.0.0.1, IPs de workers k8s, etc.) "el endpoint solo acepta requests de 10.0.0.5"
(b) Shared-key header El cliente manda X-Trusted-Poller: <secret>; servidor compara con hmac.compare_digest intercambio_poller.pycis-admin /contable/poller/recibir-dte
(c) HMAC firmado Cliente firma {timestamp}.{body_json} con HMAC-SHA256; servidor verifica firma + skew window cis-pagoscis-platform /api/v1/internal/credits/grant

Ya en producción tenemos ambos (b) y (c) operando — sin un ADR que los formalice como el patrón canónico.

Estado actual (ya construido, sin ADR previo):

  • Variant (b) — shared-key plano: el intercambio_poller (systemd timer cada 10 min, user=illanes00, EnvironmentFile=scripts/intercambio_poller.env) lee Gmail vía IMAP y POSTea XMLs DTE a cis-admin :8244 /api/v1/contable/poller/recibir-dte. Auth: header X-Trusted-Poller con valor de INTERCAMBIO_SHARED_KEY. El backend lo verifica en _check_trusted_poller (contable.py:921-932). El endpoint solo escucha en 127.0.0.1 (Caddy no proxea /poller/* al exterior).

  • Variant (c) — HMAC firmado con timestamp: cis-pagos (:8264) llama a cis-platform :8176 /api/v1/internal/credits/grant y /subscriptions/activate con:

  • X-Signature: HMAC-SHA256(key, "{timestamp}.{json.dumps(body, sort_keys=True, separators=(',', ':'))}")
  • X-Timestamp: unix int
  • Skew window: 300s (rechazo 401 fuera de la ventana)
  • Idempotencia: order_id en el body (lado servidor dedup-ea por (provider, external_id))
  • Implementación: cis-pagos/app/services/platform_client.py:38-77 + cis-platform/app/routes/internal.py:70-106.

  • Variant (c) también en cis-platform para webhooks de proveedores externos (Flow.cl, Stripe legacy) — mismo patrón firma+timestamp, distinta key por proveedor.

Alternativas consideradas

  1. (a) Trusted-IP puro
  2. Pro: setup mínimo, sin secret management.
  3. Contra: frágil. NAT, contenedores que cambian IP, multi-host deploys, IPv6 dual-stack — todos rompen el whitelist. La rotación es "modificar código + redeploy". Si un atacante toma el host (lateral movement), tiene acceso al endpoint sin más. No diferencia entre proceso legítimo y proceso comprometido en el mismo host. Descartado.

  4. (b) Shared-key header bare (sólo bearer secret)

  5. Pro: simple, fácil rotación (update vault → reiniciar ambos lados), hmac.compare_digest evita timing attacks.
  6. Contra: replay attacks si la conexión no es TLS (dentro de localhost no aplica, pero sí si cruza red). No hay integridad de body — un atacante MITM podría modificar el JSON manteniendo el header válido. Aceptable solo si el endpoint escucha en 127.0.0.1 o tras TLS interno.

  7. (c) HMAC firmado con timestamp + body

  8. Pro: firma cubre timestamp + body, replay window acotada por skew, integridad de body garantizada. Funciona aunque la conexión no sea TLS estricto.
  9. Contra: implementación más larga (~30 LOC cliente + servidor). Requiere reloj sincronizado entre ambos lados (NTP es suficiente). Skew window es trade-off entre tolerancia clock-drift y exposición a replay.

  10. (d) mTLS con certificados internos (PKI propia)

  11. Pro: standard, granular por servicio.
  12. Contra: overkill. Operar PKI interna (CA, rotación, revocación) suma complejidad sustancial. Para 5-10 servicios en un mismo VPS, mTLS no paga.

  13. (e) JWT firmado con clave compartida

  14. Pro: parece "más enterprise".
  15. Contra: JWT acarrea claims, jti, exp — útil para sesiones de usuario, no para S2S puro. Para nuestro caso, HMAC sobre body+timestamp es estrictamente más simple con la misma garantía.

Decisión

El patrón canónico para auth service-to-service en el ecosistema CIS es shared-key header, NO trusted-IP. Existen dos variantes según el riesgo de la conexión:

Variante débil (B) — X-Trusted-Poller

Cuándo usarla: - El endpoint escucha únicamente en 127.0.0.1 (Caddy no lo proxea al exterior, no es bindeable desde otro host). - El cliente y el servidor corren en el mismo host (ej. systemd timer + FastAPI service en vps-cis). - El payload no requiere protección de integridad fuerte (XMLs DTE en intercambio_poller son idempotentes por Message-ID; un MITM imposible en localhost).

Especificación:

Header: X-Trusted-Poller: <secret>
Verificación: hmac.compare_digest(header_value, env_var)
Endpoint binding: 127.0.0.1 únicamente
Secret en vault: <project>/<KEY>_SHARED_KEY (ej. cis-admin/INTERCAMBIO_SHARED_KEY)
Rotación: update vault → reiniciar ambos lados (cliente CRON + servidor)

Implementación de referencia: cis-admin/backend/app/api/v1/contable.py:921-932 (servidor) + cis-admin/scripts/intercambio_poller.py:131 (cliente).

Variante fuerte (C) — HMAC firmado con timestamp

Cuándo usarla: - El endpoint cruza red (aunque sea LAN interna). - Cliente y servidor pueden estar en hosts distintos. - El body lleva valores monetarios, identidades, o cambios de estado críticos (grant_credits, activate_subscription, webhooks de proveedores).

Especificación:

Headers:
  X-Signature: hmac.new(key, f"{timestamp}.{body_json}".encode(), sha256).hexdigest()
  X-Timestamp: <unix int seconds>
  Content-Type: application/json

body_json: json.dumps(body, sort_keys=True, separators=(",", ":"))
Skew window: 300s (settings.pagos_hmac_skew_seconds o equivalente)
Verificación servidor:
  - rechaza si headers faltan (401 "Missing HMAC headers")
  - rechaza si abs(now - ts) > skew (401 "Timestamp outside skew window")
  - rechaza si compare_digest(expected_sig, header_sig) == False (401 "Invalid signature")
Idempotencia: el body lleva un identifier estable (order_id, message_id) y el servidor dedup-ea
Secret en vault: <client>/<SERVICE>_INTERNAL_HMAC_KEY + <server>/<SERVICE>_INTERNAL_HMAC_KEY (mismo valor en ambos namespaces)
Rotación: update vault en ambos namespaces → reiniciar cliente + servidor (en ese orden, con warmup ~10s)

Implementación de referencia: cis-pagos/app/services/platform_client.py:38-77 (cliente) + cis-platform/app/routes/internal.py:70-106 (servidor).

Reglas comunes a ambas variantes

  1. Comparación constante en el tiempo: usar hmac.compare_digest(), nunca == para comparar secretos.
  2. Sin secrets hard-coded: secrets viven en vault, los procesos los leen via EnvironmentFile systemd o pydantic-settings.
  3. Sin secrets en logs: nunca log-ear el header value. Solo logear "auth ok" / "auth fail".
  4. No filtrar por trusted-IP: el patrón se sostiene incluso si la IP cambia. El secret es lo único que autentica.
  5. X-Forwarded-For no es señal de auth: documentado pero usado solo para audit logs, no para gating.
  6. Endpoints S2S NO se exponen al exterior: ni Caddy ni Authentik los proxean. El path típico es /api/v1/internal/* o /poller/* y solo bindea 127.0.0.1 cuando aplica.
  7. Audit log obligatorio: cada llamada S2S deja entrada en el audit_log del servidor (timestamp, endpoint, sig OK/fail, source IP).

Anti-patterns explícitos

  • ❌ Trusted-IP como único factor de auth.
  • ❌ Header secret transmitido en query string (queda en logs proxy).
  • ❌ HMAC con MD5 o SHA1 (usar SHA-256 mínimo).
  • ❌ Skew window > 600s (ventana de replay demasiado amplia).
  • ❌ Dos servicios compartiendo la misma key con servicio C (rotación se vuelve cascada).

Consecuencias

  • Positivo: rotación de secret es 1 comando vault + 1 restart. Sin frágilidad de IP. Endpoints S2S son testeables (los tests pueden mockear el header). El secret no aparece en código fuente — solo en vault y .env.
  • Negativo: cada nuevo servicio debe acordar la key con su contraparte (registrar en vault los dos namespaces, rotación coordinada). Cuando hay 10+ servicios, gestionar el mapa de keys requiere disciplina.
  • Operacional: los .env files deben tener perms 640 con grupo infra (lección aprendida del cis-sii-watch.service 2026-04-27 — el .env era 600/illanes00 y el unit corría como sopapo).
  • Multi-tenant: la key es por par-de-servicios, no por tenant. Si un día queremos aislar tenants en el plano S2S, hay que pasar a Variant (c) con tenant_id dentro del body firmado.
  • Observabilidad: cada rechazo 401 genera un log structlog con event=hmac_mismatch, client_ip, endpoint, timestamp. Patrón de rechazos repetidos = candidato a alerta en cis-monitoreo.
  • Migración existente: los servicios que ya usan trusted-IP solo (no aplicable hoy en CIS, todos los S2S ya están en variant b o c) — si aparecieran, deben migrar a este patrón al próximo cambio.

Relaciones

  • Aplica a: intercambio_pollercis-admin (variant b), cis-pagoscis-platform (variant c), webhooks Flow.cl → cis-pagos (variant c con key del proveedor), futuros workers/CRONs (variant b si local-only, variant c si cross-host).
  • Es compatible con la arquitectura existente sin cambios. Este ADR formaliza lo que ya está implementado en producción.
  • Es independiente de la auth de usuarios humanos (OIDC vía Authentik — ADR-014). Los dos planos no se cruzan: usuarios → OIDC, máquinas → shared-key.
  • Define el contrato canónico para futuras integraciones (cis-claudia, cis-mailer, monitoreo, etc.).

Implementación: ya en producción al momento de redactar este ADR (2026-04-27). Variant (b) operativa desde 2026-04-17 (poller intercambio); variant (c) operativa desde 2026-04-22 (cis-pagos). Pendiente: linter/check que verifique al CI que los endpoints /internal/* y /poller/* no estén expuestos en Caddy.