Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/config_resolvers/sso.py: 100%

27 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-10 12:01 +0000

1"""Resolved SSO config object. 

2 

3Reconciles the dedicated ``sso_config`` DB row (lowercase, per-value encrypted 

4keys) with the process environment (uppercase env vars) into a typed 

5``SSOConfig`` plus per-field provenance. This is the single source of truth for 

6the SSO field -> env-var mapping, used by both the read-back endpoint and the 

7save endpoint so the two can never drift. 

8""" 

9 

10from collections.abc import Mapping 

11from dataclasses import dataclass 

12from typing import Final 

13 

14from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper 

15from litellm.proxy.config_resolvers._descriptors import ( 

16 FieldDescriptor, 

17 FieldSource, 

18 resolve_fields, 

19) 

20from litellm.types.proxy.management_endpoints.ui_sso import ( 

21 RoleMappings, 

22 SSOConfig, 

23 TeamMappings, 

24) 

25 

26SSO_DESCRIPTORS: Final[tuple[FieldDescriptor, ...]] = ( 

27 FieldDescriptor("google_client_id", "google_client_id", "GOOGLE_CLIENT_ID"), 

28 FieldDescriptor("google_client_secret", "google_client_secret", "GOOGLE_CLIENT_SECRET", is_secret=True), 

29 FieldDescriptor("microsoft_client_id", "microsoft_client_id", "MICROSOFT_CLIENT_ID"), 

30 FieldDescriptor("microsoft_client_secret", "microsoft_client_secret", "MICROSOFT_CLIENT_SECRET", is_secret=True), 

31 FieldDescriptor("microsoft_tenant", "microsoft_tenant", "MICROSOFT_TENANT"), 

32 FieldDescriptor("generic_client_id", "generic_client_id", "GENERIC_CLIENT_ID"), 

33 FieldDescriptor("generic_client_secret", "generic_client_secret", "GENERIC_CLIENT_SECRET", is_secret=True), 

34 FieldDescriptor( 

35 "generic_authorization_endpoint", "generic_authorization_endpoint", "GENERIC_AUTHORIZATION_ENDPOINT" 

36 ), 

37 FieldDescriptor("generic_token_endpoint", "generic_token_endpoint", "GENERIC_TOKEN_ENDPOINT"), 

38 FieldDescriptor("generic_userinfo_endpoint", "generic_userinfo_endpoint", "GENERIC_USERINFO_ENDPOINT"), 

39 FieldDescriptor("generic_scope", "generic_scope", "GENERIC_SCOPE", default="openid email profile"), 

40 FieldDescriptor("saml_idp_metadata_url", "saml_idp_metadata_url", "SAML_IDP_METADATA_URL"), 

41 FieldDescriptor("saml_idp_metadata_xml", "saml_idp_metadata_xml", "SAML_IDP_METADATA_XML"), 

42 FieldDescriptor("saml_sp_entity_id", "saml_sp_entity_id", "SAML_SP_ENTITY_ID"), 

43 FieldDescriptor("saml_allow_unsolicited", "saml_allow_unsolicited", "SAML_ALLOW_UNSOLICITED"), 

44 FieldDescriptor("proxy_base_url", "proxy_base_url", "PROXY_BASE_URL"), 

45) 

46 

47# Derived from the descriptor table so read (masking) and the field->env mapping 

48# never diverge from the resolver. 

49SSO_SECRET_FIELDS: Final[frozenset[str]] = frozenset(d.field_name for d in SSO_DESCRIPTORS if d.is_secret) 

50SSO_FIELD_ENV_VARS: Final[dict[str, str]] = {d.field_name: d.env_var for d in SSO_DESCRIPTORS} 

51 

52# Structured sub-objects stored on the SSO row that are not simple env-backed 

53# scalars; handled outside the descriptor resolution. 

54_STRUCTURED_KEYS: Final = ("role_mappings", "team_mappings") 

55 

56 

57@dataclass(frozen=True, slots=True) 

58class ResolvedSSOConfig: 

59 config: SSOConfig 

60 provenance: dict[str, FieldSource] 

61 

62 

63def _decrypt(raw: Mapping[str, object]) -> dict[str, object]: 

64 return { 

65 key: ( 

66 decrypt_value_helper(value=value, key=key, return_original_value=True) if isinstance(value, str) else value 

67 ) 

68 for key, value in raw.items() 

69 } 

70 

71 

72def _parse_role_mappings(data: object) -> RoleMappings | None: 

73 # The stored row is JSON, so mappings arrive as a dict (or are absent). 

74 return RoleMappings(**data) if isinstance(data, dict) else None 

75 

76 

77def _parse_team_mappings(data: object) -> TeamMappings | None: 

78 return TeamMappings(**data) if isinstance(data, dict) else None 

79 

80 

81def resolve_sso_config(sso_db_settings: Mapping[str, object] | None, env: Mapping[str, str]) -> ResolvedSSOConfig: 

82 """Resolve the effective SSO config: stored row first, then process env. 

83 

84 Decryption happens here, once, via the pure ``decrypt_value_helper``; this 

85 function never writes ``os.environ`` (unlike the legacy read path). Values 

86 are returned unmasked so the login path could consume them; the read-back 

87 endpoint is responsible for masking secrets before responding to the UI. 

88 """ 

89 raw: Final = dict(sso_db_settings) if sso_db_settings else {} 

90 decrypted: Final = _decrypt({key: value for key, value in raw.items() if key not in _STRUCTURED_KEYS}) 

91 values, provenance = resolve_fields(SSO_DESCRIPTORS, decrypted, env) 

92 structured: Final = { 

93 "user_email": decrypted.get("user_email"), 

94 "ui_access_mode": decrypted.get("ui_access_mode"), 

95 "role_mappings": _parse_role_mappings(raw.get("role_mappings")), 

96 "team_mappings": _parse_team_mappings(raw.get("team_mappings")), 

97 } 

98 config: Final = SSOConfig(**{**values, **structured}) 

99 return ResolvedSSOConfig(config=config, provenance=provenance)