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.py → cis-admin /contable/poller/recibir-dte |
| (c) HMAC firmado | Cliente firma {timestamp}.{body_json} con HMAC-SHA256; servidor verifica firma + skew window |
cis-pagos → cis-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 acis-admin :8244 /api/v1/contable/poller/recibir-dte. Auth: headerX-Trusted-Pollercon valor deINTERCAMBIO_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 acis-platform :8176 /api/v1/internal/credits/granty/subscriptions/activatecon: 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_iden 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-platformpara webhooks de proveedores externos (Flow.cl, Stripe legacy) — mismo patrón firma+timestamp, distinta key por proveedor.
Alternativas consideradas
- (a) Trusted-IP puro
- Pro: setup mínimo, sin secret management.
-
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.
-
(b) Shared-key header bare (sólo bearer secret)
- Pro: simple, fácil rotación (update vault → reiniciar ambos lados),
hmac.compare_digestevita timing attacks. -
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.
-
(c) HMAC firmado con timestamp + body
- Pro: firma cubre timestamp + body, replay window acotada por skew, integridad de body garantizada. Funciona aunque la conexión no sea TLS estricto.
-
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.
-
(d) mTLS con certificados internos (PKI propia)
- Pro: standard, granular por servicio.
-
Contra: overkill. Operar PKI interna (CA, rotación, revocación) suma complejidad sustancial. Para 5-10 servicios en un mismo VPS, mTLS no paga.
-
(e) JWT firmado con clave compartida
- Pro: parece "más enterprise".
- 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¶
- Comparación constante en el tiempo: usar
hmac.compare_digest(), nunca==para comparar secretos. - Sin secrets hard-coded: secrets viven en vault, los procesos los leen via
EnvironmentFilesystemd o pydantic-settings. - Sin secrets en logs: nunca log-ear el header value. Solo logear "auth ok" / "auth fail".
- No filtrar por trusted-IP: el patrón se sostiene incluso si la IP cambia. El secret es lo único que autentica.
X-Forwarded-Forno es señal de auth: documentado pero usado solo para audit logs, no para gating.- 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. - 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
MD5oSHA1(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
.envfiles deben tener perms 640 con grupoinfra(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_iddentro 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_poller→cis-admin(variant b),cis-pagos→cis-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.