ADR-044: La identidad firmada es de un solo uso y el search_path es solo el tenant¶
Estado: Aceptado Fecha: 2026-10-22 Autores: Giampiero (mantenedor principal); tanda C-7 (deuda B10 g, h) Relacionados: ADR-036 (identidad atada a método y ruta; enmendado), ADR-002 (schema por tenant; enmendado), ADR-035 (la identidad caduca a los 120 s), ADR-003 (SQL crudo)
Contexto¶
Dos garantías declaradas en B10 seguían abiertas:
- (g) Firma, método, ruta y ventana de 120 s dejaban una identidad capturada en la red interna reproducible muchas veces dentro de la ventana, con los permisos de esa persona.
- (h) El
search_pathde cada petición era"tenant_x", public. Si una tabla de tenant faltaba, la consulta se resolvía en silencio contra la homónima depublic. No era teórico: la verificación halló 14 tablas de tenant dentro depublic(causales,formas_envio,soportes,mensajes_rapidos, los nuevepinar_*yadmin_audit) con datos, creadas por migraciones de tenant que corrieron con un path cuyo primer schema no existía: elCREATE TABLEcae en el primero que sí existe.
Decisión¶
(g) Nonce de un solo uso¶
VERSION_FIRMA = "v3". Nueva cabeceraX-Identity-Nonce(128 bits,secrets.token_urlsafe(16), 22 caracteres[A-Za-z0-9_-]) dentro deCAMPOS_FIRMADOS, al final. Una forma distinta esIdentidadNoAutentica(el mismo mensaje que una firma mala).firmar()genera siempre uno nuevo y descarta cualquierX-Identity-Nonceentrante (sin distinguir mayúsculas). El llamante no puede aportar un nonce: todo reintento es una llamada nueva afirmar, es decir, una identidad nueva.verificar()sigue síncrona y pura; ahora devuelveIdentidadVerificada(tenant, nonce, caduca_en)(oNonesin secreto).- Registro:
IdentidadFirmadaMiddlewarereclama el nonce en Redis conSET clave 1 NX EX ttl, claveorpycamcp:identidad:nonce:v3:{tenant}:{nonce}(sin servicio: la misma identidad no vale en dos destinos). TTL = vida restante de la identidad en el reloj del verificador + el desfase tolerado (techomax_age + 2·skew= 180 s). Se reclama solo después de verificar la firma: sin el secreto, nadie llena Redis ni quema nonces ajenos. - Respuestas: reutilizada,
401 identidad_no_autentica(mismo cuerpo) yidentidad_reutilizadaen el registro (consha256(nonce)[:12], nunca el nonce); registro no disponible,503 identidad_no_verificableconRetry-After: 1. Cualquier excepción del cliente Redis esRegistroNoDisponible, nunca un booleano. - Alcance del fail-closed: solo peticiones con identidad de persona (
X-User-Id). La ventanilla pública,/health, los webhooks públicos y las llamadas internas sinX-User-Idsiguen vivos con Redis caído: ningún plazo legal queda sin canal. - Arranque: con
IDENTITY_SIGNING_SECRETy sinREDIS_URLel middleware respondelifespan.startup.failed: un fallo determinista de despliegue, no un 503 por petición. Un Redis que no responde al arrancar no impide arrancar. - Servicio a servicio: siguen re-firmando en cada llamada (gateway→archive consume n1, archive→signature n2, signature→storage n3). Se descartó reenviar la firma (está atada al método y la ruta del primer salto y su nonce ya estaría consumido) y exentar las llamadas con
X-Internal-Token(abriría justo el canal que se quiere cerrar). Guardián: en un bucle, elfirmarva en el mismo cuerpo que la llamada HTTP, y solo el middleware compartido leeX-Identity-*. - storage-service pasa a depender de Redis (dev y prod):
REDIS_URL+depends_on: redisyredisen surequirements. Es la única dependencia nueva.
(h) search_path = solo el tenant¶
SET search_path = "tenant_x"en los nueve servicios, los runners y los scripts.SET search_path = publica secas (conexión sin tenant) se queda. No se toca el rolorpycamcp_appni la configuración de Postgres.- Todo lo global se califica:
public.audit_log,public.tenants,public.schema_migrations,public.divipola_*... Las extensiones viven enpublicy se nombran calificadas:$1::public.vector,OPERATOR(public.<=>),public.gin_trgm_ops,public.vector_cosine_ops,public.unaccent,'public.spanish_unaccent'::regconfig;CREATE EXTENSION ... WITH SCHEMA public. - Migraciones: tenant-service global
005retira las 14 tablas huérfanas depublic(ypinar_seguimiento_append_only); tenant-service tenant016re-fija conALTER FUNCTION ... SET search_pathtoda función del schema cuyoproconfignombrepublic(eran 9 entenant_demo; la lista creció desde el diseño, por eso la migración es genérica); workflow tenant021retira_b5_jsonb_o_null(ayudante del backfill 020 sin otros usos); document global005y knowledge global002comprueban, fail-closed, quepg_trgm/unaccentyvectorsiguen enpublic, con un mensaje que remite a este ADR. - Guardián estático
shared/tests/test_el_search_path_es_solo_el_tenant.py: ningúnsearch_pathlleva coma (los tests pueden llevar dos tenants a propósito, nuncapublic); los objetos globales, derivados demigrations/global/, no se nombran sinpublic.; las extensiones se nombran calificadas;CREATE EXTENSIONdiceWITH SCHEMA public; con controles positivos sobre fuentes sintéticas. Una comprobación de integración contra Postgres real cruza la lista de extensiones conpg_depend. - Efecto buscado: la ausencia de una tabla de tenant pasa de «lee la de
public» aUndefinedTable(503); un runner contra un schema inexistente falla con3F000en vez de crear la tabla enpublic.
Alternativas descartadas¶
| (g) | Motivo |
|---|---|
| Registro en memoria por proceso | Correcto con una réplica, pero la garantía se rompe en silencio al escalar (2 réplicas o --workers>1): justo el olvido que no se ve |
| Época + nonce (script Lua) | Cierra el residuo de un vaciado de Redis, que en producción (AOF everysec) mide ~1 s; no compensa el costo |
| Exentar llamadas con token interno | Abriría el canal que se quiere cerrar y crearía dos regímenes |
| (h) | Motivo |
|---|---|
Schema extensions y path "tenant", extensions |
Exige ALTER EXTENSION ... SET SCHEMA como superusuario en cada despliegue y descalificar lo que ya dice public.unaccent/public.digest; la configuración de Postgres queda fuera del alcance |
Mantener , public y vaciar public de tablas |
Cierra el fallback solo mientras nadie cree otra tabla allí; no hay invariante verificable |
Helper único fijar_search_path() en shared |
No añade garantía que el guardián no dé y multiplica el diff; queda como mejora si el guardián vuelve a hallar divergencias |
Residuos declarados¶
- La carrera: quien captura una identidad y llega primero gana; la legítima recibe 401. El nonce protege contra la reproducción de una captura, no contra un atacante que gana la carrera.
- La firma sigue sin cubrir la query ni el cuerpo (residuo de ADR-036).
- Un servicio comprometido con el secreto maestro puede emitir cualquier identidad con nonces nuevos.
- Un reinicio de Redis pierde los nonces vivos: ≤ 1 s con AOF en producción; en desarrollo (sin
appendonly) hasta 180 s. - Redis no debe tener política de desalojo (
allkeys-*): con ella podría desalojar un nonce y reabrir la reproducción. Hoy esnoevictionpor defecto. - (h) acopla el sistema a que las extensiones vivan en
public; se hace explícito en las migraciones de comprobación.
Consecuencias¶
- Corte atómico: el formato v2 → v3 cambia la base del HMAC, sin rama de compatibilidad. El api-gateway y los nueve servicios se reconstruyen y arrancan a la vez; si conviven versiones, toda petición autenticada responde 401. Despliegue:
docker compose up -d --build api-gateway auth-service tenant-service document-service archive-service storage-service workflow-service notification-service signature-service knowledge-service. - Redis pasa a ser dependencia dura de toda petición con identidad de persona (el gateway sigue siendo fail-open en su limitador; este registro es el primer consumidor fail-closed en el camino síncrono). Una indisponibilidad de Redis es
503 identidad_no_verificable, observable, no un 401 mudo. - (h) no requiere corte atómico: el SQL calificado funciona con el path viejo y con el nuevo. Orden seguro: migraciones, luego servicios.