Saltar al contenido principal
intermediatePart 4

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

· 18 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
authentik+LiteLLM

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.

Reproducibilidad

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.

La lista de grupos de authentik mostrando solo los tres grupos integrados, con authentik Admins ya con un miembro

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
Silenciar ak shell

Cada 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únel

Si 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.

El paso Configure Bindings del asistente, vacío, indicando No bound policies

El mismo paso del asistente tras vincular, listando Group gateway-admins como habilitado

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 pestaña de vinculaciones de la aplicación LiteLLM indicando No Policies bound

Esto es peor que saltarse el paso

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.

El diálogo Create Binding con gateway-admins seleccionado, Enabled activado, Negate Result desactivado y Failure Result en Don't Pass

Luego verifica, porque la tabla es lo que mintió:

binding count: 1
group Group gateway-admins | enabled True | negate False
Habilidad desbloqueada 🏅

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 página de inicio de sesión de LiteLLM mostrando un formulario de usuario y contraseña encima del botón Login with SSO, con un panel que nombra la clave maestra como contraseña por defecto

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.

La pantalla de inicio de sesión de authentik indicando Log in to continue to LiteLLM

La pantalla de consentimiento para akadmin, listando Email address y General Profile Information

Ahora contractor.

Todas las ventanas de incógnito comparten una sesión

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.

authentik rechazando a contractor con Permission denied y 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"
}

La llamada curl respondiendo con normalidad desde un shell sin sesión de authentik

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-admins no 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.

Habilidad desbloqueada 🏅

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.
Finished this tutorial?
Mark it complete to earn Membership, not just an account on your skill path.

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.

Lecturas adicionales​

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.