feat(litellm): SSO and user management

This commit is contained in:
Masaki Yatsu
2025-12-04 00:19:14 +09:00
parent 5055a36d87
commit 2955d7d783
4 changed files with 376 additions and 4 deletions

View File

@@ -150,6 +150,81 @@ just litellm::verify-api-keys
| `OLLAMA_NAMESPACE` | `ollama` | Ollama namespace for local models | | `OLLAMA_NAMESPACE` | `ollama` | Ollama namespace for local models |
| `MONITORING_ENABLED` | (prompt) | Enable Prometheus ServiceMonitor | | `MONITORING_ENABLED` | (prompt) | Enable Prometheus ServiceMonitor |
## Authentication
LiteLLM has two types of authentication:
1. **API Access**: Uses Master Key or Virtual Keys for programmatic access
2. **Admin UI**: Uses Keycloak SSO for browser-based access
### Enable SSO for Admin UI
After installing LiteLLM, enable Keycloak authentication for the Admin UI:
```bash
just litellm::setup-oidc
```
This will:
- Create a Keycloak client for LiteLLM
- Store the client secret in Vault
- Configure LiteLLM with OIDC environment variables
- Upgrade the deployment with SSO enabled
### Disable SSO
To disable SSO and return to unauthenticated Admin UI access:
```bash
just litellm::disable-oidc
```
### SSO Configuration Details
| Setting | Value |
| ------- | ----- |
| Callback URL | `https://<litellm-host>/sso/callback` |
| Authorization Endpoint | `https://<keycloak-host>/realms/<realm>/protocol/openid-connect/auth` |
| Token Endpoint | `https://<keycloak-host>/realms/<realm>/protocol/openid-connect/token` |
| Userinfo Endpoint | `https://<keycloak-host>/realms/<realm>/protocol/openid-connect/userinfo` |
| Scope | `openid email profile` |
## User Management
SSO users are automatically created in LiteLLM when they first log in. By default, new users are assigned the `internal_user_viewer` role (read-only access).
### List Users
```bash
just litellm::list-users
```
### Assign Role to User
Interactively select user and role:
```bash
just litellm::assign-role
```
Or specify directly:
```bash
just litellm::assign-role buun proxy_admin
```
### User Roles
| Role | Description |
| ---- | ----------- |
| `proxy_admin` | Full admin access (manage keys, users, models, settings) |
| `proxy_admin_viewer` | Admin read-only access |
| `internal_user` | Can create and manage own API keys |
| `internal_user_viewer` | Read-only access (default for SSO users) |
**Note**: To manage API keys in the Admin UI, users need at least `internal_user` or `proxy_admin` role.
## API Usage ## API Usage
LiteLLM exposes an OpenAI-compatible API at `https://your-litellm-host/`. LiteLLM exposes an OpenAI-compatible API at `https://your-litellm-host/`.
@@ -163,9 +238,11 @@ just litellm::master-key
### Generate Virtual Key for a User ### Generate Virtual Key for a User
```bash ```bash
just litellm::generate-virtual-key user@example.com just litellm::generate-virtual-key buun
``` ```
This will prompt for a model selection and generate an API key for the specified user.
### OpenAI SDK Example ### OpenAI SDK Example
```python ```python
@@ -330,6 +407,7 @@ kubectl exec -n litellm deployment/litellm -- \
| `models.example.yaml` | Example model configuration | | `models.example.yaml` | Example model configuration |
| `litellm-values.gomplate.yaml` | Helm values template | | `litellm-values.gomplate.yaml` | Helm values template |
| `apikey-external-secret.gomplate.yaml` | ExternalSecret for API keys | | `apikey-external-secret.gomplate.yaml` | ExternalSecret for API keys |
| `keycloak-auth-external-secret.gomplate.yaml` | ExternalSecret for Keycloak OIDC |
## Security Considerations ## Security Considerations

View File

@@ -3,10 +3,15 @@ set fallback := true
export LITELLM_NAMESPACE := env("LITELLM_NAMESPACE", "litellm") export LITELLM_NAMESPACE := env("LITELLM_NAMESPACE", "litellm")
export LITELLM_CHART_VERSION := env("LITELLM_CHART_VERSION", "0.1.825") export LITELLM_CHART_VERSION := env("LITELLM_CHART_VERSION", "0.1.825")
export LITELLM_HOST := env("LITELLM_HOST", "") export LITELLM_HOST := env("LITELLM_HOST", "")
export LITELLM_OIDC_CLIENT_ID := env("LITELLM_OIDC_CLIENT_ID", "litellm")
export LITELLM_OIDC_ENABLED := env("LITELLM_OIDC_ENABLED", "")
export EXTERNAL_SECRETS_NAMESPACE := env("EXTERNAL_SECRETS_NAMESPACE", "external-secrets") export EXTERNAL_SECRETS_NAMESPACE := env("EXTERNAL_SECRETS_NAMESPACE", "external-secrets")
export OLLAMA_NAMESPACE := env("OLLAMA_NAMESPACE", "ollama") export OLLAMA_NAMESPACE := env("OLLAMA_NAMESPACE", "ollama")
export PROMETHEUS_NAMESPACE := env("PROMETHEUS_NAMESPACE", "monitoring") export PROMETHEUS_NAMESPACE := env("PROMETHEUS_NAMESPACE", "monitoring")
export MONITORING_ENABLED := env("MONITORING_ENABLED", "") export MONITORING_ENABLED := env("MONITORING_ENABLED", "")
export KEYCLOAK_REALM := env("KEYCLOAK_REALM", "buunstack")
export KEYCLOAK_HOST := env("KEYCLOAK_HOST", "")
export K8S_VAULT_NAMESPACE := env("K8S_VAULT_NAMESPACE", "vault")
[private] [private]
default: default:
@@ -293,6 +298,11 @@ install:
--placeholder="e.g., litellm.example.com") --placeholder="e.g., litellm.example.com")
done done
while [ -z "${KEYCLOAK_HOST}" ]; do
KEYCLOAK_HOST=$(gum input --prompt="Keycloak host (FQDN): " --width=80 \
--placeholder="e.g., auth.example.com")
done
if helm status kube-prometheus-stack -n ${PROMETHEUS_NAMESPACE} &>/dev/null; then if helm status kube-prometheus-stack -n ${PROMETHEUS_NAMESPACE} &>/dev/null; then
if [ -z "${MONITORING_ENABLED}" ]; then if [ -z "${MONITORING_ENABLED}" ]; then
if gum confirm "Enable Prometheus monitoring?"; then if gum confirm "Enable Prometheus monitoring?"; then
@@ -320,6 +330,11 @@ install:
just create-postgres-user-and-db just create-postgres-user-and-db
just create-postgres-secret just create-postgres-secret
echo "Setting up Keycloak OIDC authentication..."
just create-keycloak-client
just create-keycloak-auth-secret
LITELLM_OIDC_ENABLED="true"
echo "Generating Helm values..." echo "Generating Helm values..."
gomplate -f litellm-values.gomplate.yaml -o litellm-values.yaml gomplate -f litellm-values.gomplate.yaml -o litellm-values.yaml
@@ -331,6 +346,7 @@ install:
echo "" echo ""
echo "LiteLLM installed successfully!" echo "LiteLLM installed successfully!"
echo "Access LiteLLM at: https://${LITELLM_HOST}" echo "Access LiteLLM at: https://${LITELLM_HOST}"
echo "SSO Callback URL: https://${LITELLM_HOST}/sso/callback"
# Upgrade LiteLLM # Upgrade LiteLLM
upgrade: upgrade:
@@ -364,6 +380,9 @@ upgrade:
echo "Generating Helm values..." echo "Generating Helm values..."
gomplate -f litellm-values.gomplate.yaml -o litellm-values.yaml gomplate -f litellm-values.gomplate.yaml -o litellm-values.yaml
# Delete the migration job as it's immutable and blocks helm upgrade
kubectl delete job litellm-migrations -n ${LITELLM_NAMESPACE} --ignore-not-found
echo "Upgrading LiteLLM Helm chart..." echo "Upgrading LiteLLM Helm chart..."
helm upgrade litellm oci://ghcr.io/berriai/litellm-helm \ helm upgrade litellm oci://ghcr.io/berriai/litellm-helm \
--version ${LITELLM_CHART_VERSION} -n ${LITELLM_NAMESPACE} --wait \ --version ${LITELLM_CHART_VERSION} -n ${LITELLM_NAMESPACE} --wait \
@@ -416,7 +435,7 @@ generate-virtual-key user='' model='':
set -euo pipefail set -euo pipefail
user="{{ user }}" user="{{ user }}"
while [ -z "${user}" ]; do while [ -z "${user}" ]; do
user=$(gum input --prompt="User email: " --width=80) user=$(gum input --prompt="Username: " --width=80)
done done
model="{{ model }}" model="{{ model }}"
if [ -z "${model}" ]; then if [ -z "${model}" ]; then
@@ -429,12 +448,247 @@ generate-virtual-key user='' model='':
fi fi
master_key=$(kubectl get secret litellm-masterkey -n ${LITELLM_NAMESPACE} \ master_key=$(kubectl get secret litellm-masterkey -n ${LITELLM_NAMESPACE} \
-o jsonpath="{.data.masterkey}" | base64 --decode) -o jsonpath="{.data.masterkey}" | base64 --decode)
curl "https://${LITELLM_HOST}/key/generate" \ response=$(curl -s "https://${LITELLM_HOST}/key/generate" \
--header "Authorization: Bearer ${master_key}" \ --header "Authorization: Bearer ${master_key}" \
--header "Content-Type: application/json" \ --header "Content-Type: application/json" \
--data-raw "{\"models\": [\"${model}\"], \"metadata\": {\"user\": \"${user}\"}}" | jq . --data-raw "{\"models\": [\"${model}\"], \"metadata\": {\"user\": \"${user}\"}}")
echo "${response}" | jq .
echo ""
echo "API Key: $(echo "${response}" | jq -r '.key')"
# Get master key # Get master key
master-key: master-key:
@kubectl get secret litellm-masterkey -n ${LITELLM_NAMESPACE} \ @kubectl get secret litellm-masterkey -n ${LITELLM_NAMESPACE} \
-o jsonpath="{.data.masterkey}" | base64 --decode -o jsonpath="{.data.masterkey}" | base64 --decode
# List users
list-users:
kubectl exec -n postgres postgres-cluster-1 -- psql -U postgres -d litellm -c \
"SELECT user_id, user_email, user_role FROM \"LiteLLM_UserTable\";"
# Assign role to user
assign-role user='' role='':
#!/bin/bash
set -euo pipefail
user="{{ user }}"
role="{{ role }}"
if [ -z "${user}" ]; then
users=$(kubectl exec -n postgres postgres-cluster-1 -- psql -U postgres -d litellm -t -c \
"SELECT user_id FROM \"LiteLLM_UserTable\";" 2>/dev/null | tr -d ' ' | grep -v '^$')
if [ -z "${users}" ]; then
echo "No users found"
exit 1
fi
user=$(echo "${users}" | gum choose --header="Select user:")
fi
if [ -z "${role}" ]; then
role=$(gum choose --header="Select role:" \
"proxy_admin" \
"proxy_admin_viewer" \
"internal_user" \
"internal_user_viewer")
fi
kubectl exec -n postgres postgres-cluster-1 -- psql -U postgres -d litellm -c \
"UPDATE \"LiteLLM_UserTable\" SET user_role = '${role}' WHERE user_id = '${user}';"
echo "Assigned role '${role}' to user '${user}'"
# Create Keycloak client for LiteLLM OIDC
create-keycloak-client:
#!/bin/bash
set -euo pipefail
while [ -z "${LITELLM_HOST}" ]; do
LITELLM_HOST=$(
gum input --prompt="LiteLLM host (FQDN): " --width=100 \
--placeholder="e.g., litellm.example.com"
)
done
echo "Creating Keycloak client for LiteLLM..."
just keycloak::delete-client ${KEYCLOAK_REALM} ${LITELLM_OIDC_CLIENT_ID} || true
CLIENT_SECRET=$(just utils::random-password)
just keycloak::create-client \
realm=${KEYCLOAK_REALM} \
client_id=${LITELLM_OIDC_CLIENT_ID} \
redirect_url="https://${LITELLM_HOST}/sso/callback" \
client_secret="${CLIENT_SECRET}"
# Store temporarily in k8s secret for create-keycloak-auth-secret to pick up
kubectl delete secret litellm-oauth-temp -n ${LITELLM_NAMESPACE} --ignore-not-found
kubectl create secret generic litellm-oauth-temp -n ${LITELLM_NAMESPACE} \
--from-literal=client_id="${LITELLM_OIDC_CLIENT_ID}" \
--from-literal=client_secret="${CLIENT_SECRET}"
echo "Keycloak client created successfully"
echo "Client ID: ${LITELLM_OIDC_CLIENT_ID}"
echo "Redirect URI: https://${LITELLM_HOST}/sso/callback"
# Delete Keycloak client for LiteLLM
delete-keycloak-client:
#!/bin/bash
set -euo pipefail
echo "Deleting Keycloak client for LiteLLM..."
just keycloak::delete-client ${KEYCLOAK_REALM} ${LITELLM_OIDC_CLIENT_ID} || true
kubectl delete secret litellm-oauth-temp -n ${LITELLM_NAMESPACE} --ignore-not-found
if just vault::exist keycloak/client/litellm &>/dev/null; then
just vault::delete keycloak/client/litellm
fi
# Create Keycloak auth secret
create-keycloak-auth-secret:
#!/bin/bash
set -euo pipefail
# Prioritize temporary secret (freshly created) over Vault (potentially stale)
if kubectl get secret litellm-oauth-temp -n ${LITELLM_NAMESPACE} &>/dev/null; then
oauth_client_id=$(kubectl get secret litellm-oauth-temp -n ${LITELLM_NAMESPACE} \
-o jsonpath='{.data.client_id}' | base64 -d)
oauth_client_secret=$(kubectl get secret litellm-oauth-temp -n ${LITELLM_NAMESPACE} \
-o jsonpath='{.data.client_secret}' | base64 -d)
elif helm status vault -n ${K8S_VAULT_NAMESPACE} &>/dev/null && \
just vault::get keycloak/client/litellm client_secret &>/dev/null; then
oauth_client_id=$(just vault::get keycloak/client/litellm client_id)
oauth_client_secret=$(just vault::get keycloak/client/litellm client_secret)
else
echo "Error: Cannot retrieve OAuth client secret. Please run 'just litellm::create-keycloak-client' first."
exit 1
fi
if helm status external-secrets -n ${EXTERNAL_SECRETS_NAMESPACE} &>/dev/null; then
echo "External Secrets Operator detected. Storing secrets in Vault..."
# Store OAuth credentials in Vault
just vault::put keycloak/client/litellm \
client_id="${oauth_client_id}" \
client_secret="${oauth_client_secret}"
# Delete existing secrets and ExternalSecrets
kubectl delete secret keycloak-auth -n ${LITELLM_NAMESPACE} --ignore-not-found
kubectl delete externalsecret keycloak-auth-external-secret -n ${LITELLM_NAMESPACE} --ignore-not-found
# Create ExternalSecret
gomplate -f keycloak-auth-external-secret.gomplate.yaml | kubectl apply -f -
echo "Waiting for ExternalSecret to sync..."
kubectl wait --for=condition=Ready externalsecret/keycloak-auth-external-secret \
-n ${LITELLM_NAMESPACE} --timeout=60s
echo "ExternalSecret created successfully"
else
echo "External Secrets Operator not found. Creating Kubernetes Secret directly..."
# Create Keycloak OAuth Secret
kubectl delete secret keycloak-auth -n ${LITELLM_NAMESPACE} --ignore-not-found
kubectl create secret generic keycloak-auth -n ${LITELLM_NAMESPACE} \
--from-literal=GENERIC_CLIENT_ID="${oauth_client_id}" \
--from-literal=GENERIC_CLIENT_SECRET="${oauth_client_secret}"
# Store credentials in Vault if available (backup for admin credentials)
if helm status vault -n ${K8S_VAULT_NAMESPACE} &>/dev/null; then
just vault::put keycloak/client/litellm \
client_id="${oauth_client_id}" \
client_secret="${oauth_client_secret}"
fi
echo "Kubernetes Secret created successfully"
fi
# Clean up temporary OAuth secret
kubectl delete secret litellm-oauth-temp -n ${LITELLM_NAMESPACE} --ignore-not-found
# Delete Keycloak auth secret
delete-keycloak-auth-secret:
kubectl delete externalsecret keycloak-auth-external-secret -n ${LITELLM_NAMESPACE} --ignore-not-found
kubectl delete secret keycloak-auth -n ${LITELLM_NAMESPACE} --ignore-not-found
# Setup OIDC authentication for Admin UI
setup-oidc:
#!/bin/bash
set -euo pipefail
echo "Setting up OIDC authentication for LiteLLM Admin UI..."
while [ -z "${LITELLM_HOST}" ]; do
LITELLM_HOST=$(
gum input --prompt="LiteLLM host (FQDN): " --width=100 \
--placeholder="e.g., litellm.example.com"
)
done
while [ -z "${KEYCLOAK_HOST}" ]; do
KEYCLOAK_HOST=$(
gum input --prompt="Keycloak host (FQDN): " --width=100 \
--placeholder="e.g., auth.example.com"
)
done
just create-keycloak-client
just create-keycloak-auth-secret
LITELLM_OIDC_ENABLED="true"
if helm status kube-prometheus-stack -n ${PROMETHEUS_NAMESPACE} &>/dev/null; then
if [ -z "${MONITORING_ENABLED}" ]; then
if gum confirm "Enable Prometheus monitoring?"; then
MONITORING_ENABLED="true"
fi
fi
fi
echo "Generating Helm values with OIDC configuration..."
gomplate -f litellm-values.gomplate.yaml -o litellm-values.yaml
# Delete the migration job as it's immutable and blocks helm upgrade
kubectl delete job litellm-migrations -n ${LITELLM_NAMESPACE} --ignore-not-found
echo "Upgrading LiteLLM with OIDC configuration..."
helm upgrade litellm oci://ghcr.io/berriai/litellm-helm \
--version ${LITELLM_CHART_VERSION} -n ${LITELLM_NAMESPACE} --wait \
-f litellm-values.yaml
echo ""
echo "OIDC authentication configured successfully!"
echo "Access LiteLLM at: https://${LITELLM_HOST}"
echo "SSO Callback URL: https://${LITELLM_HOST}/sso/callback"
# Disable OIDC authentication
disable-oidc:
#!/bin/bash
set -euo pipefail
echo "Disabling OIDC authentication for LiteLLM Admin UI..."
LITELLM_OIDC_ENABLED=""
while [ -z "${LITELLM_HOST}" ]; do
LITELLM_HOST=$(
gum input --prompt="LiteLLM host (FQDN): " --width=100 \
--placeholder="e.g., litellm.example.com"
)
done
if helm status kube-prometheus-stack -n ${PROMETHEUS_NAMESPACE} &>/dev/null; then
if [ -z "${MONITORING_ENABLED}" ]; then
if gum confirm "Enable Prometheus monitoring?"; then
MONITORING_ENABLED="true"
fi
fi
fi
echo "Generating Helm values without OIDC..."
gomplate -f litellm-values.gomplate.yaml -o litellm-values.yaml
# Delete the migration job as it's immutable and blocks helm upgrade
kubectl delete job litellm-migrations -n ${LITELLM_NAMESPACE} --ignore-not-found
echo "Upgrading LiteLLM without OIDC..."
helm upgrade litellm oci://ghcr.io/berriai/litellm-helm \
--version ${LITELLM_CHART_VERSION} -n ${LITELLM_NAMESPACE} --wait \
-f litellm-values.yaml
echo ""
echo "OIDC authentication disabled."
echo "Note: Keycloak client was NOT deleted. To clean up, run:"
echo " just litellm::delete-keycloak-client"

View File

@@ -0,0 +1,22 @@
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: keycloak-auth-external-secret
namespace: {{ .Env.LITELLM_NAMESPACE }}
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-secret-store
kind: ClusterSecretStore
target:
name: keycloak-auth
creationPolicy: Owner
data:
- secretKey: GENERIC_CLIENT_ID
remoteRef:
key: keycloak/client/litellm
property: client_id
- secretKey: GENERIC_CLIENT_SECRET
remoteRef:
key: keycloak/client/litellm
property: client_secret

View File

@@ -18,8 +18,26 @@ migrationJob:
limits: limits:
memory: 1Gi memory: 1Gi
{{- if .Env.LITELLM_OIDC_ENABLED }}
environmentSecrets: environmentSecrets:
- apikey - apikey
- keycloak-auth
extraEnvVars:
- name: PROXY_BASE_URL
value: "https://{{ .Env.LITELLM_HOST }}"
- name: GENERIC_AUTHORIZATION_ENDPOINT
value: "https://{{ .Env.KEYCLOAK_HOST }}/realms/{{ .Env.KEYCLOAK_REALM }}/protocol/openid-connect/auth"
- name: GENERIC_TOKEN_ENDPOINT
value: "https://{{ .Env.KEYCLOAK_HOST }}/realms/{{ .Env.KEYCLOAK_REALM }}/protocol/openid-connect/token"
- name: GENERIC_USERINFO_ENDPOINT
value: "https://{{ .Env.KEYCLOAK_HOST }}/realms/{{ .Env.KEYCLOAK_REALM }}/protocol/openid-connect/userinfo"
- name: GENERIC_SCOPE
value: "openid email profile"
{{- else }}
environmentSecrets:
- apikey
{{- end }}
proxy_config: proxy_config:
model_list: model_list: