feat(ui): surface env-configured SSO settings in the Admin UI panel

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Mubashir Osmani 2026-07-07 23:05:38 +00:00
parent 9652509e46
commit ce4fd4e0e8
2 changed files with 115 additions and 35 deletions

View file

@ -1,6 +1,7 @@
#### CRUD ENDPOINTS for UI Settings #####
import asyncio
import json
import os
from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union
from urllib.parse import urlparse
@ -35,6 +36,23 @@ _SSO_SENSITIVE_FIELDS: Set[str] = {
"generic_client_secret",
}
# Maps SSOConfig field names to the process env vars the SSO login flows read
# (litellm/proxy/management_endpoints/ui_sso.py). Used both to persist config to
# the environment on write and to fall back to it on read.
_SSO_FIELD_TO_ENV_VAR: Dict[str, str] = {
"google_client_id": "GOOGLE_CLIENT_ID",
"google_client_secret": "GOOGLE_CLIENT_SECRET",
"microsoft_client_id": "MICROSOFT_CLIENT_ID",
"microsoft_client_secret": "MICROSOFT_CLIENT_SECRET",
"microsoft_tenant": "MICROSOFT_TENANT",
"generic_client_id": "GENERIC_CLIENT_ID",
"generic_client_secret": "GENERIC_CLIENT_SECRET",
"generic_authorization_endpoint": "GENERIC_AUTHORIZATION_ENDPOINT",
"generic_token_endpoint": "GENERIC_TOKEN_ENDPOINT",
"generic_userinfo_endpoint": "GENERIC_USERINFO_ENDPOINT",
"proxy_base_url": "PROXY_BASE_URL",
}
class IPAddress(BaseModel):
ip: str
@ -759,22 +777,27 @@ async def get_sso_settings():
environment_variables=sso_settings_dict
)
# Build SSO config with database values or environment fallback
# Build SSO config from database values, falling back to the environment so
# env-configured SSO still populates the UI. Stored DB values win on overlap.
env_fallback = {
field: os.environ[env_var] for field, env_var in _SSO_FIELD_TO_ENV_VAR.items() if os.environ.get(env_var)
}
merged_sso_settings = {**env_fallback, **decrypted_sso_settings_dict}
sso_config = SSOConfig(
google_client_id=decrypted_sso_settings_dict.get("google_client_id", None),
google_client_secret=decrypted_sso_settings_dict.get("google_client_secret", None),
microsoft_client_id=decrypted_sso_settings_dict.get("microsoft_client_id", None),
microsoft_client_secret=decrypted_sso_settings_dict.get("microsoft_client_secret", None),
microsoft_tenant=decrypted_sso_settings_dict.get("microsoft_tenant", None),
generic_client_id=decrypted_sso_settings_dict.get("generic_client_id", None),
generic_client_secret=decrypted_sso_settings_dict.get("generic_client_secret", None),
generic_authorization_endpoint=decrypted_sso_settings_dict.get("generic_authorization_endpoint", None),
generic_token_endpoint=decrypted_sso_settings_dict.get("generic_token_endpoint", None),
generic_userinfo_endpoint=decrypted_sso_settings_dict.get("generic_userinfo_endpoint", None),
proxy_base_url=decrypted_sso_settings_dict.get("proxy_base_url", None),
user_email=decrypted_sso_settings_dict.get("user_email"),
ui_access_mode=decrypted_sso_settings_dict.get("ui_access_mode"),
google_client_id=merged_sso_settings.get("google_client_id", None),
google_client_secret=merged_sso_settings.get("google_client_secret", None),
microsoft_client_id=merged_sso_settings.get("microsoft_client_id", None),
microsoft_client_secret=merged_sso_settings.get("microsoft_client_secret", None),
microsoft_tenant=merged_sso_settings.get("microsoft_tenant", None),
generic_client_id=merged_sso_settings.get("generic_client_id", None),
generic_client_secret=merged_sso_settings.get("generic_client_secret", None),
generic_authorization_endpoint=merged_sso_settings.get("generic_authorization_endpoint", None),
generic_token_endpoint=merged_sso_settings.get("generic_token_endpoint", None),
generic_userinfo_endpoint=merged_sso_settings.get("generic_userinfo_endpoint", None),
proxy_base_url=merged_sso_settings.get("proxy_base_url", None),
user_email=merged_sso_settings.get("user_email"),
ui_access_mode=merged_sso_settings.get("ui_access_mode"),
role_mappings=role_mappings,
team_mappings=team_mappings,
)
@ -819,9 +842,6 @@ async def update_sso_settings(
"""
Update SSO configuration by saving to the dedicated SSO table.
"""
import json
import os
from litellm.proxy.proxy_server import (
create_config_audit_log,
prisma_client,
@ -841,21 +861,6 @@ async def update_sso_settings(
detail={"error": "Set `'STORE_MODEL_IN_DB='True'` in your env to enable this feature."},
)
# Update environment variables
env_var_mapping = {
"google_client_id": "GOOGLE_CLIENT_ID",
"google_client_secret": "GOOGLE_CLIENT_SECRET",
"microsoft_client_id": "MICROSOFT_CLIENT_ID",
"microsoft_client_secret": "MICROSOFT_CLIENT_SECRET",
"microsoft_tenant": "MICROSOFT_TENANT",
"generic_client_id": "GENERIC_CLIENT_ID",
"generic_client_secret": "GENERIC_CLIENT_SECRET",
"generic_authorization_endpoint": "GENERIC_AUTHORIZATION_ENDPOINT",
"generic_token_endpoint": "GENERIC_TOKEN_ENDPOINT",
"generic_userinfo_endpoint": "GENERIC_USERINFO_ENDPOINT",
"proxy_base_url": "PROXY_BASE_URL",
}
# Read the existing SSO row first so the audit log captures a real
# before/after diff. Stored values are encrypted; decrypt them so the
# before-snapshot has the same shape as after_value, and rely on
@ -884,8 +889,8 @@ async def update_sso_settings(
# Update environment variables in config and in memory
sso_data = sso_config.model_dump()
for field_name, value in sso_data.items():
if field_name in env_var_mapping:
env_var_name = env_var_mapping[field_name]
if field_name in _SSO_FIELD_TO_ENV_VAR:
env_var_name = _SSO_FIELD_TO_ENV_VAR[field_name]
if value:
os.environ[env_var_name] = value
else:
@ -935,7 +940,7 @@ async def update_sso_settings(
else:
environment_variables = {}
env_vars_to_remove = set(env_var_mapping.values())
env_vars_to_remove = set(_SSO_FIELD_TO_ENV_VAR.values())
filtered_env_vars = {
key: value for key, value in environment_variables.items() if key not in env_vars_to_remove
}

View file

@ -386,6 +386,81 @@ class TestProxySettingEndpoints:
call_args = mock_prisma.db.litellm_ssoconfig.find_unique.call_args
assert call_args.kwargs["where"]["id"] == "sso_config"
def test_get_sso_settings_falls_back_to_env_when_db_empty(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""SSO configured only via env vars (no DB row) still populates the panel."""
from unittest.mock import AsyncMock, MagicMock
# No SSO row saved through the UI
mock_prisma = MagicMock()
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
from litellm.proxy.proxy_server import proxy_config
monkeypatch.setattr(
proxy_config,
"_decrypt_and_set_db_env_variables",
lambda environment_variables: environment_variables,
)
monkeypatch.setenv("GOOGLE_CLIENT_ID", "env_google_client_id")
monkeypatch.setenv("GOOGLE_CLIENT_SECRET", "env_google_client_secret")
monkeypatch.setenv("GENERIC_AUTHORIZATION_ENDPOINT", "https://env.okta.com/authorize")
monkeypatch.setenv("PROXY_BASE_URL", "https://env-proxy.example.com")
response = client.get("/get/sso_settings")
assert response.status_code == 200
values = response.json()["values"]
# Non-secret env values surface verbatim
assert values["google_client_id"] == "env_google_client_id"
assert values["generic_authorization_endpoint"] == "https://env.okta.com/authorize"
assert values["proxy_base_url"] == "https://env-proxy.example.com"
# Secret env values are still masked on read
assert values["google_client_secret"] != "env_google_client_secret"
assert "*" in values["google_client_secret"]
def test_get_sso_settings_db_takes_precedence_over_env(
self, mock_proxy_config, mock_auth, monkeypatch
):
"""When both DB and env define a field, the stored DB value wins."""
from unittest.mock import AsyncMock, MagicMock
mock_prisma = MagicMock()
mock_db_record = MagicMock()
mock_db_record.sso_settings = {
"google_client_id": "db_google_client_id",
}
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(
return_value=mock_db_record
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
from litellm.proxy.proxy_server import proxy_config
monkeypatch.setattr(
proxy_config,
"_decrypt_and_set_db_env_variables",
lambda environment_variables: environment_variables,
)
monkeypatch.setenv("GOOGLE_CLIENT_ID", "env_google_client_id")
monkeypatch.setenv("MICROSOFT_TENANT", "env_tenant")
response = client.get("/get/sso_settings")
assert response.status_code == 200
values = response.json()["values"]
# DB value wins for the overlapping field
assert values["google_client_id"] == "db_google_client_id"
# Env-only field still falls back
assert values["microsoft_tenant"] == "env_tenant"
def test_update_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch):
"""Test updating the SSO settings to the dedicated database table"""
import json