From ce4fd4e0e864121cf8da79ac664652d217087ad9 Mon Sep 17 00:00:00 2001 From: Mubashir Osmani Date: Tue, 7 Jul 2026 23:05:38 +0000 Subject: [PATCH] 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> --- .../proxy_setting_endpoints.py | 75 ++++++++++--------- .../test_proxy_setting_endpoints.py | 75 +++++++++++++++++++ 2 files changed, 115 insertions(+), 35 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index a8926d26047..62340795c9b 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -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 } diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 69845ec59c2..6574265420e 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -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