LLM corporativa sin filtrar datos: LiteLLM, Presidio y Keycloak en un gateway Zero Trust
Usar el modelo de frontera sobre datos de la empresa, sin que el dato cruce el borde. Primera de dos entregas.

El riesgo no entró por la puerta que estábamos mirando.
Mientras discutíamos si podíamos usar IA, alguien del equipo ya había pegado la ficha de un cliente en el ChatGPT de su celular. No por malicia: porque el asistente corporativo le decía "no tengo ese dato" y el personal le contestaba bien.
Ese es el verdadero modo de falla, y no aparece en ninguna matriz de riesgos. El control que no contempla el caso de uso no elimina el riesgo: lo muda a donde no lo ves. La política decía que los datos sensibles no salen de la empresa. Salían igual, uno por uno, por WhatsApp Web.
La respuesta de manual —"lo sensible se queda on-prem, para lo demás usá la nube"— es correcta, es defendible en una auditoría, y es la que produjo esa escena. Así que en vez de preguntarnos cómo impedirlo, nos preguntamos otra cosa:
¿Cómo le damos el modelo bueno sobre nuestros datos, sin que el dato cruce el borde?
Se puede. El proveedor ve esto:
¿Cuál es el saldo del cliente <<PERSONA_1>> y en qué segmento está?
El usuario ve esto:
El saldo del cliente María García es de 152.340,75 y está en el segmento premium.
Mismo razonamiento del modelo, mismo resultado útil, y el nombre nunca salió de la empresa.
Esta es la primera de dos entregas. Acá va la arquitectura y cómo se construye. En la segunda la atacamos y contamos qué se rompió.
Todo el código está acá, con un paso a paso para levantarlo: safernandez666/zt-llm-gateway
La arquitectura

Todo lo del diagrama corre en tu infraestructura salvo una caja: la API de ChatGPT. Los puertos son los del compose, así que lo que ves es lo que levanta.
Los usuarios entran por Open WebUI, que es lo que ya usan y no queremos tocar. Detrás hay un orquestador propio que hace el ruteo y arma el contexto, y todas las llamadas a modelos pasan por LiteLLM, que es el único componente con credenciales de proveedor.
Todo open source:
| Pieza | Qué resuelve |
|---|---|
| Open WebUI | el front, el que los usuarios ya conocen |
| Keycloak | identidad: el rol sale de un JWT firmado, no de un header |
| LangChain | la cadena del orquestador y el retriever sobre el RAG |
| Presidio + spaCy | PII: patrón y checksum para lo universal, NER en castellano para texto libre |
| ChromaDB | el vector store, alimentado por el ingestor |
| Cross-encoder multilingüe | reranking: decide qué chunks entran al contexto |
| LiteLLM | el gateway: virtual key por rol, budget, rate limit, guardrails |
| Ollama | el modelo on-prem, para lo que no sale ni tokenizado |
| Langfuse | qué contexto se usó, qué costó, quién preguntó |
| MySQL + PostgreSQL | datos de negocio, y el estado del gateway |
Las fuentes de contexto
Tres, y cada una necesita un tratamiento distinto:
| Fuente | Qué trae | Cómo se protege |
|---|---|---|
| Bases relacionales vía API interna | fichas de clientes, saldos, estados | ABAC + field masking en el borde del dato |
| RAG sobre documentos corporativos | políticas, procedimientos, documentación | escaneo al ingestar y al recuperar |
| APIs de terceros / SaaS | CRM, ERP, ticketera | mismo patrón que la API interna |
La última todavía no está conectada en la POC — está en el diagrama porque es parte de la arquitectura, no porque ya funcione. Lo aclaro para que nadie copie el diagrama creyendo que lo probamos.
La ingesta documental
El RAG no se alimenta de un array de strings: toma PDF, DOCX y Markdown de una carpeta montada.
archivo tipo chunks alertas
----------------------------------------------------------------
onboarding.md markdown 4
politica-gastos.pdf pdf 4
politica-vacaciones.docx docx 3
politica-viajes.pdf pdf 5 ⚠ 1 con alerta
----------------------------------------------------------------
4 archivos · 16 chunks indexados · 1 con alerta
Tres decisiones que importan más de lo que parecen:
Cada chunk lleva su procedencia. Archivo, sección y hash del documento del que salió. Una respuesta que cita algo y no puede decir de dónde vino no sirve en una empresa: no se puede auditar ni corregir en el origen.
El tamaño del chunk no es un detalle de tuning. La primera versión metía cada PDF entero en un chunk. El embedding de un documento completo es el promedio de todo lo que dice, y entonces no matchea ninguna pregunta concreta: la consulta por viáticos devolvió la política de vacaciones. Ahora son 600 caracteres con solapamiento, cortando en párrafos o en frases, nunca a mitad de oración.
El encabezado va adentro del texto embebido. El chunk que respondía la pregunta decía USD 80 por día en viajes nacionales, USD 120 en el exterior — y no contenía la palabra "viáticos". Indexarlo como [Viáticos] USD 80 por día... lo arregló.
Recuperación en dos etapas
El RAG recupera en dos pasos, y los dos importan.
El embedding tiene que hablar el idioma del corpus. El default de ChromaDB es all-MiniLM-L6-v2, entrenado en inglés; sobre documentos en castellano las distancias quedan casi indiferenciadas. Acá usamos paraphrase-multilingual-MiniLM-L12-v2, servido desde el servicio de guardrails para que la indexación y la consulta usen exactamente el mismo modelo.
Si tu corpus no está en inglés, revisá qué embedding usa tu vector DB por defecto. Es el tipo de problema que no se nota: con un corpus chico el LLM responde razonable con casi cualquier contexto, así que un RAG mal configurado parece un RAG que anda — hasta que la base crece y empieza a citar el documento equivocado con total seguridad. Y el modelo tiene que ser el mismo al indexar y al consultar: si no, las distancias no significan nada.
Y el embedding solo no alcanza. Compara la pregunta y el documento por separado: es rápido y tosco, sirve para traer candidatos. Un cross-encoder los lee juntos y devuelve relevancia real — más caro, pero sobre 10 candidatos y no sobre todo el índice.
embedding (top 3) reranker
politica-viajes § Autorización → 0.0415 onboarding.md § Datos sensibles
politica-viajes § Aéreos (9 descartados de 10)
politica-gastos § Rev. 7
Para "¿qué dice el onboarding sobre datos productivos?", el embedding deja el chunk correcto en cuarto lugar y el reranker lo pone primero. De cinco chunks siempre, a uno o dos precisos.
En un stack de seguridad eso no es sólo calidad de respuesta: cada chunk irrelevante que entra al contexto es superficie de ataque. Es exactamente por donde llega un documento envenenado que no tiene nada que ver con la pregunta.
El umbral del reranker va relativo, no absoluto. El cross-encoder devuelve logits —pueden ser negativos— y aun con sigmoide los puntajes se mueven muchísimo según la pregunta: 0.44 para una consulta y 0.04 para otra, con las dos acertando. Un corte fijo deja consultas enteras sin contexto. Lo que importa es cuánto se despega el primero del resto.
Y se escanea al ingestar, no sólo al recuperar. Atajar un documento envenenado cuando lo indexás es más barato que hacerlo en cada consulta. Pero no lo reemplaza: un documento puede envenenarse después de ingestado, y el índice no se entera. Hacen falta los dos.
El ruteo, con el stack corriendo
El Router clasifica por palabras clave — determinístico, auditable, y sin un LLM en el medio que sea un punto de inyección más. Seis consultas reales, misma identidad:
| Consulta | Ruta | Fuentes usadas |
|---|---|---|
| ¿Cuál es el tope de viáticos en el exterior? | documental |
politica-viajes.pdf § Viáticos (+4) |
| ¿Cuántos días de vacaciones me corresponden con 6 años? | documental |
politica-vacaciones.docx § Días correspondientes (+4) |
| ¿Cuál es el saldo del cliente García? | negocio |
business-api (abac=true, rol=finanzas) |
| Mostrame los datos de contacto del cliente Fernández | negocio |
business-api (abac=true, rol=finanzas) |
| Explicame qué es Zero Trust en dos frases | general |
— |
| ¿Qué dice el onboarding sobre datos productivos? | documental |
onboarding.md § Datos sensibles (+4) |
Lo que cambia es la pregunta, no quién pregunta. Y la ruta decide dos cosas a la vez: a qué datos llega la consulta, y a qué modelo puede ir ese contexto.
Cómo se ve para el usuario

Arriba, una consulta normal: la respuesta trae el nombre real, y el pie dice por qué ruta fue, a qué modelo, y cuántas entidades se tokenizaron. Abajo, un intento de inyección cortado antes de llegar al modelo.
Ese pie no es decoración: es la diferencia entre un control invisible y uno que el equipo entiende. Si nadie ve que el gateway está haciendo algo, el día que bloquea algo legítimo parece un bug.
Una aclaración de la POC: la API key que usa Open WebUI mapea a un usuario del realm, y el orquestador le pide a Keycloak un token real para ese usuario. La cadena de identidad queda entera, pero el atajo —qué usuario representa esa key— confía en el front. La versión de producción es OIDC de punta a punta: Open WebUI autentica contra Keycloak y el token del usuario viaja hasta el orquestador.
El contrato de la pseudonimización

Tres reglas. Las tres importan, y las tres se rompen fácil si no se escriben.
1. Si va a la nube, va tokenizado. Siempre.
No es un flag por request. No es una opción de la policy. Es estructural: la única manera de llegar a un modelo externo es pasando por el pseudonimizador.
# Si el destino es la nube, el prompt sale tokenizado. Siempre. No hay
# flag que lo apague, porque un control que se puede apagar por request
# termina apagado el día que alguien tiene apuro.
if SECURE and destino == "cloud":
ps = await pseudonimizar(prompt, session_id)
prompt_enviado = ps["text"]
Un control opcional es un control que no existe. Tarde o temprano hay un ticket urgente, alguien pasa pseudonimizar: false, y nadie lo revisa de nuevo.
2. PAN y CBU no son tokenizables
El resto de las entidades sale como token reversible y vuelve restituida. El número de tarjeta y el CBU, no: se reemplazan por una marca irreversible que nunca se recupera.
tarjeta 4111111111111111 → <<DATO_PCI_REMOVIDO>>
El motivo no es técnico, es de alcance. Mandar un PAN tokenizado igual mete al proveedor dentro de tu entorno de datos de tarjeta, con todo lo que eso arrastra en PCI-DSS. Y no hay caso de uso que lo justifique: ninguna pregunta útil necesita el número completo.
Además, cuando el dato viene de la API de negocio, el ABAC ya lo enmascaró antes de que llegara acá. Son dos redes distintas, no una redundante.
3. El mapa vive poco y no sale
El diccionario token→valor queda en un vault con TTL, y se borra cuando termina la consulta:
# El mapa muere con la consulta. No hay razón para conservarlo.
await client.delete(f"{GUARDRAILS_URL}/vault/{session_id}")
En la POC es memoria del proceso. En producción va a Redis con TTL y cifrado en reposo — pero la propiedad que importa es la misma: ese mapa es el único lugar donde conviven el token y el dato real, y no viaja a ningún lado.
PII por esquema, NER sólo para lo que no tiene esquema
La detección de PII va en dos carriles, y el orden importa.
Lo que viene de una base se declara, no se adivina. La columna nombre de una tabla ES un nombre: no hace falta que un modelo probabilístico lo deduzca. El orquestador mapea campo a tipo de entidad y se lo pasa al pseudonimizador como dato declarado:
CAMPOS_PII = {
"nombre": "PERSONA",
"email": "EMAIL_ADDRESS",
"telefono": "PHONE_NUMBER",
"dni": "DNI_AR",
"cuit": "CUIT_AR",
"tarjeta": "CREDIT_CARD",
"cbu": "CBU_AR",
}
Determinístico, auditable, y no depende de que nadie acierte.
El NER queda para texto libre, donde no hay esquema que consultar: la pregunta del usuario, el chunk de un documento. Ahí es best-effort por definición.
Un NER es un modelo, y se equivoca en las dos direcciones. Puede no marcar un nombre real, y puede marcar como persona un término técnico — tenemos una lista corta de términos que nunca son PII justamente por eso. Esa lista es un parche y tiene su propio agujero: si un cliente se llama como un término de la lista, su nombre viaja en claro. Por eso lo estructurado nunca pasa por el NER.
Un framework en el medio: dónde queda el control
El orquestador usa LangChain. La recuperación es un retriever sobre ChromaDB y la generación es una cadena LCEL. Y la migración dejó un aprendizaje que no esperaba.
Cuando el prompt se arma a mano, tokenizarlo es una línea más antes de la llamada. Con un framework que arma el prompt por vos, el control tiene que meterse entre el armado y el modelo:
cadena = (RunnableLambda(armar) # system + contexto + pregunta
| RunnableLambda(pseudonimizar) # <-- el control, acá
| llm # ChatOpenAI -> LiteLLM
| StrOutputParser()
| RunnableLambda(rehidratar))
El lugar importa. Si el guardrail queda arriba del framework —validando lo que escribió el usuario— no ve el contexto que el framework inyectó después, que es exactamente donde está el dato sensible: la ficha del cliente, el chunk del documento. Validás la pregunta y dejás pasar la base.
Y es un eslabón de la cadena, no un callback, a propósito. En una cadena se ve dónde está el control con sólo leerla. Un callback se registra en otro archivo, se puede desactivar con una variable de entorno, y el diagrama no cambia.
Dos detalles más que el framework no resuelve solo:
El embedding hay que pasárselo explícitamente. LangChain trae uno por defecto; aceptarlo significa consultar con un modelo distinto del que indexó. No falla: devuelve resultados plausibles y equivocados.
Y migrar no cambió ningún resultado. Corrí la batería de ataques antes y después: 4 de 7 del lado inseguro, 7 de 7 contenidos del lado seguro, las dos veces. Es lo que uno quiere de una refactorización — y es la única manera de saber que el control sobrevivió al cambio de arquitectura.
Por qué el orden importa
Esta parte me llevó varias vueltas y es donde se cometen los errores.
Pseudonimizar va después de armar el contexto. Si tokenizás antes, tokenizás la pregunta del usuario y no lo que las fuentes devolvieron — que es justamente donde está el 90% del dato sensible.
Re-hidratar va antes del guardrail de salida. Parece al revés, pero: el guardrail decide qué puede ver cada rol. Si lo corrés sobre el texto tokenizado no encuentra nada, porque no hay nombres, hay tokens. Tiene que evaluar el texto real.
El mismo valor recibe el mismo token. Si "María García" aparece tres veces y recibe tres tokens distintos, el modelo cree que son tres personas y razona mal.
Tres cosas que sólo aparecen al probarlo
"García" no es "María García"
El usuario escribe el apellido. El sistema guarda el nombre completo. Si cada uno recibe su propio token, la pregunta y el contexto quedan desconectados y el modelo contesta "no tengo el dato del cliente" teniendo la ficha entera delante.
La solución fue que el vault relacione valores por palabra completa en cualquiera de los dos sentidos: si el valor nuevo está contenido en uno ya mapeado, o al revés, comparten token.
El guardrail tapaba lo que la re-hidratación acababa de restituir
Primera corrida funcionando, la respuesta salió así:
El saldo del cliente [REDACTED:PII] es de 152340.75 y está en el segmento premium.
El pipeline hizo todo bien: tokenizó, mandó, re-hidrató "María García"... y el guardrail de salida la tapó, porque PERSONA no estaba en las entidades visibles de ese rol.
El bug no era el código, era la política. El ABAC ya había decidido que ese rol puede consultar la ficha de ese cliente. Taparle además el nombre no protege nada: rompe la respuesta y entrena al equipo a desconfiar del control. Es exactamente el camino por el que un guardrail termina apagado.
Redacción anidada
Antes de eso apareció algo peor:
No tengo el dato del cliente <<[[REDACTED:PII]:PII]>>.
El detector de entidades estaba encontrando una persona adentro del token: veía PERSONA_4 y lo trataba como un nombre. Ahora los hallazgos que caen dentro de un token de pseudonimización se descartan antes de redactar.
Dos detalles que parecen menores y no lo son
Hay que explicarle los tokens al modelo. Si pseudonimizás y no se lo decís, el modelo ve <<EMAIL_ADDRESS_1>>, no lo reconoce como dato y responde "no tengo esa información". El control funcionó y el asistente quedó inútil. Hay que aclararle que un marcador representa un valor real y que lo use tal cual — con una excepción: <<DATO_PCI_REMOVIDO>> sí es información que no está.
Y hay que explicárselos sólo cuando existen. Agregar esa instrucción a una consulta sin tokens sesga al modelo: preguntado por Zero Trust después de leer sobre <<PERSONA_1>>, contestó que era una persona. Una instrucción sobre algo ausente no es neutral.
Qué resuelve Presidio y qué le toca a cada quien
Presidio es la respuesta estándar para PII y resuelve muy bien lo que es patrón más checksum: tarjeta validada con Luhn, mail, teléfono, IBAN. Eso funciona igual en cualquier idioma, porque un número de tarjeta es un número de tarjeta en todos lados.
Lo que no cubre es lo local. DNI, CUIT y CBU no los conoce ninguna librería internacional: esos reconocedores los pone uno, con regex y validación propia.
Y el reconocimiento de nombres lo sacamos de Presidio y lo hacemos con es_core_news_md, en el idioma del texto.
La regla general: lo que es patrón más checksum es universal; lo que es modelo tiene que estar en el idioma del corpus. Vale la pena mirar qué modelo trae por defecto la imagen que estás usando.
Cómo se verifica que funciona
Que el dato no cruce el borde es una promesa hasta que la medís. El orquestador expone el prompt exacto que salió al proveedor:
{
"pseudonimizado": true,
"prompt_enviado": "¿Cuál es el saldo del cliente <<PERSONA_1>> y en qué segmento está?",
"respuesta": "El saldo del cliente María García es de 152340.75, segmento premium."
}
Y sobre eso se asserta:
fuga de nombre real al proveedor: False
fuga de mail real al proveedor : False
Sin ese campo, "no mandamos datos sensibles" es una declaración. Con ese campo, es un test.
Y del otro lado, la traza de Langfuse muestra el payload tal como salió:

Ahí se ve lo que recibió OpenAI: <<PERSONA_1>>, <<EMAIL_ADDRESS_1>>, <<CUIT_AR_1>>, y la tarjeta y el CBU como <<DATO_PCI_REMOVIDO>> — esos dos no vuelven nunca. El saldo sí viaja en claro, porque un número sin nombre no identifica a nadie, y es justo lo que el modelo necesita para responder.
Esa pantalla es la que le mostrás a quien tenga que firmar el riesgo. No un diagrama: el payload.
Consumos: quién gastó qué

Cada llamada queda atribuida a un rol, con latencia, tokens y costo. Y ahí se ve sola la consecuencia del model routing:
zt-finanzasfue agpt-4o-miniy costó plata — centavos, pero costó.zt-soporteresolvió on-prem contra Ollama: $0.00.
Esa columna es la que convierte "usamos IA" en una línea de presupuesto. Y del lado del gateway, cada rol tiene su propia virtual key con su budget:

zt-soporte tiene un dólar; zt-finanzas y zt-auditoria, cinco. No es una métrica: es un control. Cuando un rol se pasa, el gateway devuelve 429 y deja de haber inferencia.
Una cicatriz de esta parte: el orquestador emite las keys al arrancar con un sufijo único, y durante días no borraba las anteriores. Cuando fui a sacar esta captura me encontré con 72 keys vivas, cada una con su presupuesto. Ninguna hacía daño sola; juntas son 72 credenciales que nadie recuerda haber emitido, y por lo tanto nadie va a revocar. Ahora el arranque limpia las de corridas previas antes de crear las nuevas.
Lo que sigue
El repo tiene el docker compose, el corpus de prueba, los scripts de ruteo y la batería de ataques: https://github.com/safernandez666/zt-llm-gateway
En la segunda entrega atacamos este mismo stack: inyección de prompt directa, inyección indirecta a través de un documento envenenado del RAG, exfiltración de datos vía la API de negocio, fuga de PII en la respuesta, y el escenario de la virtual key filtrada.
Spoiler del resultado: contra la versión sin controles, 4 de 7 ataques funcionaron. Con el gateway, los 7 quedaron contenidos y la consulta legítima siguió respondiendo igual.
About the author
I have a bachelor's degree in Technology from the University of Palermo, a Master in Information Security from the University of Murcia and different certifications such as CISSP | CISM | CDPSE | CCSK | CSX | MCSA | SMAC™️ | DSOE | DEPC | CSFPC | CSFPC | 5x AWS Certified.
He is currently CISO at Klar, a Mexican Fintech. He was fortunate to be awarded as CISO of the Year in Argentina in 2021 and was among the Top 100 CISO's in the World in 2022.
A lover of new technologies, he has developed a career in DevSecOps and Cloud Security at Eko Party, the largest security conference in Latin America.
Comments
Join the discussionNo comments yet. Be the first to comment.





