El gateway no vive solo: el JWT que no valida la audiencia, el usuario que nunca llega a la traza y las costuras que hay que escribir a mano

Octavo artículo del track operativo de la capa de control. Los siete anteriores tratan el gateway por dentro: el par con Langfuse, el día 2, las claves virtuales, humanos y agentes, el enrutado por prefijo, el gateway MCP y el dimensionado para agentes. Este trata lo que hay alrededor. Verificado contra LiteLLM 1.102.0 y Langfuse 4.35.0, ambos con commit del 11 de septiembre de 2026.

TL;DR

El gateway tiene cinco vecinos: una base de datos, una caché, un proveedor de identidad, un backend de trazas y un colector. Cada vecindad tiene su propia física, y estas son las siete cosas que decide.

El proxy arranca sin Postgres, pero entonces solo funciona la clave maestra. La variable DATABASE_URL es opcional (proxy_server.py:1163); sin ella, cualquier clave que no sea la maestra devuelve un 400 de conexión ausente (user_api_key_auth.py:1886). Con base de datos configurada pero caída, el comportamiento por defecto es devolver 503, salvo que se active general_settings.allow_requests_on_db_unavailable.

Redis nunca es obligatorio, y esa es justo la trampa. Hay un cortacircuitos con umbral de cinco fallos y recuperación de sesenta segundos (constants.py:456). Cuando se abre, el proxy no falla ninguna petición: degrada a memoria local. Lo que se pierde en silencio es que los límites de tasa dejen de ser globales y pasen a ser por pod.

Ningún backend de observabilidad puede tumbar una petición. Todos los callbacks se lanzan con asyncio.create_task y sus excepciones se capturan y registran (litellm_logging.py:3235). Si Langfuse no responde, los eventos se descartan al llenarse la cola. No hay latencia añadida, y tampoco hay registro fiable: la observabilidad del par es un dato estadístico, no un registro de auditoría.

La autenticación por JWT no valida ni la audiencia ni el emisor si no se definen dos variables. En _build_decode_kwargs (handle_jwt.py:1003) la audiencia sale de JWT_AUDIENCE y el emisor de JWT_ISSUER; si están vacías, el código fija verify_aud=False y verify_iss=False y emite un aviso una sola vez. Cualquier token válido firmado por ese proveedor, emitido para otro servicio, es aceptado.

El control de acceso basado en roles viene desactivado. enforce_rbac tiene valor por defecto False (_types.py:4848 y siguientes). Con ese valor, un token cuyo rol no se resuelve a nada no produce un 403: sigue el flujo. La autorización real, entonces, la aporta la pertenencia a equipo, si es que hay un claim de equipo configurado.

El identificador de usuario que llega a Langfuse no es el de Keycloak. El campo user_id de la traza se rellena con user_api_key_end_user_id (integrations/langfuse/langfuse.py:604), es decir con el campo user del cuerpo OpenAI, que lo pone el cliente. El sujeto del token y el dueño de la clave no viajan. Quien quiera trazabilidad de persona necesita configurar end_user_id_jwt_field o hacer que el cliente mande ese campo.

Langfuse no aprovisiona nada desde un claim. No existe variable que mapee un claim de OIDC a organización ni a proyecto. Lo único es asignación estática al alta con LANGFUSE_DEFAULT_ORG_ID y LANGFUSE_DEFAULT_PROJECT_ID, igual para todos. Además, el control de acceso por proyecto está detrás de la licencia Enterprise en la edición self-hosted, igual que los registros de auditoría y la retención por proyecto.

Estás aquí: entre las cuatro piezas

El par LiteLLM y Langfuse recorrió la costura entre el gateway y el backend de trazas: rutas de integración, coste que llega a cero, correlación y colas con descarte. Este artículo amplía el foco a las otras tres vecindades, con la identidad en el centro, porque es la que ordena todo lo demás y la que pide un auditor.

La pieza de identidad en sí, es decir Keycloak, la trata el artículo siguiente. Aquí se trata el lado del gateway.

La analogía: la obra con cinco gremios

En una obra pequeña, el problema no suele estar dentro del trabajo de cada gremio, sino en los encuentros: donde el fontanero deja el pasatubos y el albañil cierra el tabique, donde el electricista quiere pasar por el mismo hueco. Cada gremio hace bien su parte y el encuentro queda mal resuelto porque no es de nadie.

Una plataforma de inferencia tiene cinco gremios. El gateway sabe de tokens, el proveedor de identidad sabe de personas, el backend de trazas sabe de eventos, la base de datos sabe de gasto y la caché sabe de contadores. Cada uno tiene documentación buena. Los encuentros no tienen documentación de nadie, y ahí es donde se pierde la identidad del usuario entre el token y la traza.

Hay un detalle más de la analogía que sirve. En la obra, la parte que más problemas da no es la que se ve mal acabada, es la que se tapa. Aquí pasa igual: las cinco costuras de este artículo fallan en silencio.

Costura 1. Qué tumba y qué degrada

Conviene tener escrito qué pasa cuando cada vecino se cae, porque la intuición falla en casi todos los casos.

Vecino¿Arranca sin él?Si se cae
PostgresSí, solo con clave maestra503 por defecto; degradado con allow_requests_on_db_unavailable
RedisCortacircuitos tras 5 fallos; límites pasan a ser por pod
LangfuseSe descartan eventos al llenar la cola; sin impacto en latencia
Colector OTelIgual que el anterior
Proveedor de identidadCae el acceso a la interfaz y el flujo JWT; las claves virtuales siguen

Postgres

Sin DATABASE_URL, _setup_prisma_client devuelve None sin error (proxy_server.py:10375). El proxy sirve /chat/completions con la clave maestra. Cualquier otra clave recibe un 400. Tampoco hay presupuesto global, y el propio arranque lo avisa: Redis no sustituye a la base de datos (proxy_server.py:9240).

Con base de datos configurada y caída, el manejador de excepciones relanza salvo que esté activo el flag de degradación (db/exception_handler.py:56). Hay además un DISABLE_PRISMA_HEALTH_CHECK_ON_STARTUP y un vigilante que reconecta. El detalle que importa para el diagnóstico: un fallo transitorio de base de datos devuelve 503, no 401. Si en un incidente se ven 401, el problema es de credenciales; si se ven 503, es de base de datos o de admisión.

Redis

El cortacircuitos está activado por defecto, con umbral de cinco fallos, recuperación de sesenta segundos y duración mínima de cinco (constants.py:456, lógica en caching/redis_cache.py:153). El socket tiene un timeout de 0,1 segundos (constants.py:453). Con el circuito abierto, DualCache sirve desde memoria sin propagar excepción (dual_cache.py:215).

La consecuencia hay que medirla, no suponerla: con cuatro workers y tres réplicas, un límite de cien peticiones por minuto se convierte en mil doscientas. Es un modo de fallo que no genera errores y que puede durar días sin que nadie lo note, hasta que llega la factura o el motor se satura.

La alerta correcta no es sobre Redis, es sobre litellm_service_latency etiquetada por servicio (integrations/prometheus_services.py:119), que expone la latencia de Redis y de la base de datos por separado.

Los callbacks

Todo lo que va detrás, es decir Langfuse y el colector, se lanza fuera del camino de respuesta y sus fallos se capturan. La integración clásica usa el SDK v2 con su propio hilo de fondo y un intervalo de vaciado de un segundo, ajustable con LANGFUSE_FLUSH_INTERVAL (langfuse.py:198). Hay un tope de cincuenta clientes Langfuse instanciados (constants.py:554), porque cada cliente es un hilo.

La integración por OTLP usa el procesador de lotes del SDK de OpenTelemetry (opentelemetry.py:3060) con sus valores por defecto. En los dos casos, saturación significa descarte, y el descarte no genera error hacia el cliente.

Costura 2. Identidad: las tres puertas y lo que valida cada una

Todo pasa por un constructor único de autenticación (user_api_key_auth.py:1245), con este orden:

  1. Autenticación personalizada de la edición empresarial.
  2. Rutas públicas.
  3. OAuth2 opaco, si está activado, con comprobación de licencia.
  4. JWT, si general_settings.enable_jwt_auth está activo y el token tiene tres partes.
  5. Clave maestra, con comparación en tiempo constante.
  6. Clave virtual, con hash SHA-256 y búsqueda en la tabla de verificación.

Un JWT sirve para /chat/completions, no solo para la administración: las rutas permitidas por defecto para un equipo incluyen openai_routes (_types.py:4886), que contiene el endpoint de chat. La administración, en cambio, solo la alcanza un token con rol de administrador.

Lo primero que hay que saber: es de pago

La rama JWT lleva una comprobación explícita de licencia, con el mensaje literal de que la autenticación por JWT es una función exclusivamente empresarial (user_api_key_auth.py:1416). El SSO de la interfaz de administración es gratuito hasta cinco usuarios facturables y exige licencia por encima (ui_sso.py:976).

Esto condiciona la arquitectura de cualquier despliegue soberano que no quiera pagar licencia: la integración con Keycloak queda para la interfaz de administración y para el aprovisionamiento, mientras que el tráfico de inferencia se autentica con claves virtuales. Las claves virtuales no son peores, pero son otra cosa, y hay que decirlo en el análisis de riesgos: son credenciales desacopladas del proveedor de identidad, de modo que dar de baja a una persona en Keycloak no invalida su clave.

Los campos de litellm_jwtauth que deciden la seguridad

De la clase LiteLLM_JWTAuth (_types.py:4848 y siguientes), estos son los que importan, con sus valores por defecto:

CampoDefectoQué hace
enforce_rbacFalseSi es falso, un rol no resuelto no produce 403
enforce_scope_based_accessFalseSin esto, scope_mappings no se aplica
enforce_team_based_model_accessFalseAcceso a modelos por equipo
team_id_jwt_fieldNoneClaim del que sale el equipo
team_id_upsertFalseSi es falso, un equipo que no existe da 404
user_id_upsertFalseIgual con el usuario
public_key_ttl600Caché de las claves públicas
public_key_stale_ttl3600Margen extra si el proveedor está caído
admin_jwt_scopelitellm_proxy_adminÁmbito que otorga administración
user_allowed_email_domainNoneRestricción por dominio de correo

Los algoritmos aceptados son RS256/384/512, PS256/384/512, ES256/384/512 y EdDSA (handle_jwt.py:163). No hay ninguno simétrico, lo que elimina la clase de ataque por confusión de algoritmo. Es una buena decisión de diseño y conviene reconocerla.

La descarga de claves admite varias URL separadas por comas en JWT_PUBLIC_KEY_URL, resuelve el descubrimiento si la URL apunta a la configuración conocida de OpenID, reintenta tres veces con espera creciente y memoriza el fallo treinta segundos (handle_jwt.py:680, :751, :88). Si el proveedor está caído, sirve la copia caducada hasta la suma de los dos TTL, es decir hasta setenta minutos. Para disponibilidad está bien; para revocación no, y hay que escribirlo.

Un detalle de la rotación de claves: con más de una clave en el juego se exige coincidencia exacta del identificador de clave, pero con una sola clave y sin identificador se acepta sin comprobar (handle_jwt.py:913).

La validación que no ocurre

Esta es la parte que hay que corregir el primer día. En _build_decode_kwargs (handle_jwt.py:1003), la audiencia se toma de la variable de entorno JWT_AUDIENCE y el emisor de JWT_ISSUER. Si no están definidas, el código fija verify_aud=False y verify_iss=False, y emite un aviso una sola vez.

Lo que eso significa en un despliegue real con Keycloak: un token emitido para el cliente de Grafana, o para el de Backstage, firmado por el mismo realm, es aceptado por el gateway como credencial válida. La caducidad sí se valida siempre, con margen cero. La firma también. La audiencia no.

Hay una segunda vía, issuers, con configuración por emisor, que sí valida emisor y audiencia salvo que se desactive explícitamente (handle_jwt.py:1186). Es la que hay que usar cuando hay más de un proveedor.

La corrección mínima son dos líneas de entorno:

JWT_AUDIENCE=litellm-gateway
JWT_ISSUER=https://sso.ejemplo.es/realms/plataforma

Y del lado de Keycloak, un mapeador de audiencia en un client scope dedicado que inyecte esa audiencia. El detalle está en el artículo de Keycloak, porque el estándar que resolvería esto de forma limpia, los indicadores de recurso, no está soportado.

Del claim al permiso

El rol de administrador sale de que admin_jwt_scope aparezca en el claim scope, y devuelve resultado sin pasar por equipo (handle_jwt.py:309, :1373). El resto se resuelve por equipo.

Si el equipo del claim no existe en la base de datos, con team_id_upsert en falso se devuelve un 404 con el mensaje de que hay que crear el equipo (auth_checks.py:3035). Con el flag activo, se crea invocando el alta de equipo con una identidad sintética de administrador (auth_checks.py:2874). Esa es la única vía de aprovisionamiento automático que existe de Keycloak hacia el gateway, y funciona bien. Conviene saber que crea equipos sin presupuesto ni límites, de modo que hace falta un valor por defecto en la configuración.

La vulnerabilidad que conviene conocer

CVE-2026-35030, con puntuación 9,1 en CVSS 3.1 y 9,4 en CVSS 4.0, aviso GHSA-jjhc-v7c2-5hh6. Con la autenticación por JWT activa, la caché del endpoint de información de usuario de OIDC usaba los veinte primeros caracteres del token como clave, de modo que dos tokens con el mismo prefijo se confundían. Corregida en 1.83.0.

En 1.102.0 el código usa el hash SHA-256 completo (handle_jwt.py:962). Una búsqueda de patrones parecidos en el árbol no encuentra ninguna caché de autenticación indexada por prefijo de token: los recortes que quedan son enmascarados para el registro. Y merece recordarse lo ya publicado sobre el gateway MCP: por debajo de 1.83.14 hay ejecución remota o inyección SQL sin autenticar.

Costura 3. La identidad no llega a la traza

Aquí está el fallo de diseño más caro de la figura, porque se descubre en la primera auditoría.

El campo user_id de una traza de Langfuse se rellena con user_api_key_end_user_id (integrations/langfuse/langfuse.py:604 y :724), que proviene del campo user del cuerpo de la petición OpenAI o de end_user_id_jwt_field (litellm_pre_call_utils.py:1584). No es el sujeto del token ni el propietario de la clave virtual.

Las consecuencias en cadena:

  • Si el cliente no manda user, la traza no tiene usuario.
  • Si el cliente manda user, la traza tiene el valor que el cliente quiera poner, sin verificar.
  • Un agente que llama con una clave de servicio deja trazas sin persona detrás.

Hay tres formas de arreglarlo, en orden de robustez:

  1. Configurar end_user_id_jwt_field para que el usuario final salga de un claim del token y no del cuerpo. Es la única opción en la que el valor no lo controla el cliente, y exige autenticación por JWT, es decir licencia.
  2. Forzar el metadato trace_user_id, que sobrescribe el campo (langfuse.py:726; en la vía OTLP, langfuse_otel.py:96). Se puede fijar por equipo.
  3. Obligar por contrato a que el cliente mande user y validar en un guardrail. Funciona, pero es una declaración del cliente, no una prueba.

Hacia el motor pasa algo parecido. La identidad del llamante no viaja a vLLM salvo que se active litellm.add_user_information_to_llm_headers, cuyo valor por defecto es None (litellm/__init__.py:223). Con ella se inyectan cabeceras x-litellm-* con identificador de usuario, de equipo y hash de clave (litellm_pre_call_utils.py:1356). El reenvío de cabeceras del cliente exige general_settings.forward_client_headers_to_llm_api, y la cabecera authorization se filtra siempre, lo cual es correcto.

Hacia los servidores MCP, por defecto se usa la credencial propia del servidor, no el token del usuario. Solo dos modos reenvían el token del llamante, y hacerlo tiene un nombre en la especificación de MCP: paso de token, que está explícitamente prohibido. Vuelve a salir en el artículo de Keycloak.

Costura 4. Langfuse como vecino

Lo que se puede automatizar y lo que no

La lista exacta de variables para SSO en Langfuse 4.x, tomada de web/src/env.mjs:

Con proveedor Keycloak: AUTH_KEYCLOAK_CLIENT_ID, AUTH_KEYCLOAK_CLIENT_SECRET, AUTH_KEYCLOAK_ISSUER, AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING, AUTH_KEYCLOAK_CLIENT_AUTH_METHOD, AUTH_KEYCLOAK_CHECKS, AUTH_KEYCLOAK_ID_TOKEN_SIGNED_RESPONSE_ALG, AUTH_KEYCLOAK_SCOPE, AUTH_KEYCLOAK_ID_TOKEN, AUTH_KEYCLOAK_NAME.

Con OIDC genérico: el mismo juego con prefijo AUTH_CUSTOM_, más AUTH_CUSTOM_FETCH_USERINFO y el mapeo de claims con LANGFUSE_CUSTOM_SSO_SUB_CLAIM, LANGFUSE_CUSTOM_SSO_EMAIL_CLAIM, LANGFUSE_CUSTOM_SSO_NAME_CLAIM y LANGFUSE_CUSTOM_SSO_IMAGE_CLAIM.

Globales: AUTH_DISABLE_USERNAME_PASSWORD, AUTH_DISABLE_SIGNUP, AUTH_DOMAINS_WITH_SSO_ENFORCEMENT, AUTH_SESSION_MAX_AGE.

Y ahora lo que no hay. No existe ninguna variable que mapee un claim a organización o proyecto. Lo único es asignación estática al alta con LANGFUSE_DEFAULT_ORG_ID, LANGFUSE_DEFAULT_ORG_ROLE con valor por defecto VIEWER, LANGFUSE_DEFAULT_PROJECT_ID y LANGFUSE_DEFAULT_PROJECT_ROLE (features/auth/lib/createProjectMembershipsOnSignup.ts:61). La propia documentación lo confirma: el SSO empresarial no aprovisiona roles automáticamente en el alta.

Además, en la tabla de permisos por edición (features/entitlements/constants/entitlements.ts:139), las ediciones abierta y profesional self-hosted no incluyen el control de acceso por proyecto ni la API de administración. Son de la edición empresarial, igual que los registros de auditoría y la retención por proyecto. En la edición abierta, todo usuario hereda el rol de su organización.

El arranque declarativo, que sí existe

Para on-premise sin licencia, la vía practicable es el bootstrap por variables (web/src/initialize.ts), que es idempotente: LANGFUSE_INIT_ORG_ID (obligatorio, si falta se ignora el resto con un aviso), LANGFUSE_INIT_ORG_NAME, LANGFUSE_INIT_PROJECT_ID, LANGFUSE_INIT_PROJECT_NAME, LANGFUSE_INIT_PROJECT_RETENTION, LANGFUSE_INIT_PROJECT_PUBLIC_KEY, LANGFUSE_INIT_PROJECT_SECRET_KEY, LANGFUSE_INIT_USER_EMAIL, LANGFUSE_INIT_USER_NAME, LANGFUSE_INIT_USER_PASSWORD.

Con eso, un proyecto por inquilino se crea desde GitOps con un contenedor de inicialización por inquilino y las claves en secretos de Kubernetes.

Credenciales de Langfuse por equipo

Esta parte sí está bien resuelta del lado del gateway y poca gente la usa. TeamCallbackMetadata.callback_vars (_types.py:2152) admite langfuse_public_key, langfuse_secret_key, langfuse_host y langfuse_environment, con lista blanca en initialize_dynamic_callback_params.py:67. El gateway cachea un registrador por juego de credenciales (langfuse_handler.py:24) y, en la vía OTLP, construye un exportador completo por clave (langfuse_otel.py:400).

Es decir: un proyecto de Langfuse por equipo de LiteLLM es posible hoy, sin licencia, y es la forma correcta de aislar trazas entre inquilinos. Lo que no existe es la creación automática del proyecto, ni la correspondencia entre el equipo y el proyecto. Eso es pegamento que hay que escribir.

La ruta de ingesta

Langfuse acepta únicamente OTLP sobre HTTP, en http/protobuf o http/json. gRPC no está soportado. La ruta base es /api/public/otel y la señal vive en /api/public/otel/v1/traces, verificado en el repositorio. La autenticación es Basic con el par de claves del proyecto, más la cabecera x-langfuse-ingestion-version: 4 para la ruta de la versión 4.

El gateway lo construye exactamente así (langfuse_otel.py:328, :347, :359) y luego normaliza el endpoint añadiendo el sufijo de la señal (opentelemetry.py:3237). El resultado coincide con la ruta real, así que la integración funciona sin tocar nada. El detalle importa cuando se mete un colector en medio y alguien copia el endpoint a mano.

Y el aviso de calendario que ya salió en el par operativo sigue en pie: el callback clásico está atado al SDK v2 y la fecha marcada es el 16 de noviembre de 2026. Con un matiz que conviene leer antes de programar una migración de urgencia, y que se detalla en el artículo del modelo de datos: esa fecha rige para Langfuse Cloud, y en el código de la versión self-hosted la ruta antigua no se apaga, cambia de comportamiento según el modo de escritura.

Cuándo meter un colector

Directo si Langfuse es el único destino de trazas. Vía colector si hay más de un consumidor, o si hace falta alguna de estas tres cosas que el procesador de lotes del proxy no da:

  • Reintentos con espera creciente y cola en disco, con los procesadores sending_queue y file_storage. Con eso, una ventana de mantenimiento de Langfuse deja de perder trazas.
  • Muestreo de cola, que permite quedarse con el 100 % de los errores y el 1 % del resto. El proxy no puede muestrear por resultado, porque cuando decide ya no sabe cómo acabó.
  • Redacción de atributos antes de que salgan del espacio de nombres. Es la única capa donde se pueden borrar los argumentos de herramienta MCP que, como ya se documentó, se escriben en claro saltándose el interruptor de redacción de mensajes.

Costura 5. Postgres, Redis y la política de memoria

Postgres separado, siempre. Cada pieza aplica sus propias migraciones de Prisma sobre el esquema público de la base que se le indique. Dos clientes de Prisma migrando el mismo esquema colisionan por nombre de tabla y por historial de migraciones. Además Langfuse 4.x exige Postgres 15 como mínimo, recomienda 16 y quiere UTC por defecto. El perfil de carga también es opuesto: el gateway escribe registros de gasto a alta frecuencia, mientras que Langfuse hace transaccional ligero porque los datos de traza van a ClickHouse.

Redis separado, y el argumento no es el que parece. No hay colisión de claves por diseño: el gateway usa etiquetas de hash con la forma {api_key:...}:requests y un espacio de nombres opcional, y Langfuse usa el prefijo de BullMQ más un prefijo propio para su caché de claves. El argumento decisivo es otro: Langfuse exige maxmemory-policy=noeviction porque sus colas son datos, no caché, mientras que el gateway asume caché desechable. Compartir instancia significa que un pico de claves de límite de tasa puede llenar la memoria y provocar errores de escritura en las colas de ingesta en lugar de un desalojo inofensivo.

Si aun así se comparte, dos medidas mínimas: REDIS_KEY_PREFIX en Langfuse, namespace en el gateway, y bases lógicas distintas.

Y un recordatorio de superficie: Langfuse 4.x no arranca sin ClickHouse ni sin bucket compatible con S3. CLICKHOUSE_URL y LANGFUSE_S3_EVENT_UPLOAD_BUCKET son obligatorios y sin valor por defecto (packages/shared/src/env.ts:120 y :271). Su superficie de fallo es mucho mayor que la del gateway, y eso cambia dónde poner el esfuerzo de alta disponibilidad.

Puertos, para la NetworkPolicy

PiezaPuerto
LiteLLM (API, interfaz y métricas)4000
Langfuse web3000
Langfuse worker3030
ClickHouse8123 HTTP, 9000 nativo
Almacenamiento S3 compatible9000
Redis6379
Postgres5432
Colector OTel4317 gRPC, 4318 HTTP

Los pares que hay que permitir con una política de denegación por defecto, siguiendo el método de hardening del stack:

litellm        → postgres:5432, redis:6379
litellm        → collector:4318   (o langfuse-web:3000 si va directo)
litellm        → vllm:8000
litellm        → keycloak:8443    (solo interfaz y flujo JWT)
collector      → langfuse-web:3000
langfuse-web   → postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-worker→ postgres:5432, redis:6379, clickhouse:8123, s3:9000
langfuse-web   → keycloak:8443
ingress        → litellm:4000, langfuse-web:3000
prometheus     → litellm:4000

El worker de Langfuse no necesita tráfico de entrada más allá de la sonda de salud.

El pegamento que hay que escribir

Puesto en una tabla, el estado real de la cadena de inquilinos:

Tramo¿Automático?Cómo
Keycloak → equipo de LiteLLMteam_ids_jwt_field más team_id_upsert: true
Keycloak → usuario de LiteLLMuser_id_jwt_field más user_id_upsert: true
Keycloak → organización de LangfuseNoSolo asignación estática al alta
Keycloak → proyecto de LangfuseNoIgual
Equipo de LiteLLM → proyecto de LangfuseNocallback_vars a mano, o un operador propio
Usuario de Keycloak → user_id de la trazaNoend_user_id_jwt_field, o contrato con el cliente

La arquitectura mínima viable sin licencia, entonces, es esta:

  1. Un proyecto de Langfuse por inquilino, creado con las variables de inicialización desde GitOps.
  2. Las claves de ese proyecto en un secreto de Kubernetes.
  3. Un trabajo periódico propio que lea los equipos por la API de administración del gateway y escriba callback_vars con las claves correspondientes.
  4. Las personas entrando a Langfuse por Keycloak, pero asignadas a mano a su organización.
  5. El identificador de usuario final resuelto por contrato con los clientes, y validado.

Son unas ciento cincuenta líneas de código propio. Conviene presupuestarlas desde el principio, porque el hueco no se cierra solo.

Configuración de referencia

general_settings:
  # solo con licencia; sin ella, claves virtuales
  enable_jwt_auth: true
  litellm_jwtauth:
    team_ids_jwt_field: "groups"
    team_id_upsert: true
    user_id_jwt_field: "sub"
    user_id_upsert: true
    end_user_id_jwt_field: "preferred_username"
    user_allowed_email_domain: "ejemplo.es"
    admin_jwt_scope: "litellm_proxy_admin"
    # los tres que vienen apagados de fábrica
    enforce_rbac: true
    enforce_scope_based_access: true
    enforce_team_based_model_access: true
  allow_requests_on_db_unavailable: false
  proxy_batch_write_at: 60

litellm_settings:
  callbacks: ["langfuse_otel", "prometheus"]
  # sin esto, el motor no sabe quién llama
  add_user_information_to_llm_headers: true
  turn_off_message_logging: true
  cache: true
  cache_params:
    type: redis
    host: os.environ/REDIS_HOST
    namespace: litellm
# las dos que activan la validación que por defecto no ocurre
JWT_AUDIENCE=litellm-gateway
JWT_ISSUER=https://sso.ejemplo.es/realms/plataforma
JWT_PUBLIC_KEY_URL=https://sso.ejemplo.es/realms/plataforma/.well-known/openid-configuration

LANGFUSE_HOST=http://langfuse-web.observabilidad.svc:3000
LANGFUSE_PUBLIC_KEY=...
LANGFUSE_SECRET_KEY=...

Y del lado de Langfuse:

AUTH_KEYCLOAK_CLIENT_ID=langfuse
AUTH_KEYCLOAK_ISSUER=https://sso.ejemplo.es/realms/plataforma
AUTH_KEYCLOAK_ALLOW_ACCOUNT_LINKING=true
AUTH_DISABLE_USERNAME_PASSWORD=true
AUTH_DISABLE_SIGNUP=true
LANGFUSE_DEFAULT_ORG_ID=plataforma
LANGFUSE_DEFAULT_ORG_ROLE=VIEWER
ENCRYPTION_KEY=...   # 64 caracteres hexadecimales

Checklist

  1. Definir JWT_AUDIENCE y JWT_ISSUER el primer día, o usar la configuración por emisor. Sin eso no se valida ni audiencia ni emisor.
  2. Poner enforce_rbac, enforce_scope_based_access y enforce_team_based_model_access en cierto, y probar que un token de otro cliente del mismo realm es rechazado.
  3. Decidir de dónde sale el usuario final de las trazas y escribirlo en el diseño. Si no se decide, las trazas salen sin persona.
  4. Separar Postgres. Separar Redis. Si se comparte Redis, prefijo y base lógica distintos, y revisar la política de memoria.
  5. Comprobar que el proxy arranca con Postgres caído solo si eso es lo que se quiere, y que el equipo de guardia sabe distinguir un 503 de admisión de un 401 de credencial.
  6. Alertar sobre litellm_service_latency por servicio, no sobre la disponibilidad de Redis, porque el cortacircuitos oculta el problema.
  7. Un proyecto de Langfuse por inquilino con las variables de inicialización, y las claves por equipo en callback_vars.
  8. Meter un colector en medio si hace falta cola en disco, muestreo de cola o redacción de atributos.
  9. Revisar que la versión del gateway está por encima de 1.83.14 por las vulnerabilidades críticas anteriores.
  10. Escribir en el análisis de riesgos que las claves virtuales son credenciales desacopladas del proveedor de identidad, y definir el procedimiento de baja.

Trampas y cosas que no son lo que parecen

Un JWT bien firmado no es un JWT dirigido a este servicio. Sin las dos variables de entorno, la audiencia no se comprueba.

enforce_rbac en falso no significa que no haya autorización, significa que la autorización la aporta solo la pertenencia a equipo, si hay claim de equipo. Sin claim de equipo y sin el flag, un token válido llega a /chat/completions.

La copia caducada de las claves públicas dura hasta setenta minutos, sumando los dos TTL. Revocar una clave en el proveedor no surte efecto inmediato.

Con una sola clave en el JWKS y sin identificador de clave, no se comprueba el identificador.

El usuario de la traza lo pone el cliente. Cualquier informe de gasto por persona construido sobre ese campo es una declaración, no una medida.

La caída de Redis no genera errores, genera límites por pod. Es el fallo más caro de los que no avisan.

Langfuse no arranca sin ClickHouse ni sin bucket S3. Su superficie de fallo es mayor que la del gateway, y eso cambia el reparto del esfuerzo de alta disponibilidad.

El control de acceso por proyecto de Langfuse es de pago en self-hosted, igual que los registros de auditoría. Para un expediente de ENS, eso se decide antes de montar, no después.

Compartir Redis no rompe por colisión de claves, rompe por política de memoria. Es un fallo que aparece bajo carga y que se diagnostica mal.

La ruta de ingesta de Langfuse es solo OTLP sobre HTTP. Un colector configurado con el exportador gRPC apunta a un sitio que no existe.

Cierre

Las cinco costuras tienen un patrón común: fallan sin avisar. El token de otro servicio entra, el usuario de la traza viene del cliente, el límite de tasa deja de ser global, la cola de gasto descarta al llegar a 64 MB y el proyecto de Langfuse no se crea solo. Ninguna de esas cinco cosas produce un error visible.

El trabajo del día 2, entonces, consiste sobre todo en convertir silencios en señales. Dos variables de entorno para que la audiencia se valide, tres flags para que la autorización sea real, una alerta sobre la latencia de servicio en lugar de sobre la disponibilidad de Redis, y un contrato explícito sobre de dónde sale la identidad del usuario final.

Y una decisión que conviene tomar pronto, antes de que el presupuesto esté cerrado. La autenticación por JWT del gateway y el control de acceso por proyecto de Langfuse están los dos detrás de licencia. Una plataforma soberana puede vivir perfectamente sin ambas, con claves virtuales y un proyecto por inquilino, pero es una arquitectura distinta y hay que dibujarla como tal desde el principio.

Ver también

Fuentes