mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
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:
parent
9652509e46
commit
ce4fd4e0e8
2 changed files with 115 additions and 35 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue