Skip to content

Commit e2fc225

Browse files
authored
[Feat] SSO - Ensure role from SSO provider is used when a user is inserted onto LiteLLM (#16794)
* test_apply_user_info_values_sso_role_takes_precedence * fix SSO
1 parent 97bb899 commit e2fc225

File tree

3 files changed

+112
-6
lines changed

3 files changed

+112
-6
lines changed

litellm/model_prices_and_context_window_backup.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13084,7 +13084,7 @@
1308413084
"supports_audio_output": false,
1308513085
"supports_function_calling": true,
1308613086
"supports_response_schema": true,
13087-
"supports_system_messages": true,
13087+
"supports_system_messages": false,
1308813088
"supports_tool_choice": true,
1308913089
"supports_vision": true
1309013090
},

litellm/proxy/management_endpoints/ui_sso.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,11 @@
6262
has_admin_ui_access,
6363
)
6464
from litellm.proxy.management_endpoints.team_endpoints import new_team, team_member_add
65-
from litellm.proxy.management_endpoints.types import CustomOpenID, get_litellm_user_role
65+
from litellm.proxy.management_endpoints.types import (
66+
CustomOpenID,
67+
get_litellm_user_role,
68+
is_valid_litellm_user_role,
69+
)
6670
from litellm.proxy.utils import (
6771
PrismaClient,
6872
ProxyLogging,
@@ -554,6 +558,20 @@ async def get_user_info_from_db(
554558

555559
return None
556560

561+
def _should_use_role_from_sso_response(sso_role: Optional[str]) -> bool:
562+
"""returns true if SSO upsert should use the 'role' defined on the SSO response"""
563+
if sso_role is None:
564+
return False
565+
566+
if not is_valid_litellm_user_role(sso_role):
567+
verbose_proxy_logger.debug(
568+
f"SSO role '{sso_role}' is not a valid LiteLLM user role. "
569+
"Ignoring role from SSO response. See LitellmUserRoles enum for valid roles."
570+
)
571+
return False
572+
return True
573+
574+
557575

558576
def apply_user_info_values_to_sso_user_defined_values(
559577
user_info: Optional[Union[LiteLLM_UserTable, NewUserResponse]],
@@ -564,12 +582,22 @@ def apply_user_info_values_to_sso_user_defined_values(
564582
if user_info is not None and user_info.user_id is not None:
565583
user_defined_values["user_id"] = user_info.user_id
566584

567-
# Check if user_role already exists in user_defined_values (from JWT/SSO response)
568-
if user_defined_values.get("user_role") is None:
585+
# SSO role takes precedence - only use DB role if SSO didn't provide one
586+
# This ensures SSO is the authoritative source for user roles
587+
sso_role = user_defined_values.get("user_role")
588+
db_role = user_info.user_role if user_info else None
589+
590+
if _should_use_role_from_sso_response(sso_role):
591+
# SSO provided a valid role, keep it and log that we're using it
592+
verbose_proxy_logger.info(f"Using SSO role: {sso_role} (DB role was: {db_role})")
593+
else:
594+
# SSO didn't provide a valid role, fall back to DB role or default
569595
if user_info is None or user_info.user_role is None:
570-
user_defined_values["user_role"] = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
596+
user_defined_values["user_role"] = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value
597+
verbose_proxy_logger.debug("No SSO or DB role found, using default: INTERNAL_USER_VIEW_ONLY")
571598
else:
572599
user_defined_values["user_role"] = user_info.user_role
600+
verbose_proxy_logger.debug(f"Using DB role: {user_info.user_role}")
573601

574602
# Preserve the user's existing models from the database
575603
if user_info is not None and hasattr(user_info, "models") and user_info.models:
@@ -1540,7 +1568,15 @@ def _get_user_email_and_id_from_result(
15401568
},
15411569
)
15421570

1543-
# generic client id
1571+
# Extract user_role from result (works for all SSO providers)
1572+
if result is not None:
1573+
_user_role = getattr(result, "user_role", None)
1574+
if _user_role is not None:
1575+
# Convert enum to string if needed
1576+
user_role = _user_role.value if isinstance(_user_role, LitellmUserRoles) else _user_role
1577+
verbose_proxy_logger.debug(f"Extracted user_role from SSO result: {user_role}")
1578+
1579+
# generic client id - override with custom attribute name if specified
15441580
if generic_client_id is not None and result is not None:
15451581
generic_user_role_attribute_name = os.getenv(
15461582
"GENERIC_USER_ROLE_ATTRIBUTE", "role"

tests/test_litellm/proxy/management_endpoints/test_ui_sso.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,6 +533,75 @@ def test_apply_user_info_values_to_sso_user_defined_values_with_models():
533533
assert sso_user_defined_values["models"] == ["no-default-models"]
534534

535535

536+
def test_apply_user_info_values_sso_role_takes_precedence():
537+
"""
538+
Test that SSO role takes precedence over DB role.
539+
540+
When Microsoft SSO returns a user_role, it should be used instead of the role stored in the database.
541+
This ensures SSO is the authoritative source for user roles.
542+
"""
543+
from litellm.proxy._types import LiteLLM_UserTable, SSOUserDefinedValues
544+
from litellm.proxy.management_endpoints.ui_sso import (
545+
apply_user_info_values_to_sso_user_defined_values,
546+
)
547+
548+
user_info = LiteLLM_UserTable(
549+
user_id="123",
550+
user_email="[email protected]",
551+
user_role="internal_user_viewer",
552+
models=["model-1"],
553+
)
554+
555+
user_defined_values: SSOUserDefinedValues = {
556+
"models": [],
557+
"user_id": "456",
558+
"user_email": "[email protected]",
559+
"user_role": "proxy_admin_viewer",
560+
"max_budget": None,
561+
"budget_duration": None,
562+
}
563+
564+
sso_user_defined_values = apply_user_info_values_to_sso_user_defined_values(
565+
user_info=user_info,
566+
user_defined_values=user_defined_values,
567+
)
568+
569+
assert sso_user_defined_values is not None
570+
assert sso_user_defined_values["user_id"] == "123"
571+
assert sso_user_defined_values["user_role"] == "proxy_admin_viewer"
572+
assert sso_user_defined_values["models"] == ["model-1"]
573+
574+
575+
def test_get_user_email_and_id_extracts_microsoft_role():
576+
"""
577+
Test that _get_user_email_and_id_from_result extracts user_role from Microsoft SSO.
578+
579+
This ensures Microsoft SSO roles (from app_roles in id_token) are properly
580+
extracted and converted from enum to string.
581+
"""
582+
from litellm.proxy._types import LitellmUserRoles
583+
from litellm.proxy.management_endpoints.types import CustomOpenID
584+
from litellm.proxy.management_endpoints.ui_sso import SSOAuthenticationHandler
585+
586+
result = CustomOpenID(
587+
id="test-user-id",
588+
589+
display_name="Test User",
590+
provider="microsoft",
591+
team_ids=["team-1"],
592+
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
593+
)
594+
595+
parsed = SSOAuthenticationHandler._get_user_email_and_id_from_result(
596+
result=result,
597+
generic_client_id=None,
598+
)
599+
600+
assert parsed.get("user_email") == "[email protected]"
601+
assert parsed.get("user_id") == "test-user-id"
602+
assert parsed.get("user_role") == "proxy_admin_viewer"
603+
604+
536605
@pytest.mark.asyncio
537606
async def test_get_user_info_from_db():
538607
"""
@@ -2068,6 +2137,7 @@ def test_process_sso_jwt_access_token_empty_team_ids_from_jwt(
20682137
async def test_get_ui_settings_includes_api_doc_base_url():
20692138
"""Ensure the UI settings endpoint surfaces the optional API doc override."""
20702139
from fastapi import Request
2140+
20712141
from litellm.proxy.management_endpoints.ui_sso import get_ui_settings
20722142

20732143
mock_request = Request(

0 commit comments

Comments
 (0)