La vinculación que no estaba: control de acceso por grupos, y lo que el SSO no protege

- 1Lock down Docker networks
- 2Run containers as non-root
- 3Identity in front of every port
- 4Membership, not just an account
- 5An identity for the agent, not a key
- 6A tool server that checks who is asking
- 7Where the agent can go, not just what it can call
- 8Move the daemon off root
- 9Run the model's own code without trusting it
- 🏆A scope per tool, and a refusal clients can act on
La Parte 3 puso authentik delante de Langfuse y terminó con una confesión: el paso de vinculaciones quedó vacío, así que cualquier cuenta de authentik podía llegar a Langfuse y nada te avisaría.
Esta parte cierra ese hueco en la gateway LiteLLM — y el cierre salió mal de una forma que vale más que el procedimiento. Configuramos las vinculaciones en el asistente de authentik, las vimos aparecer en la tabla del propio asistente, enviamos, y acabamos con cero vinculaciones guardadas. La aplicación estaba abierta a todo el mundo, la interfaz no dijo nada, y la única razón por la que lo descubrimos es que probamos con una cuenta que debería haber sido rechazada y no lo fue.
Después, ya con el control de acceso funcionando de verdad, llamamos a la API del gateway desde un shell sin cuenta, sin sesión y sin grupo. Respondió con normalidad. Eso no es un fallo — es la diferencia entre un plano de control y un plano de datos, y suponer que el SSO cubre ambos es cómo la gente concluye que ha asegurado algo que no ha asegurado.
Una WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15.
authentik 2026.8.1, la misma instancia de la Parte 3 en el puerto host 9100. LiteLLM
main-stable de la Parte 1 de la serie del gateway,
enlazado a 127.0.0.1:4000, con modelos servidos por WEC Inference.
HTTP plano sobre una LAN privada, como en la Parte 3.
Qué hay de nuevo aquí
La Parte 3 enseñó el asistente — aplicación, proveedor, URI de redirección, scopes. Eso no se repite; está todo en la Parte 3 y los pasos son idénticos para cualquier app. Tres cosas son nuevas:
Un grupo, como la unidad a la que concedes acceso, de modo que añadir a alguien a un equipo y darle acceso sean la misma acción.
Una vinculación, que ata ese grupo a una aplicación, de modo que llegar a ella exija pertenencia en lugar de meramente existir.
Un límite, demostrado en vez de afirmado: qué protege el SSO, y qué no toca en absoluto.
Requisitos previos
- Un authentik funcionando, idealmente el de la Parte 3
- Un gateway LiteLLM en ejecución, de la Parte 1 de la serie del gateway
- Su
LITELLM_MASTER_KEY, que necesitarás para crear una clave virtual - Acceso SSH a la caja, porque el gateway está enlazado a
127.0.0.1
Dos puertas, solo una vigilada
Dos cajas, sin flecha entre ellas — ese es el punto. Todo lo que configura este post vive en la de la izquierda. La de la derecha nunca consulta a authentik.
También hay una tercera vía de entrada que el diagrama deja fuera a propósito, porque no pertenece a ninguno de los dos planos: la clave maestra de LiteLLM entra directamente a la interfaz de administración, saltándose por completo la comprobación de grupo. Nos topamos con ella en el Paso 5.
Paso 1 — Un grupo y dos cuentas
Directory → Groups → New Group, llámalo gateway-admins, y deja Superuser privileges
desactivado. Esa bandera convierte a los miembros en administradores del propio authentik y
no tiene nada que ver con qué aplicaciones pueden alcanzar.
authentik ya trae tres grupos — authentik Admins, authentik Agent-Users,
authentik Read-only — y authentik Admins ya contiene tu akadmin. Vincular ese
funcionaría hoy y significaría en silencio "cualquiera a quien alguna vez haga administrador
de authentik", que no es la misma intención.

Añade akadmin a gateway-admins. Luego Directory → Users → New User, tipo Internal,
usuario contractor, y sin ningún grupo.
Internal y no External para que la única diferencia entre las dos cuentas sea la pertenencia — cualquier otra diferencia enturbia el resultado. Tampoco Service Account: esas son de máquina a máquina y no hacen inicios de sesión interactivos.
authentik crea usuarios sin contraseña, y el diálogo de edición no tiene campo de contraseña — es una acción aparte en la página de detalle del usuario. Desde el shell:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import User
u = User.objects.get(username='contractor')
u.set_password('Contractor123!')
u.save()
print('password set for', u.username)
" 2>/dev/null | tail -2
ak shellCada llamada a ak shell imprime unas sesenta líneas de JSON de arranque antes de tu salida.
2>/dev/null | tail -N descarta el ruido y conserva las últimas N líneas. Se usa en todo lo
que sigue.
Paso 2 — Registrar el gateway, y vigilar el slug
Ejecuta el asistente de la Parte 3: aplicación LiteLLM, proveedor OAuth2/OpenID, flujo
de autorización default-provider-authorization-explicit-consent, tipo de cliente
Confidential.
El gateway está publicado en 127.0.0.1:4000, así que tu navegador no puede alcanzarlo — y
un inicio de sesión OIDC ocurre en un navegador. Abre un túnel desde tu equipo:
ssh -L 4000:127.0.0.1:4000 ubuntu@10.80.4.212
ssh -L avisa, y luego sigue sin el túnelSi algo ya ocupa el puerto local 4000, ssh imprime bind: Address already in use y aun así
te da un shell funcionando — con el reenvío muerto. El único síntoma es
ERR_CONNECTION_REFUSED en el navegador, que parece que el gateway estuviera caído.
Comprueba el puerto antes de culpar al gateway: lsof -nP -iTCP:4000 -sTCP:LISTEN.
Todo lo que el navegador escribe es ahora localhost:4000, así que esa es la URI de
redirección, modo Strict:
http://localhost:4000/sso/callback
Ahora la primera sorpresa. Pregúntale a authentik cómo llamó realmente a las cosas:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
for a in Application.objects.all():
print(repr(a.name), '->', repr(a.slug))
" 2>/dev/null | tail -5
'Langfuse' -> 'langfuse'
'LiteLLM' -> 'lite-llm'
lite-llm, no litellm. El generador de slugs leyó las mayúsculas de "LiteLLM" y las
separó. La Parte 3 dijo que el slug carga peso porque acaba en la URL del emisor, y aquí está:
http://10.80.4.212:9100/application/o/lite-llm/. Fija el slug explícitamente en lugar de
dejar que un nombre en CamelCase lo genere, o carga ese guion en cada URL para siempre.
Lee las credenciales con el slug real:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
p = Application.objects.get(slug='lite-llm').get_provider()
print('CLIENT_ID :', p.client_id)
print('CLIENT_SECRET:', p.client_secret)
" 2>/dev/null | tail -3
Paso 3 — Las vinculaciones que el asistente tiró
El paso 4 del asistente es Configure Bindings, y la Parte 3 pasó de largo mostrando
No bound policies. Esta vez no pasamos de largo. Vinculamos el grupo ahí, el asistente lo
listó en su tabla, y enviamos.


Luego le preguntamos a la base de datos qué existía:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
app = Application.objects.get(slug='lite-llm')
bs = list(app.bindings.all())
print('binding count:', len(bs))
for b in bs:
print('group', b.group, '| enabled', b.enabled, '| negate', b.negate)
" 2>/dev/null | tail -4
binding count: 0
Cero. La propia pestaña de vinculaciones de la aplicación coincidía — No Policies bound. Dos fuentes independientes, una conclusión: el asistente mostró un control de acceso que nunca se escribió.

La lección de la Parte 3 era "no te saltes las vinculaciones". El peligro real es que puedes completarlas, ver tus reglas en la tabla del asistente, enviar, y acabar con una aplicación que admite a todo el mundo — sin error, sin aviso, y con una interfaz que se veía correcta todo el rato.
Una aplicación con cero vinculaciones no está rota de ninguna forma que authentik reporte. Está haciendo exactamente lo que debe: nada vinculado significa que nadie queda excluido. Verifica las vinculaciones desde la base de datos o desde la propia pestaña de vinculaciones de la aplicación — nunca desde el asistente.
Vincúlalo desde la aplicación en su lugar: Applications → LiteLLM → Policy / Group / User
Bindings → Create or bind…, luego Bind a group bajo Bind Existing… → gateway-admins
→ Create.
Elige Bind a group en concreto. La sección Choose Policy Type de abajo crea políticas nuevas (Event Matcher, Expression, GeoIP); una vinculación de grupo ya es una regla y no necesita ninguna de ellas.

Luego verifica, porque la tabla es lo que mintió:
binding count: 1
group Group gateway-admins | enabled True | negate False
Puedes restringir una aplicación a un grupo en lugar de dejarla abierta a toda cuenta — y,
más importante, verificar que la vinculación realmente persistió (app.bindings.all()) en
vez de fiarte de la tabla del propio asistente.
Esa página indica el modo con claridad: "The currently selected policy engine mode is ANY:
Any policy must match to grant access." Con una sola vinculación, ANY y ALL se comportan
igual. Con dos no — ANY significa que basta con que una regla pase, así que una
vinculación suelta de User Contractor junto al grupo concedería exactamente el acceso que
este post intenta denegar.
Paso 4 — Apuntar el gateway hacia authentik
LiteLLM lee ajustes OIDC genéricos desde GENERIC_*. Los endpoints salen del documento de
descubrimiento:
cat >> ~/llm-gateway/.env <<'EOF'
GENERIC_CLIENT_ID=<client id del Paso 2>
GENERIC_CLIENT_SECRET=<client secret del Paso 2>
GENERIC_AUTHORIZATION_ENDPOINT=http://10.80.4.212:9100/application/o/authorize/
GENERIC_TOKEN_ENDPOINT=http://10.80.4.212:9100/application/o/token/
GENERIC_USERINFO_ENDPOINT=http://10.80.4.212:9100/application/o/userinfo/
PROXY_BASE_URL=http://localhost:4000
EOF
PROXY_BASE_URL es la que muerde. LiteLLM construye su URI de redirección a partir de ella,
así que tiene que ser igual a lo que el navegador escribe y a lo que authentik tiene
registrado. Las nuestras no coincidieron en el primer intento y produjeron un Redirect URI
Error — el redirect_uri de la barra de direcciones es lo que LiteLLM envió, el valor
Strict del proveedor es lo que authentik esperaba, y basta un puerto, un nombre de host o una
barra final entre ambos para fallar.
El archivo compose del gateway usa env_file: .env, así que a diferencia de Langfuse en la
Parte 3 no hay archivo de override que escribir:
cd ~/llm-gateway && docker compose up -d && docker compose exec litellm env | grep -c GENERIC_
Cinco. Antes de la edición era cero — la misma clase de comprobación que la Parte 3 usó para cazar variables que nunca llegaban al proceso.
Paso 5 — Los dos lados de la vinculación
Abre http://localhost:4000/ui a través del túnel. La página de inicio de sesión trae un
hallazgo que este post no fue a buscar.
Login with SSO aparece, como se pretendía. Encima hay un formulario de usuario y
contraseña, y el propio panel de LiteLLM lo explica: "By default, Username is admin and
Password is your set LiteLLM Proxy MASTER_KEY."

La clave maestra es un bypass completo. Cualquiera que la tenga llega a la interfaz de administración sin tocar authentik, sin grupo, sin vinculación. Todo lo configurado en los Pasos 1–3 está junto a una puerta que lo ignora — y la llave de esa puerta es además la credencial raíz del gateway, capaz de acuñar claves de API y alcanzar cualquier modelo.
AUTO_REDIRECT_UI_LOGIN_TO_SSO=true manda /ui directo a authentik en lugar de mostrar el
formulario. Deja de anunciar la vía de contraseña; no la elimina. Trata la clave maestra
como una credencial de emergencia y no confundas el redirect con una solución.
Inicia sesión como akadmin, que está en gateway-admins. authentik muestra la pantalla de
consentimiento del flujo explícito de la Parte 3, y aterrizas en el panel en
/ui/?login=success.


Ahora contractor.
Abrir "una ventana privada nueva" mientras ya hay otra abierta reutiliza las mismas cookies, así que authentik te vuelve a identificar como el usuario anterior. El síntoma es un inicio de sesión instantáneo que parece un fallo de tu control de acceso. Cierra todas las ventanas de incógnito, o usa otro navegador.
Permission denied. Request has been denied.

Fíjate en dónde ocurrió: authentik rechazó antes de la pantalla de consentimiento. La
comprobación de política corre por delante del consentimiento, así que un usuario bloqueado
nunca ve la página de permisos — que es también cómo cazamos las vinculaciones ausentes antes.
Cuando contractor llegó a una pantalla de consentimiento, esa fue la pista.
La parte que no queda cerrada
Desde un shell en la caja — sin cuenta de authentik, sin sesión, sin grupo, nada más que una clave:
curl -s http://127.0.0.1:4000/v1/chat/completions -H "Authorization: Bearer sk-..." -H "Content-Type: application/json" -d '{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}' | jq '{model, answer: .choices[0].message.content}'
{
"model": "qwen-mid",
"answer": "\n\nOK"
}

Nada está roto. El SSO nunca se aplicó a ese endpoint. Los Pasos 1–5 protegieron /ui —
el plano de control, donde las personas crean claves y leen el gasto. El plano de datos en
/v1/* autentica a los llamantes máquina con claves de API, y tiene que hacerlo: un agente
corriendo a las 3 de la madrugada no puede completar un login de navegador con pantalla de
consentimiento.
Esa clave se creó a su vez desde un shell, con la clave maestra, sin que existiera ninguna sesión SSO en ningún sitio:
cd ~/llm-gateway && source .env && curl -s http://127.0.0.1:4000/key/generate -H "Authorization: Bearer $LITELLM_MASTER_KEY" -H "Content-Type: application/json" -d '{"key_alias":"sso-demo","models":[]}' | jq -r '.key'
Las consecuencias, dichas sin rodeos, porque "le pusimos SSO al gateway" se dice en reuniones como si zanjara la cuestión:
- Sacar a alguien de
gateway-adminsno revoca sus claves. Pierde la interfaz y conserva todas las claves que creó. Dar de baja son dos acciones; solo una es la que la gente recuerda. - Una clave filtrada no se ve afectada por la identidad en absoluto. No hay sesión que expirar ni grupo del que sacarla.
- Las claves sobreviven a las personas, salvo que les dieras dueño y caducidad al crearlas.
El propio modelo de claves del gateway — claves acotadas, presupuestos, caducidad, modelos por clave — es lo que cubre ese terreno, y es un mecanismo distinto del configurado aquí. Complementario, no sustituible.
Una capa más: entrar no es poder hacer nada
Con la sesión iniciada como akadmin vía SSO, la página de Virtual Keys no tenía botón
Create Key.
LiteLLM asigna a los usuarios SSO un rol por defecto que no es administrador. Pasar la vinculación de authentik nos metió dentro de la interfaz; lo que podíamos hacer ahí es el modelo de roles propio de LiteLLM, que nunca configuramos. Autenticación, luego autorización, luego roles de aplicación — tres capas distintas, y superar una no dice nada de la siguiente.
Resolución de problemas — los errores que esta ejecución produjo de verdad
Application matching query does not exist
La búsqueda de ak shell falla aunque la aplicación se vea en la interfaz. El slug no es el
que supusiste — authentik lo deriva del nombre, y un nombre en CamelCase como LiteLLM se
convierte en lite-llm. Pregunta en vez de adivinar:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
for a in Application.objects.all():
print(repr(a.name), '->', repr(a.slug))
" 2>/dev/null | tail -5
Redirect URI Error
authentik rechaza antes de cualquier formulario. El redirect_uri de la barra de direcciones
es lo que LiteLLM envió; el valor Strict del proveedor es lo que authentik espera. Los
nuestros no coincidían porque PROXY_BASE_URL decía un host y la URI registrada otro. Imprime
lo registrado y compara carácter por carácter:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
p = Application.objects.get(slug='lite-llm').get_provider()
for r in p.redirect_uris:
print(repr(r.matching_mode), repr(r.url))
" 2>/dev/null | tail -5
Si lo editas y el error persiste, Applications → Clear cache — authentik cachea la configuración del proveedor.
ERR_CONNECTION_REFUSED en localhost:4000
El túnel no está reenviando, aunque ssh haya conectado. Si algo ya ocupaba el puerto local,
ssh imprimió bind: Address already in use y siguió con un shell funcional y un reenvío
muerto. Comprueba el puerto y reinicia el túnel:
lsof -nP -iTCP:4000 -sTCP:LISTEN
El usuario que esperaba que fuera rechazado entró
Dos causas, ambas reales aquí. O una vinculación de usuario suelta le concede acceso directamente — el modo ANY significa que basta con que una regla pase — o las vinculaciones nunca se guardaron. Pregunta a la base de datos, no a la interfaz:
cd ~/authentik && docker compose exec server ak shell -c "
from authentik.core.models import Application
app = Application.objects.get(slug='lite-llm')
bs = list(app.bindings.all())
print('binding count:', len(bs))
for b in bs:
print('group', b.group, '| user', b.user, '| enabled', b.enabled, '| negate', b.negate)
" 2>/dev/null | tail -5
Un usuario que llega a la pantalla de consentimiento ya pasó la comprobación de política — esa es la pista. Un usuario bloqueado nunca llega tan lejos.
El usuario rechazado entra directo sin que le pregunten
Sesión del navegador, no política. Todas las ventanas de incógnito de Chrome comparten una sesión, así que abrir "una ventana privada nueva" mientras hay otra abierta mantiene al usuario anterior identificado. Cierra todas las ventanas de incógnito, o usa otro navegador.
No hay botón Create Key en la interfaz de administración
Has entrado por SSO, y LiteLLM da a los usuarios SSO un rol por defecto que no es administrador. Crea claves desde el shell con la clave maestra, o configura los roles propios de LiteLLM — un mecanismo aparte de todo lo que hace authentik.
Puedes distinguir un plano de control de un plano de datos en tu propia pila: poner SSO y pertenencia a un grupo delante de una interfaz de administración, y luego demostrar que la API detrás sigue respondiendo solo con una clave — y nombrar lo que eso significa para las bajas, las claves filtradas y las claves que sobreviven a las personas.
Qué te compró esto y qué no
Hecho: un grupo que significa algo, una vinculación que lo aplica y que verificamos en la base de datos en lugar de fiarnos de una tabla, y una interfaz de administración donde el acceso sigue a la pertenencia.
No hecho:
- La API queda intacta, por diseño, como se demostró.
- La clave maestra sigue entrando, saltándose todo esto.
- Los usuarios SSO no tienen rol en LiteLLM más allá del de por defecto.
- Sigue siendo HTTP plano. Todo token y secreto aquí cruza la red en claro.
- Sin MFA.
Qué sigue
TLS y exposición real. El gateway está en 127.0.0.1:4000 y en este post solo es accesible a
través de un túnel SSH; authentik va por HTTP plano. Hacer que cualquiera de los dos sea
accesible de verdad significa un proxy inverso, un nombre de host y un certificado — y cada
URI de redirección, PROXY_BASE_URL y emisor cambia el día que eso ocurra.
