mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): resolve SSO and SMTP settings from a typed config object (#33576)
The SSO and Email Server settings pages read only stored config, so a gateway configured entirely through environment variables rendered every field blank even though both features were live. Rather than add per-endpoint env fallback, resolve each setting through one typed config object. A FieldDescriptor names, for one setting, where it lives in the stored row (db_key), which process env var carries it (env_var), whether it is a secret, and its effective default. A pure resolve_fields reconciles a descriptor table against the stored row and the process environment with a fixed precedence and reports per-field provenance (db, env, default, or unset). The SSO descriptor table single-sources the field-to-env mapping that the read and write paths previously duplicated, so they can no longer drift. get_sso_settings and the /get/config/callbacks alerting block read through the resolver instead of their own inline fallbacks. get_sso_settings no longer decrypts stored values into os.environ; decryption happens once inside the resolver via the pure helper, so a GET stops mutating the process environment. The SSO response carries provenance so the UI can distinguish an env-sourced value from a stored one, and secrets are masked at the endpoint (the resolver returns them unmasked so the login path could consume them). os.environ remains the runtime carrier; the SSO login and mail-send paths are unchanged. The settings pages also submit only fields an admin actually edited, so a rendered mask or env-sourced value is never written back over a working secret, and generic_scope is a real SSO form field. Omitting a field from /update/sso_settings clears it, which provider switching relies on; the deeper write-path concern that behaviour points at is tracked in LIT-4498.
This commit is contained in:
parent
c6b2f111a6
commit
9baea68f37
19 changed files with 693 additions and 217 deletions
|
|
@ -46,6 +46,7 @@ jobs:
|
|||
tests/test_litellm/proxy/rag_endpoints
|
||||
tests/test_litellm/proxy/realtime_endpoints
|
||||
tests/test_litellm/proxy/ui_crud_endpoints
|
||||
tests/test_litellm/proxy/config_resolvers
|
||||
tests/test_litellm/proxy/utils
|
||||
workers: 2
|
||||
reruns: 2
|
||||
|
|
|
|||
9
litellm/proxy/config_resolvers/__init__.py
Normal file
9
litellm/proxy/config_resolvers/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""Typed, provenance-aware resolution of proxy settings from DB then env."""
|
||||
|
||||
from litellm.proxy.config_resolvers._descriptors import (
|
||||
FieldDescriptor,
|
||||
FieldSource,
|
||||
resolve_fields,
|
||||
)
|
||||
|
||||
__all__ = ["FieldDescriptor", "FieldSource", "resolve_fields"]
|
||||
73
litellm/proxy/config_resolvers/_descriptors.py
Normal file
73
litellm/proxy/config_resolvers/_descriptors.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"""Shared primitive for resolving a settings value from its sources.
|
||||
|
||||
A ``FieldDescriptor`` names, for one setting, where it lives in the stored DB
|
||||
row (``db_key``), which process env var carries it (``env_var``), whether it is
|
||||
a secret, and its effective default. ``resolve_fields`` reconciles a set of
|
||||
descriptors against a decrypted DB row and the process environment with a fixed
|
||||
precedence, returning the resolved values plus per-field provenance so a caller
|
||||
can tell whether a value came from the database, the environment, a default, or
|
||||
is unset.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
FieldSource = Literal["db", "env", "default", "unset"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FieldDescriptor:
|
||||
field_name: str
|
||||
db_key: str
|
||||
env_var: str
|
||||
is_secret: bool = False
|
||||
default: str | None = None
|
||||
|
||||
|
||||
def _db_is_set(db_value: object, empty_db_is_set: bool) -> bool:
|
||||
if empty_db_is_set:
|
||||
# A stored key that is present, even as "", is an explicit admin choice
|
||||
# (e.g. clearing an alerting webhook) and must win over a stale env var.
|
||||
return db_value is not None
|
||||
# A blank stored value is treated as absent, so it falls through to env. This
|
||||
# fits settings whose clear path also unsets the env var (e.g. SSO).
|
||||
return isinstance(db_value, str) and bool(db_value.strip())
|
||||
|
||||
|
||||
def _resolve_one(
|
||||
descriptor: FieldDescriptor,
|
||||
db_values: Mapping[str, object],
|
||||
env: Mapping[str, str],
|
||||
empty_db_is_set: bool,
|
||||
) -> tuple[str, str | None, FieldSource]:
|
||||
db_value = db_values.get(descriptor.db_key)
|
||||
if _db_is_set(db_value, empty_db_is_set):
|
||||
return descriptor.field_name, db_value if isinstance(db_value, str) else str(db_value), "db"
|
||||
env_value = env.get(descriptor.env_var)
|
||||
if isinstance(env_value, str) and env_value.strip():
|
||||
return descriptor.field_name, env_value, "env"
|
||||
if descriptor.default is not None:
|
||||
return descriptor.field_name, descriptor.default, "default"
|
||||
return descriptor.field_name, None, "unset"
|
||||
|
||||
|
||||
def resolve_fields(
|
||||
descriptors: Sequence[FieldDescriptor],
|
||||
db_values: Mapping[str, object],
|
||||
env: Mapping[str, str],
|
||||
empty_db_is_set: bool = False,
|
||||
) -> tuple[dict[str, str | None], dict[str, FieldSource]]:
|
||||
"""Resolve every descriptor to (values, provenance).
|
||||
|
||||
Precedence per field: a set stored value wins, else a non-blank process env
|
||||
var, else the descriptor default, else unset. ``empty_db_is_set`` selects
|
||||
how a present-but-empty stored value is read: ``False`` treats it as absent
|
||||
so it falls back to env (SSO, whose clear path also unsets the env var);
|
||||
``True`` treats it as an explicit clear that wins over env (alerting, whose
|
||||
clear path stores "" without unsetting the env var).
|
||||
"""
|
||||
resolved = tuple(_resolve_one(descriptor, db_values, env, empty_db_is_set) for descriptor in descriptors)
|
||||
values = {field_name: value for field_name, value, _ in resolved}
|
||||
provenance = {field_name: source for field_name, _, source in resolved}
|
||||
return values, provenance
|
||||
25
litellm/proxy/config_resolvers/alerting.py
Normal file
25
litellm/proxy/config_resolvers/alerting.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Descriptor tables for the alerting settings surfaced by /get/config/callbacks.
|
||||
|
||||
These reconcile the stored ``environment_variables`` blob (keyed by the
|
||||
uppercase env-var names) with the process environment. SMTP_PORT and SMTP_TLS
|
||||
carry the same effective defaults the mail-send path applies, so the settings
|
||||
page shows the config that mail would actually use rather than a blank.
|
||||
"""
|
||||
|
||||
from litellm.proxy.config_resolvers._descriptors import FieldDescriptor
|
||||
|
||||
EMAIL_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
|
||||
FieldDescriptor("SMTP_HOST", "SMTP_HOST", "SMTP_HOST"),
|
||||
FieldDescriptor("SMTP_PORT", "SMTP_PORT", "SMTP_PORT", default="587"),
|
||||
FieldDescriptor("SMTP_TLS", "SMTP_TLS", "SMTP_TLS", default="True"),
|
||||
FieldDescriptor("SMTP_USERNAME", "SMTP_USERNAME", "SMTP_USERNAME", is_secret=True),
|
||||
FieldDescriptor("SMTP_PASSWORD", "SMTP_PASSWORD", "SMTP_PASSWORD", is_secret=True),
|
||||
FieldDescriptor("SMTP_SENDER_EMAIL", "SMTP_SENDER_EMAIL", "SMTP_SENDER_EMAIL"),
|
||||
FieldDescriptor("TEST_EMAIL_ADDRESS", "TEST_EMAIL_ADDRESS", "TEST_EMAIL_ADDRESS"),
|
||||
FieldDescriptor("EMAIL_LOGO_URL", "EMAIL_LOGO_URL", "EMAIL_LOGO_URL"),
|
||||
FieldDescriptor("EMAIL_SUPPORT_CONTACT", "EMAIL_SUPPORT_CONTACT", "EMAIL_SUPPORT_CONTACT"),
|
||||
)
|
||||
|
||||
SLACK_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
|
||||
FieldDescriptor("SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", "SLACK_WEBHOOK_URL", is_secret=True),
|
||||
)
|
||||
94
litellm/proxy/config_resolvers/sso.py
Normal file
94
litellm/proxy/config_resolvers/sso.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Resolved SSO config object.
|
||||
|
||||
Reconciles the dedicated ``sso_config`` DB row (lowercase, per-value encrypted
|
||||
keys) with the process environment (uppercase env vars) into a typed
|
||||
``SSOConfig`` plus per-field provenance. This is the single source of truth for
|
||||
the SSO field -> env-var mapping, used by both the read-back endpoint and the
|
||||
save endpoint so the two can never drift.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
||||
from litellm.proxy.config_resolvers._descriptors import (
|
||||
FieldDescriptor,
|
||||
FieldSource,
|
||||
resolve_fields,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import (
|
||||
RoleMappings,
|
||||
SSOConfig,
|
||||
TeamMappings,
|
||||
)
|
||||
|
||||
SSO_DESCRIPTORS: tuple[FieldDescriptor, ...] = (
|
||||
FieldDescriptor("google_client_id", "google_client_id", "GOOGLE_CLIENT_ID"),
|
||||
FieldDescriptor("google_client_secret", "google_client_secret", "GOOGLE_CLIENT_SECRET", is_secret=True),
|
||||
FieldDescriptor("microsoft_client_id", "microsoft_client_id", "MICROSOFT_CLIENT_ID"),
|
||||
FieldDescriptor("microsoft_client_secret", "microsoft_client_secret", "MICROSOFT_CLIENT_SECRET", is_secret=True),
|
||||
FieldDescriptor("microsoft_tenant", "microsoft_tenant", "MICROSOFT_TENANT"),
|
||||
FieldDescriptor("generic_client_id", "generic_client_id", "GENERIC_CLIENT_ID"),
|
||||
FieldDescriptor("generic_client_secret", "generic_client_secret", "GENERIC_CLIENT_SECRET", is_secret=True),
|
||||
FieldDescriptor(
|
||||
"generic_authorization_endpoint", "generic_authorization_endpoint", "GENERIC_AUTHORIZATION_ENDPOINT"
|
||||
),
|
||||
FieldDescriptor("generic_token_endpoint", "generic_token_endpoint", "GENERIC_TOKEN_ENDPOINT"),
|
||||
FieldDescriptor("generic_userinfo_endpoint", "generic_userinfo_endpoint", "GENERIC_USERINFO_ENDPOINT"),
|
||||
FieldDescriptor("generic_scope", "generic_scope", "GENERIC_SCOPE", default="openid email profile"),
|
||||
FieldDescriptor("proxy_base_url", "proxy_base_url", "PROXY_BASE_URL"),
|
||||
)
|
||||
|
||||
# Derived from the descriptor table so read (masking) and the field->env mapping
|
||||
# never diverge from the resolver.
|
||||
SSO_SECRET_FIELDS: frozenset[str] = frozenset(d.field_name for d in SSO_DESCRIPTORS if d.is_secret)
|
||||
SSO_FIELD_ENV_VARS: dict[str, str] = {d.field_name: d.env_var for d in SSO_DESCRIPTORS}
|
||||
|
||||
# Structured sub-objects stored on the SSO row that are not simple env-backed
|
||||
# scalars; handled outside the descriptor resolution.
|
||||
_STRUCTURED_KEYS = ("role_mappings", "team_mappings")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedSSOConfig:
|
||||
config: SSOConfig
|
||||
provenance: dict[str, FieldSource]
|
||||
|
||||
|
||||
def _decrypt(raw: Mapping[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
key: (
|
||||
decrypt_value_helper(value=value, key=key, return_original_value=True) if isinstance(value, str) else value
|
||||
)
|
||||
for key, value in raw.items()
|
||||
}
|
||||
|
||||
|
||||
def _parse_role_mappings(data: object) -> RoleMappings | None:
|
||||
# The stored row is JSON, so mappings arrive as a dict (or are absent).
|
||||
return RoleMappings(**data) if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def _parse_team_mappings(data: object) -> TeamMappings | None:
|
||||
return TeamMappings(**data) if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def resolve_sso_config(sso_db_settings: Mapping[str, object] | None, env: Mapping[str, str]) -> ResolvedSSOConfig:
|
||||
"""Resolve the effective SSO config: stored row first, then process env.
|
||||
|
||||
Decryption happens here, once, via the pure ``decrypt_value_helper``; this
|
||||
function never writes ``os.environ`` (unlike the legacy read path). Values
|
||||
are returned unmasked so the login path could consume them; the read-back
|
||||
endpoint is responsible for masking secrets before responding to the UI.
|
||||
"""
|
||||
raw = dict(sso_db_settings) if sso_db_settings else {}
|
||||
decrypted = _decrypt({key: value for key, value in raw.items() if key not in _STRUCTURED_KEYS})
|
||||
values, provenance = resolve_fields(SSO_DESCRIPTORS, decrypted, env)
|
||||
structured = {
|
||||
"user_email": decrypted.get("user_email"),
|
||||
"ui_access_mode": decrypted.get("ui_access_mode"),
|
||||
"role_mappings": _parse_role_mappings(raw.get("role_mappings")),
|
||||
"team_mappings": _parse_team_mappings(raw.get("team_mappings")),
|
||||
}
|
||||
config = SSOConfig(**{**values, **structured})
|
||||
return ResolvedSSOConfig(config=config, provenance=provenance)
|
||||
|
|
@ -304,6 +304,11 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
|||
encrypt_value_helper,
|
||||
)
|
||||
from litellm.proxy.common_utils.html_forms.ui_login import build_ui_login_form
|
||||
from litellm.proxy.config_resolvers import resolve_fields
|
||||
from litellm.proxy.config_resolvers.alerting import (
|
||||
EMAIL_DESCRIPTORS,
|
||||
SLACK_DESCRIPTORS,
|
||||
)
|
||||
from litellm.proxy.common_utils.http_parsing_utils import (
|
||||
_read_request_body,
|
||||
_safe_get_request_headers,
|
||||
|
|
@ -1159,9 +1164,9 @@ _OPENAPI_HTTP_METHODS = {
|
|||
# Credentials surfaced by `/get/config/callbacks` in the alerting block: the
|
||||
# full Slack incoming-webhook URL is itself a credential, and the SMTP
|
||||
# password is a service password. Masked on read so plaintext never reaches
|
||||
# the UI. Kept here at module scope to match the analogous
|
||||
# `_SSO_SENSITIVE_FIELDS` / `_CACHE_SENSITIVE_FIELDS` constants in the SSO
|
||||
# and cache endpoint files.
|
||||
# the UI. Kept here at module scope to match the analogous descriptor
|
||||
# `is_secret` flags in litellm.proxy.config_resolvers and the
|
||||
# `_CACHE_SENSITIVE_FIELDS` constant in the cache endpoint file.
|
||||
_ALERTING_SENSITIVE_VARS: Set[str] = {"SLACK_WEBHOOK_URL", "SMTP_PASSWORD"}
|
||||
|
||||
|
||||
|
|
@ -15491,14 +15496,10 @@ async def get_config(
|
|||
_alerting = _general_settings.get("alerting", [])
|
||||
alerting_data = []
|
||||
if "slack" in _alerting:
|
||||
_slack_vars = [
|
||||
"SLACK_WEBHOOK_URL",
|
||||
]
|
||||
_slack_env_vars = {
|
||||
_var: (value if (value := environment_variables.get(_var)) is not None else os.getenv(_var))
|
||||
for _var in _slack_vars
|
||||
}
|
||||
_slack_env_vars = _apply_alerting_env_role_gate(_slack_env_vars, is_full_admin)
|
||||
_slack_values, _ = resolve_fields(
|
||||
SLACK_DESCRIPTORS, environment_variables, os.environ, empty_db_is_set=True
|
||||
)
|
||||
_slack_env_vars = _apply_alerting_env_role_gate(_slack_values, is_full_admin)
|
||||
|
||||
_alerting_types = proxy_logging_obj.slack_alerting_instance.alert_types
|
||||
_all_alert_types = proxy_logging_obj.slack_alerting_instance._all_possible_alert_types()
|
||||
|
|
@ -15514,19 +15515,8 @@ async def get_config(
|
|||
}
|
||||
)
|
||||
# pass email alerting vars
|
||||
_email_vars = [
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
"SMTP_USERNAME",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_SENDER_EMAIL",
|
||||
"TEST_EMAIL_ADDRESS",
|
||||
"EMAIL_LOGO_URL",
|
||||
"EMAIL_SUPPORT_CONTACT",
|
||||
]
|
||||
_email_env_vars = _apply_alerting_env_role_gate(
|
||||
{_var: environment_variables.get(_var) for _var in _email_vars}, is_full_admin
|
||||
)
|
||||
_email_values, _ = resolve_fields(EMAIL_DESCRIPTORS, environment_variables, os.environ, empty_db_is_set=True)
|
||||
_email_env_vars = _apply_alerting_env_role_gate(_email_values, is_full_admin)
|
||||
|
||||
alerting_data.append(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_keys
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.config_resolvers.sso import (
|
||||
SSO_FIELD_ENV_VARS,
|
||||
SSO_SECRET_FIELDS,
|
||||
resolve_sso_config,
|
||||
)
|
||||
from litellm.repositories.config_repository import ConfigRepository
|
||||
from litellm.repositories.table_repositories import (
|
||||
SSOConfigRepository,
|
||||
|
|
@ -27,16 +32,6 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
|
|||
|
||||
router = APIRouter()
|
||||
|
||||
# SSO secret fields returned by /get/sso_settings. These are masked on read so
|
||||
# the UI can show "(set)" without ever transporting the plaintext OAuth secret
|
||||
# off the server, matching the write-once + masked-on-read contract used for
|
||||
# the HashiCorp Vault config override.
|
||||
_SSO_SENSITIVE_FIELDS: Set[str] = {
|
||||
"google_client_secret",
|
||||
"microsoft_client_secret",
|
||||
"generic_client_secret",
|
||||
}
|
||||
|
||||
# Maps each UIThemeConfig field to the env var the UI branding path reads it
|
||||
# from. /update/ui_theme_settings writes both the stored ui_theme_config and
|
||||
# these env vars, so /get/ui_theme_settings resolves the same env vars to
|
||||
|
|
@ -109,7 +104,8 @@ class SettingsResponse(BaseModel):
|
|||
class SSOSettingsResponse(SettingsResponse):
|
||||
"""Response model for SSO settings"""
|
||||
|
||||
pass
|
||||
provenance: Dict[str, str] = Field(default_factory=dict)
|
||||
"""Per-field source of each value: 'db', 'env', 'default', or 'unset'."""
|
||||
|
||||
|
||||
class InternalUserSettingsResponse(SettingsResponse):
|
||||
|
|
@ -757,7 +753,7 @@ async def get_sso_settings():
|
|||
Returns a structured object with values and descriptions for UI display.
|
||||
"""
|
||||
|
||||
from litellm.proxy.proxy_server import prisma_client, proxy_config
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -765,59 +761,12 @@ async def get_sso_settings():
|
|||
detail={"error": "Database not connected. Please connect a database."},
|
||||
)
|
||||
|
||||
# Get SSO config from dedicated table
|
||||
# Resolve the effective SSO config: the stored row wins, else the process
|
||||
# environment, else each field's default. Unlike the legacy read path this
|
||||
# does not write os.environ; a GET has no business mutating the environment.
|
||||
sso_db_record = await SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"})
|
||||
|
||||
# Initialize with defaults
|
||||
sso_settings_dict = {}
|
||||
|
||||
if sso_db_record and sso_db_record.sso_settings:
|
||||
# Load settings from database
|
||||
sso_settings_dict = dict(sso_db_record.sso_settings)
|
||||
|
||||
role_mappings_data = sso_settings_dict.pop("role_mappings", None)
|
||||
role_mappings = None
|
||||
if role_mappings_data:
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import RoleMappings
|
||||
|
||||
if isinstance(role_mappings_data, dict):
|
||||
role_mappings = RoleMappings(**role_mappings_data)
|
||||
elif isinstance(role_mappings_data, RoleMappings):
|
||||
role_mappings = role_mappings_data
|
||||
|
||||
team_mappings_data = sso_settings_dict.pop("team_mappings", None)
|
||||
team_mappings = None
|
||||
if team_mappings_data:
|
||||
from litellm.types.proxy.management_endpoints.ui_sso import TeamMappings
|
||||
|
||||
if isinstance(team_mappings_data, dict):
|
||||
team_mappings = TeamMappings(**team_mappings_data)
|
||||
elif isinstance(team_mappings_data, TeamMappings):
|
||||
team_mappings = team_mappings_data
|
||||
|
||||
decrypted_sso_settings_dict = proxy_config._decrypt_and_set_db_env_variables(
|
||||
environment_variables=sso_settings_dict
|
||||
)
|
||||
|
||||
# Build SSO config with database values or environment fallback
|
||||
|
||||
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"),
|
||||
role_mappings=role_mappings,
|
||||
team_mappings=team_mappings,
|
||||
)
|
||||
sso_db_settings = dict(sso_db_record.sso_settings) if sso_db_record and sso_db_record.sso_settings else None
|
||||
resolved = resolve_sso_config(sso_db_settings, os.environ)
|
||||
|
||||
# Get the schema for UI display
|
||||
from pydantic import TypeAdapter
|
||||
|
|
@ -826,11 +775,12 @@ async def get_sso_settings():
|
|||
|
||||
# Convert to dict for response, masking OAuth client secrets so plaintext
|
||||
# is never sent to the UI.
|
||||
sso_dict = mask_sensitive_keys(sso_config.model_dump(), _SSO_SENSITIVE_FIELDS)
|
||||
sso_dict = mask_sensitive_keys(resolved.config.model_dump(), set(SSO_SECRET_FIELDS))
|
||||
|
||||
# Add descriptions to the response
|
||||
result = {
|
||||
"values": sso_dict,
|
||||
"provenance": resolved.provenance,
|
||||
"field_schema": {
|
||||
"description": schema.get("description", ""),
|
||||
"properties": {},
|
||||
|
|
@ -881,21 +831,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
|
||||
|
|
@ -924,8 +859,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_ENV_VARS:
|
||||
env_var_name = SSO_FIELD_ENV_VARS[field_name]
|
||||
if value:
|
||||
os.environ[env_var_name] = value
|
||||
else:
|
||||
|
|
@ -975,7 +910,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_ENV_VARS.values())
|
||||
filtered_env_vars = {
|
||||
key: value for key, value in environment_variables.items() if key not in env_vars_to_remove
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,10 @@ class SSOConfig(LiteLLMPydanticObjectBase):
|
|||
default=None,
|
||||
description="User info endpoint URL for generic OAuth provider",
|
||||
)
|
||||
generic_scope: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Space-separated OAuth scopes requested from the generic provider, e.g. 'openid email profile'",
|
||||
)
|
||||
|
||||
# Common settings
|
||||
proxy_base_url: Optional[str] = Field(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
import os
|
||||
|
||||
from litellm.proxy.config_resolvers._descriptors import FieldDescriptor, resolve_fields
|
||||
from litellm.proxy.config_resolvers.sso import (
|
||||
SSO_FIELD_ENV_VARS,
|
||||
SSO_SECRET_FIELDS,
|
||||
resolve_sso_config,
|
||||
)
|
||||
|
||||
_D = (
|
||||
FieldDescriptor("client_id", "client_id", "CLIENT_ID"),
|
||||
FieldDescriptor("scope", "scope", "SCOPE", default="openid"),
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_fields_db_wins_over_env():
|
||||
values, provenance = resolve_fields(_D, {"client_id": "from-db"}, {"CLIENT_ID": "from-env"})
|
||||
assert values["client_id"] == "from-db"
|
||||
assert provenance["client_id"] == "db"
|
||||
|
||||
|
||||
def test_resolve_fields_blank_db_falls_back_to_env():
|
||||
values, provenance = resolve_fields(_D, {"client_id": " "}, {"CLIENT_ID": "from-env"})
|
||||
assert values["client_id"] == "from-env"
|
||||
assert provenance["client_id"] == "env"
|
||||
|
||||
|
||||
def test_resolve_fields_blank_everywhere_falls_to_default():
|
||||
values, provenance = resolve_fields(_D, {}, {"SCOPE": ""})
|
||||
assert values["scope"] == "openid"
|
||||
assert provenance["scope"] == "default"
|
||||
|
||||
|
||||
def test_resolve_fields_unset_everywhere():
|
||||
values, provenance = resolve_fields(_D, {}, {})
|
||||
assert values["client_id"] is None
|
||||
assert provenance["client_id"] == "unset"
|
||||
|
||||
|
||||
def test_resolve_fields_empty_db_absent_by_default_falls_to_env():
|
||||
# SSO semantics: a present-but-empty stored value is absent, so env wins.
|
||||
values, provenance = resolve_fields(_D, {"client_id": ""}, {"CLIENT_ID": "from-env"})
|
||||
assert values["client_id"] == "from-env"
|
||||
assert provenance["client_id"] == "env"
|
||||
|
||||
|
||||
def test_resolve_fields_empty_db_is_explicit_clear_when_flag_set():
|
||||
# Alerting semantics: a present-but-empty stored value is an explicit clear
|
||||
# that must win over a stale env var.
|
||||
values, provenance = resolve_fields(
|
||||
_D, {"client_id": ""}, {"CLIENT_ID": "stale-env"}, empty_db_is_set=True
|
||||
)
|
||||
assert values["client_id"] == ""
|
||||
assert provenance["client_id"] == "db"
|
||||
|
||||
|
||||
def test_sso_descriptor_mapping_is_single_sourced():
|
||||
# The write path and read path both consume this mapping; it must cover every
|
||||
# env-backed SSO field and map to the uppercase env var.
|
||||
assert SSO_FIELD_ENV_VARS["generic_client_id"] == "GENERIC_CLIENT_ID"
|
||||
assert SSO_SECRET_FIELDS == frozenset(
|
||||
{"google_client_secret", "microsoft_client_secret", "generic_client_secret"}
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_sso_config_returns_unmasked_secret_and_provenance():
|
||||
# The resolver hands back plaintext; masking is the endpoint's job. If the
|
||||
# resolver masked, the login path would consume a masked secret and fail.
|
||||
resolved = resolve_sso_config(
|
||||
{"generic_client_secret": "super-secret-value"},
|
||||
{"GENERIC_CLIENT_ID": "env-id"},
|
||||
)
|
||||
assert resolved.config.generic_client_secret == "super-secret-value"
|
||||
assert resolved.provenance["generic_client_secret"] == "db"
|
||||
assert resolved.config.generic_client_id == "env-id"
|
||||
assert resolved.provenance["generic_client_id"] == "env"
|
||||
|
||||
|
||||
def test_resolve_sso_config_parses_structured_mappings():
|
||||
resolved = resolve_sso_config(
|
||||
{
|
||||
"generic_client_id": "id",
|
||||
"role_mappings": {
|
||||
"provider": "generic",
|
||||
"group_claim": "groups",
|
||||
"default_role": "internal_user",
|
||||
"roles": {},
|
||||
},
|
||||
"team_mappings": {"team_ids_jwt_field": "teams"},
|
||||
},
|
||||
{},
|
||||
)
|
||||
assert resolved.config.role_mappings is not None
|
||||
assert resolved.config.role_mappings.group_claim == "groups"
|
||||
assert resolved.config.team_mappings is not None
|
||||
assert resolved.config.team_mappings.team_ids_jwt_field == "teams"
|
||||
|
||||
|
||||
def test_resolve_sso_config_does_not_mutate_os_environ(monkeypatch):
|
||||
# Unlike the legacy read path, resolving must not write os.environ.
|
||||
monkeypatch.delenv("GENERIC_CLIENT_ID", raising=False)
|
||||
before = dict(os.environ)
|
||||
resolve_sso_config({"generic_client_id": "id-from-db"}, os.environ)
|
||||
assert dict(os.environ) == before
|
||||
assert "GENERIC_CLIENT_ID" not in os.environ
|
||||
|
|
@ -1174,6 +1174,113 @@ def test_get_config_returns_email_settings(monkeypatch):
|
|||
assert "*" in variables["SMTP_PASSWORD"]
|
||||
|
||||
|
||||
def _get_email_alert_variables(monkeypatch, config_data):
|
||||
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_settings.return_value = {}
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
|
||||
monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data))
|
||||
|
||||
original_overrides = app.dependency_overrides.copy()
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234"
|
||||
)
|
||||
|
||||
client = TestClient(app)
|
||||
try:
|
||||
response = client.get("/get/config/callbacks")
|
||||
finally:
|
||||
app.dependency_overrides = original_overrides
|
||||
|
||||
assert response.status_code == 200
|
||||
email_alert = next((a for a in response.json()["alerts"] if a["name"] == "email"), None)
|
||||
assert email_alert is not None
|
||||
return email_alert["variables"]
|
||||
|
||||
|
||||
def test_get_config_returns_email_settings_set_only_in_process_env(monkeypatch):
|
||||
"""
|
||||
Regression for LIT-4165.
|
||||
|
||||
SMTP supplied purely as process env vars (helm/terraform, no UI writes) is
|
||||
live at runtime because litellm/proxy/utils.py::send_email resolves every
|
||||
field from os.getenv. The /get/config/callbacks email block only read the
|
||||
config/DB environment_variables overlay though, so those deployments saw an
|
||||
empty Email Server Settings page and could not tell SMTP was configured.
|
||||
The slack block one branch above already fell back to os.getenv.
|
||||
"""
|
||||
smtp_password = "env-only-app-password"
|
||||
monkeypatch.setenv("SMTP_HOST", "smtp.env-host.com")
|
||||
monkeypatch.setenv("SMTP_PORT", "2525")
|
||||
monkeypatch.setenv("SMTP_TLS", "False")
|
||||
monkeypatch.setenv("SMTP_USERNAME", "env-user")
|
||||
monkeypatch.setenv("SMTP_PASSWORD", smtp_password)
|
||||
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@env-host.com")
|
||||
monkeypatch.setenv("TEST_EMAIL_ADDRESS", "admin@env-host.com")
|
||||
|
||||
variables = _get_email_alert_variables(
|
||||
monkeypatch,
|
||||
{
|
||||
"litellm_settings": {},
|
||||
"general_settings": {"alerting": ["email"]},
|
||||
"environment_variables": {},
|
||||
},
|
||||
)
|
||||
|
||||
# Every one of these was None before the fix, despite SMTP working.
|
||||
assert variables["SMTP_HOST"] == "smtp.env-host.com"
|
||||
assert variables["SMTP_PORT"] == "2525"
|
||||
assert variables["SMTP_TLS"] == "False"
|
||||
assert variables["SMTP_USERNAME"] == "env-user"
|
||||
assert variables["SMTP_SENDER_EMAIL"] == "alerts@env-host.com"
|
||||
assert variables["TEST_EMAIL_ADDRESS"] == "admin@env-host.com"
|
||||
|
||||
# An env-sourced secret is masked exactly like a stored one.
|
||||
assert variables["SMTP_PASSWORD"] not in (None, smtp_password)
|
||||
assert "*" in variables["SMTP_PASSWORD"]
|
||||
|
||||
|
||||
def test_get_config_email_settings_prefer_stored_over_process_env(monkeypatch):
|
||||
"""
|
||||
Stored environment_variables win over the process environment, matching the
|
||||
load order in ProxyConfig.get_config, which pushes stored values into
|
||||
os.environ. Only a field with no stored entry falls back to os.getenv.
|
||||
"""
|
||||
monkeypatch.setenv("SMTP_HOST", "smtp.env-host.com")
|
||||
monkeypatch.setenv("SMTP_SENDER_EMAIL", "alerts@env-host.com")
|
||||
|
||||
variables = _get_email_alert_variables(
|
||||
monkeypatch,
|
||||
{
|
||||
"litellm_settings": {},
|
||||
"general_settings": {"alerting": ["email"]},
|
||||
"environment_variables": {"SMTP_HOST": "smtp.stored-host.com"},
|
||||
},
|
||||
)
|
||||
|
||||
assert variables["SMTP_HOST"] == "smtp.stored-host.com"
|
||||
assert variables["SMTP_SENDER_EMAIL"] == "alerts@env-host.com"
|
||||
|
||||
|
||||
def test_get_config_email_settings_absent_everywhere_stay_none(monkeypatch):
|
||||
"""A field set in neither source is reported unset rather than invented."""
|
||||
for var in ("SMTP_HOST", "SMTP_PORT", "SMTP_TLS", "SMTP_USERNAME", "SMTP_PASSWORD", "SMTP_SENDER_EMAIL"):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
variables = _get_email_alert_variables(
|
||||
monkeypatch,
|
||||
{
|
||||
"litellm_settings": {},
|
||||
"general_settings": {"alerting": ["email"]},
|
||||
"environment_variables": {},
|
||||
},
|
||||
)
|
||||
|
||||
assert variables["SMTP_HOST"] is None
|
||||
assert variables["SMTP_PASSWORD"] is None
|
||||
|
||||
|
||||
def test_get_config_returns_slack_webhook(monkeypatch):
|
||||
"""
|
||||
Same double-decryption regression as the email block (issue #19221): the
|
||||
|
|
|
|||
|
|
@ -396,6 +396,146 @@ class TestProxySettingEndpoints:
|
|||
call_args = mock_prisma.db.litellm_ssoconfig.find_unique.call_args
|
||||
assert call_args.kwargs["where"]["id"] == "sso_config"
|
||||
|
||||
def _mock_sso_db_record(self, monkeypatch, sso_settings):
|
||||
"""Point /get/sso_settings at a stored SSO row (or None for no row)."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
if sso_settings is None:
|
||||
mock_db_record = None
|
||||
else:
|
||||
mock_db_record = MagicMock()
|
||||
mock_db_record.sso_settings = sso_settings
|
||||
mock_prisma.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=mock_db_record)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
|
||||
# The resolver decrypts stored values via decrypt_value_helper; make it an
|
||||
# identity so the plaintext fixtures round-trip.
|
||||
monkeypatch.setattr(
|
||||
"litellm.proxy.config_resolvers.sso.decrypt_value_helper",
|
||||
lambda value, key, exception_type="error", return_original_value=False: value,
|
||||
)
|
||||
|
||||
def test_get_sso_settings_falls_back_to_process_env(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""
|
||||
Regression for LIT-4165.
|
||||
|
||||
SSO configured purely as process env vars (helm/terraform, no UI writes)
|
||||
logs users in successfully, because ui_sso.py resolves every setting from
|
||||
os.environ. /get/sso_settings read only the sso_config table though, so
|
||||
the Admin UI showed "not configured" for a working SSO deployment and hid
|
||||
the Edit/Delete controls behind an empty-state placeholder.
|
||||
"""
|
||||
self._mock_sso_db_record(monkeypatch, None)
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
|
||||
monkeypatch.setenv("GENERIC_CLIENT_SECRET", "env-client-secret-value")
|
||||
monkeypatch.setenv("GENERIC_AUTHORIZATION_ENDPOINT", "https://idp.example.com/authorize")
|
||||
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
|
||||
monkeypatch.setenv("GENERIC_USERINFO_ENDPOINT", "https://idp.example.com/userinfo")
|
||||
monkeypatch.setenv("GENERIC_SCOPE", "openid email profile groups")
|
||||
monkeypatch.setenv("PROXY_BASE_URL", "https://gateway.example.com")
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
values = response.json()["values"]
|
||||
|
||||
# Every one of these was None before the fix, despite SSO working.
|
||||
assert values["generic_client_id"] == "env-client-id"
|
||||
assert values["generic_authorization_endpoint"] == "https://idp.example.com/authorize"
|
||||
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
|
||||
assert values["generic_userinfo_endpoint"] == "https://idp.example.com/userinfo"
|
||||
assert values["generic_scope"] == "openid email profile groups"
|
||||
assert values["proxy_base_url"] == "https://gateway.example.com"
|
||||
|
||||
# An env-sourced secret is masked exactly like a stored one.
|
||||
assert values["generic_client_secret"] not in (None, "env-client-secret-value")
|
||||
assert "*" in values["generic_client_secret"]
|
||||
|
||||
def test_get_sso_settings_does_not_mutate_os_environ(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""A GET must not write os.environ. The legacy read path decrypted DB
|
||||
values straight into the environment, so opening the settings page
|
||||
repopulated env and masked any consumer that stopped reading it."""
|
||||
self._mock_sso_db_record(monkeypatch, {"generic_client_id": "db-only-id"})
|
||||
monkeypatch.delenv("GENERIC_CLIENT_ID", raising=False)
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["values"]["generic_client_id"] == "db-only-id"
|
||||
# The DB value must NOT have leaked into the process environment.
|
||||
assert "GENERIC_CLIENT_ID" not in os.environ
|
||||
|
||||
def test_get_sso_settings_prefers_stored_over_process_env(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""A stored value wins; only fields absent from the row fall back to env."""
|
||||
self._mock_sso_db_record(monkeypatch, {"generic_client_id": "stored-client-id"})
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
|
||||
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
values = response.json()["values"]
|
||||
assert values["generic_client_id"] == "stored-client-id"
|
||||
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
|
||||
|
||||
def test_get_sso_settings_blank_stored_value_falls_back_to_process_env(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""
|
||||
Blank means absent. update_sso_settings clears the env var for a blank
|
||||
field, so a blank row entry cannot describe a live setting; os.environ is
|
||||
the effective config and is what the UI must report.
|
||||
"""
|
||||
self._mock_sso_db_record(monkeypatch, {"generic_client_id": " ", "generic_token_endpoint": ""})
|
||||
monkeypatch.setenv("GENERIC_CLIENT_ID", "env-client-id")
|
||||
monkeypatch.setenv("GENERIC_TOKEN_ENDPOINT", "https://idp.example.com/token")
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
values = response.json()["values"]
|
||||
assert values["generic_client_id"] == "env-client-id"
|
||||
assert values["generic_token_endpoint"] == "https://idp.example.com/token"
|
||||
|
||||
def test_get_sso_settings_unset_everywhere_reports_source(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""A field set in neither source is unset (or its effective default),
|
||||
and provenance reports which."""
|
||||
self._mock_sso_db_record(monkeypatch, None)
|
||||
for env_var in (
|
||||
"GENERIC_CLIENT_ID",
|
||||
"GENERIC_CLIENT_SECRET",
|
||||
"GENERIC_TOKEN_ENDPOINT",
|
||||
"GENERIC_SCOPE",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
"MICROSOFT_CLIENT_ID",
|
||||
"PROXY_BASE_URL",
|
||||
):
|
||||
monkeypatch.delenv(env_var, raising=False)
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
values = body["values"]
|
||||
provenance = body["provenance"]
|
||||
assert values["generic_client_id"] is None
|
||||
assert provenance["generic_client_id"] == "unset"
|
||||
assert values["generic_client_secret"] is None
|
||||
assert values["google_client_id"] is None
|
||||
# generic_scope carries the same effective default the login path applies,
|
||||
# so the settings page shows the scope logins would actually request.
|
||||
assert values["generic_scope"] == "openid email profile"
|
||||
assert provenance["generic_scope"] == "default"
|
||||
|
||||
def test_update_sso_settings(self, mock_proxy_config, mock_auth, monkeypatch):
|
||||
"""Test updating the SSO settings to the dedicated database table"""
|
||||
import json
|
||||
|
|
@ -1463,19 +1603,20 @@ class TestProxySettingEndpoints:
|
|||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
|
||||
|
||||
# Mock the decryption method to return decrypted values
|
||||
def mock_decrypt_and_set(environment_variables):
|
||||
return {
|
||||
"google_client_id": "decrypted_google_id",
|
||||
"google_client_secret": "decrypted_google_secret",
|
||||
"microsoft_client_id": "decrypted_microsoft_id",
|
||||
"proxy_base_url": "https://decrypted.example.com",
|
||||
}
|
||||
# The resolver decrypts each stored value via decrypt_value_helper; map
|
||||
# the ciphertext fixtures to their plaintext.
|
||||
decrypted_by_ciphertext = {
|
||||
"encrypted_google_id": "decrypted_google_id",
|
||||
"encrypted_google_secret": "decrypted_google_secret",
|
||||
"encrypted_microsoft_id": "decrypted_microsoft_id",
|
||||
"encrypted_proxy_url": "https://decrypted.example.com",
|
||||
}
|
||||
|
||||
from litellm.proxy.proxy_server import proxy_config
|
||||
def mock_decrypt(value, key, exception_type="error", return_original_value=False):
|
||||
return decrypted_by_ciphertext.get(value, value)
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_config, "_decrypt_and_set_db_env_variables", mock_decrypt_and_set
|
||||
"litellm.proxy.config_resolvers.sso.decrypt_value_helper", mock_decrypt
|
||||
)
|
||||
|
||||
response = client.get("/get/sso_settings")
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export interface SSOSettingsValues {
|
|||
generic_authorization_endpoint: string | null;
|
||||
generic_token_endpoint: string | null;
|
||||
generic_userinfo_endpoint: string | null;
|
||||
generic_scope: string | null;
|
||||
proxy_base_url: string | null;
|
||||
user_email: string | null;
|
||||
ui_access_mode: string | null;
|
||||
|
|
|
|||
|
|
@ -462,6 +462,7 @@ describe("SSOModals", () => {
|
|||
generic_authorization_endpoint: null,
|
||||
generic_token_endpoint: null,
|
||||
generic_userinfo_endpoint: null,
|
||||
generic_scope: null,
|
||||
proxy_base_url: null,
|
||||
user_email: null,
|
||||
sso_provider: null,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Modal, Form, Input, Button as Button2, Select, Checkbox } from "antd";
|
||||
import { Modal, Form, Button as Button2, Select, Checkbox } from "antd";
|
||||
import { Text, TextInput } from "@tremor/react";
|
||||
import { getSSOSettings, updateSSOSettings } from "./networking";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { parseErrorMessage } from "./shared/errorUtils";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { ssoProviderDisplayNames, ssoProviderLogoMap } from "./Settings/AdminSettings/SSOSettings/constants";
|
||||
import { renderProviderFields } from "./Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm";
|
||||
|
||||
interface SSOModalsProps {
|
||||
isAddSSOModalVisible: boolean;
|
||||
|
|
@ -20,82 +21,6 @@ interface SSOModalsProps {
|
|||
ssoConfigured?: boolean; // Add optional prop to indicate if SSO is configured
|
||||
}
|
||||
|
||||
// Define the SSO provider configuration type
|
||||
interface SSOProviderConfig {
|
||||
envVarMap: Record<string, string>;
|
||||
fields: Array<{
|
||||
label: string;
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Define configurations for each SSO provider
|
||||
const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
||||
google: {
|
||||
envVarMap: {
|
||||
google_client_id: "GOOGLE_CLIENT_ID",
|
||||
google_client_secret: "GOOGLE_CLIENT_SECRET",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Google Client ID", name: "google_client_id" },
|
||||
{ label: "Google Client Secret", name: "google_client_secret" },
|
||||
],
|
||||
},
|
||||
microsoft: {
|
||||
envVarMap: {
|
||||
microsoft_client_id: "MICROSOFT_CLIENT_ID",
|
||||
microsoft_client_secret: "MICROSOFT_CLIENT_SECRET",
|
||||
microsoft_tenant: "MICROSOFT_TENANT",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Microsoft Client ID", name: "microsoft_client_id" },
|
||||
{ label: "Microsoft Client Secret", name: "microsoft_client_secret" },
|
||||
{ label: "Microsoft Tenant", name: "microsoft_tenant" },
|
||||
],
|
||||
},
|
||||
okta: {
|
||||
envVarMap: {
|
||||
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",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Generic Client ID", name: "generic_client_id" },
|
||||
{ label: "Generic Client Secret", name: "generic_client_secret" },
|
||||
{
|
||||
label: "Authorization Endpoint",
|
||||
name: "generic_authorization_endpoint",
|
||||
placeholder: "https://your-domain/authorize",
|
||||
},
|
||||
{ label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" },
|
||||
{
|
||||
label: "Userinfo Endpoint",
|
||||
name: "generic_userinfo_endpoint",
|
||||
placeholder: "https://your-domain/userinfo",
|
||||
},
|
||||
],
|
||||
},
|
||||
generic: {
|
||||
envVarMap: {
|
||||
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",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Generic Client ID", name: "generic_client_id" },
|
||||
{ label: "Generic Client Secret", name: "generic_client_secret" },
|
||||
{ label: "Authorization Endpoint", name: "generic_authorization_endpoint" },
|
||||
{ label: "Token Endpoint", name: "generic_token_endpoint" },
|
||||
{ label: "Userinfo Endpoint", name: "generic_userinfo_endpoint" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const SSOModals: React.FC<SSOModalsProps> = ({
|
||||
isAddSSOModalVisible,
|
||||
isInstructionsModalVisible,
|
||||
|
|
@ -266,6 +191,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
generic_authorization_endpoint: null,
|
||||
generic_token_endpoint: null,
|
||||
generic_userinfo_endpoint: null,
|
||||
generic_scope: null,
|
||||
proxy_base_url: null,
|
||||
user_email: null,
|
||||
sso_provider: null,
|
||||
|
|
@ -291,22 +217,6 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
};
|
||||
|
||||
// Helper function to render provider fields
|
||||
const renderProviderFields = (provider: string) => {
|
||||
const config = ssoProviderConfigs[provider];
|
||||
if (!config) return null;
|
||||
|
||||
return config.fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.name}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
rules={[{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }]}
|
||||
>
|
||||
{field.name.includes("client") ? <Input.Password /> : <TextInput placeholder={field.placeholder} />}
|
||||
</Form.Item>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
|
|
@ -540,5 +450,4 @@ const SSOModals: React.FC<SSOModalsProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export { ssoProviderConfigs }; // Export for use in other components
|
||||
export default SSOModals;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Form } from "antd";
|
|||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { renderWithProviders } from "../../../../../../tests/test-utils";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import BaseSSOSettingsForm, { renderProviderFields } from "./BaseSSOSettingsForm";
|
||||
import BaseSSOSettingsForm, { renderProviderFields, ssoProviderConfigs } from "./BaseSSOSettingsForm";
|
||||
|
||||
describe("BaseSSOSettingsForm", () => {
|
||||
afterEach(() => {
|
||||
|
|
@ -285,13 +285,70 @@ describe("renderProviderFields", () => {
|
|||
it("should return fields for okta provider", () => {
|
||||
const result = renderProviderFields("okta");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.length).toBe(5);
|
||||
expect(result?.length).toBe(6);
|
||||
});
|
||||
|
||||
it("should return fields for generic provider", () => {
|
||||
const result = renderProviderFields("generic");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.length).toBe(5);
|
||||
expect(result?.length).toBe(6);
|
||||
});
|
||||
|
||||
it.each(["okta", "generic"])(
|
||||
"renders an optional generic_scope field for %s so editing cannot clear it",
|
||||
(provider) => {
|
||||
const scopeField = ssoProviderConfigs[provider].fields.find((field) => field.name === "generic_scope");
|
||||
expect(scopeField).toBeDefined();
|
||||
expect(scopeField?.required).toBe(false);
|
||||
expect(ssoProviderConfigs[provider].envVarMap.generic_scope).toBe("GENERIC_SCOPE");
|
||||
},
|
||||
);
|
||||
|
||||
it("submits generic_scope untouched, so saving an unrelated edit cannot clear GENERIC_SCOPE", async () => {
|
||||
// update_sso_settings clears the env var for any mapped field its payload
|
||||
// omits, and antd only submits mounted fields. So the Scopes field being
|
||||
// present is what stops an unrelated edit from downgrading a custom scope
|
||||
// to the provider default. Dropping the field from ssoProviderConfigs must
|
||||
// fail here rather than silently in production.
|
||||
const handleSubmit = vi.fn();
|
||||
let form: any;
|
||||
const TestWrapper = () => {
|
||||
const [formInstance] = Form.useForm();
|
||||
form = formInstance;
|
||||
return <BaseSSOSettingsForm form={formInstance} onFormSubmit={handleSubmit} />;
|
||||
};
|
||||
|
||||
renderWithProviders(<TestWrapper />);
|
||||
|
||||
// Mirror EditSSOSettingsModal hydrating the form from the GET response.
|
||||
await act(async () => {
|
||||
form.setFieldsValue({
|
||||
sso_provider: "generic",
|
||||
generic_client_id: "client-id",
|
||||
generic_client_secret: "client-secret",
|
||||
generic_authorization_endpoint: "https://idp.example.com/authorize",
|
||||
generic_token_endpoint: "https://idp.example.com/token",
|
||||
generic_userinfo_endpoint: "https://idp.example.com/userinfo",
|
||||
generic_scope: "openid email profile groups",
|
||||
proxy_base_url: "https://gateway.example.com",
|
||||
user_email: "admin@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
// The admin edits something else entirely and saves.
|
||||
await act(async () => {
|
||||
form.setFieldsValue({ generic_token_endpoint: "https://idp.example.com/token/v2" });
|
||||
form.submit();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handleSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
generic_token_endpoint: "https://idp.example.com/token/v2",
|
||||
generic_scope: "openid email profile groups",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders provider logos in the dropdown and falls back to a letter avatar on load error", async () => {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export interface SSOProviderConfig {
|
|||
label: string;
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +53,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
|
||||
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
|
||||
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
|
||||
generic_scope: "GENERIC_SCOPE",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Generic Client ID", name: "generic_client_id" },
|
||||
|
|
@ -67,6 +69,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
name: "generic_userinfo_endpoint",
|
||||
placeholder: "https://your-domain/userinfo",
|
||||
},
|
||||
{ label: "Scopes", name: "generic_scope", placeholder: "openid email profile", required: false },
|
||||
],
|
||||
},
|
||||
generic: {
|
||||
|
|
@ -76,6 +79,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
|
||||
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
|
||||
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
|
||||
generic_scope: "GENERIC_SCOPE",
|
||||
},
|
||||
fields: [
|
||||
{ label: "Generic Client ID", name: "generic_client_id" },
|
||||
|
|
@ -83,6 +87,7 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
|
|||
{ label: "Authorization Endpoint", name: "generic_authorization_endpoint" },
|
||||
{ label: "Token Endpoint", name: "generic_token_endpoint" },
|
||||
{ label: "Userinfo Endpoint", name: "generic_userinfo_endpoint" },
|
||||
{ label: "Scopes", name: "generic_scope", placeholder: "openid email profile", required: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -97,7 +102,7 @@ export const renderProviderFields = (provider: string) => {
|
|||
key={field.name}
|
||||
label={field.label}
|
||||
name={field.name}
|
||||
rules={[{ required: true, message: `Please enter the ${field.label.toLowerCase()}` }]}
|
||||
rules={[{ required: field.required !== false, message: `Please enter the ${field.label.toLowerCase()}` }]}
|
||||
>
|
||||
{field.name.includes("client") ? <Input.Password /> : <TextInput placeholder={field.placeholder} />}
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export default function SSOSettings() {
|
|||
label: "User Info Endpoint",
|
||||
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint),
|
||||
},
|
||||
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
|
||||
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
|
||||
isTeamMappingsEnabled
|
||||
? {
|
||||
|
|
@ -143,6 +144,7 @@ export default function SSOSettings() {
|
|||
label: "User Info Endpoint",
|
||||
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint),
|
||||
},
|
||||
{ label: "Scopes", render: (values: SSOSettingsValues) => renderSimpleValue(values.generic_scope) },
|
||||
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
|
||||
isTeamMappingsEnabled
|
||||
? {
|
||||
|
|
|
|||
|
|
@ -26,9 +26,17 @@ const EmailSettings: React.FC<EmailSettingsProps> = ({ accessToken, premiumUser,
|
|||
.forEach((alert) => {
|
||||
Object.entries(alert.variables ?? {}).forEach(([key, value]) => {
|
||||
const inputElement = document.querySelector(`input[name="${key}"]`) as HTMLInputElement;
|
||||
if (inputElement && inputElement.value) {
|
||||
updatedVariables[key] = inputElement?.value;
|
||||
if (!inputElement || !inputElement.value) {
|
||||
return;
|
||||
}
|
||||
// Only send fields the admin actually edited. Values rendered from the
|
||||
// server are masked (SMTP_PASSWORD) or sourced from the process
|
||||
// environment, so re-submitting an untouched field would persist a mask
|
||||
// or copy env-managed config into the database.
|
||||
if (inputElement.value === (value == null ? "" : String(value))) {
|
||||
return;
|
||||
}
|
||||
updatedVariables[key] = inputElement.value;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
9
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
9
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -30686,6 +30686,11 @@ export interface components {
|
|||
* @description Generic OAuth Client Secret for SSO authentication
|
||||
*/
|
||||
generic_client_secret?: string | null;
|
||||
/**
|
||||
* Generic Scope
|
||||
* @description Space-separated OAuth scopes requested from the generic provider, e.g. 'openid email profile'
|
||||
*/
|
||||
generic_scope?: string | null;
|
||||
/**
|
||||
* Generic Token Endpoint
|
||||
* @description Token endpoint URL for generic OAuth provider
|
||||
|
|
@ -30750,6 +30755,10 @@ export interface components {
|
|||
field_schema: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Provenance */
|
||||
provenance?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** Values */
|
||||
values: {
|
||||
[key: string]: unknown;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue