From f08ded532463f3863af05b17c72a3b4ce3178a1a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:12:49 +0000 Subject: [PATCH] fix(ui_sso): select most permissive role when SSO payload has multiple app_roles --- litellm/proxy/management_endpoints/types.py | 34 +++++++++++++++++++ litellm/proxy/management_endpoints/ui_sso.py | 13 ++++--- .../test_entraid_app_roles.py | 31 ++++++++++++++++- 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/management_endpoints/types.py b/litellm/proxy/management_endpoints/types.py index 295c2ad50b3..7c605399781 100644 --- a/litellm/proxy/management_endpoints/types.py +++ b/litellm/proxy/management_endpoints/types.py @@ -53,6 +53,40 @@ def get_litellm_user_role(role_str) -> Optional[LitellmUserRoles]: return None +LITELLM_USER_ROLE_HIERARCHY: tuple[LitellmUserRoles, ...] = ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.ORG_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.TEAM, + LitellmUserRoles.CUSTOMER, +) + + +def get_most_permissive_litellm_user_role( + role_strs: list[str], +) -> Optional[LitellmUserRoles]: + """ + Given a list of role strings from an SSO payload, return the most permissive + valid LitellmUserRoles according to LITELLM_USER_ROLE_HIERARCHY. + + Some identity providers (e.g. Microsoft Entra with access packages) assign a + user multiple app_roles at once. Selecting the first array entry is arbitrary, + so pick the highest-privilege role instead. + + Args: + role_strs: Role strings from the SSO payload (e.g. ["internal_user", "proxy_admin"]) + + Returns: + The most permissive valid LitellmUserRoles, or None if none are valid + """ + valid_roles = frozenset( + role for role in (get_litellm_user_role(role_str) for role_str in role_strs) if role is not None + ) + return next((role for role in LITELLM_USER_ROLE_HIERARCHY if role in valid_roles), None) + + class CustomOpenID(OpenID): team_ids: List[str] user_role: Optional[LitellmUserRoles] = None diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 0475566192e..7227ba04f6c 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -102,6 +102,7 @@ from litellm.proxy.management_endpoints.team_endpoints import new_team, team_mem from litellm.proxy.management_endpoints.types import ( CustomOpenID, get_litellm_user_role, + get_most_permissive_litellm_user_role, is_valid_litellm_user_role, ) from litellm.proxy.utils import ( @@ -3848,13 +3849,11 @@ class MicrosoftSSOHandler: # Combine groups and app roles user_role: Optional[LitellmUserRoles] = None if app_roles: - # Check if any app role is a valid LitellmUserRoles - for role_str in app_roles: - role = get_litellm_user_role(role_str) - if role is not None: - user_role = role - verbose_proxy_logger.debug(f"Found valid LitellmUserRoles '{role.value}' in app_roles") - break + user_role = get_most_permissive_litellm_user_role(app_roles) + if user_role is not None: + verbose_proxy_logger.debug( + f"Selected most permissive LitellmUserRoles '{user_role.value}' from app_roles: {app_roles}" + ) verbose_proxy_logger.debug(f"Combined team_ids (groups + app roles): {user_team_ids}") diff --git a/tests/test_litellm/proxy/management_endpoints/test_entraid_app_roles.py b/tests/test_litellm/proxy/management_endpoints/test_entraid_app_roles.py index 2ce36b73de0..c5a21d31c2f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_entraid_app_roles.py +++ b/tests/test_litellm/proxy/management_endpoints/test_entraid_app_roles.py @@ -1,7 +1,11 @@ import jwt +import pytest from litellm.proxy.management_endpoints.ui_sso import MicrosoftSSOHandler -from litellm.proxy.management_endpoints.types import get_litellm_user_role +from litellm.proxy.management_endpoints.types import ( + get_litellm_user_role, + get_most_permissive_litellm_user_role, +) from litellm.proxy._types import LitellmUserRoles @@ -89,3 +93,28 @@ def test_defaults_to_internal_user_viewer_when_no_role(): # Default role would be internal_user_viewer default_role = LitellmUserRoles.INTERNAL_USER_VIEW_ONLY assert default_role.value == "internal_user_viewer" + + +@pytest.mark.parametrize( + "app_roles, expected", + [ + # Regression for #33434: the most permissive role wins regardless of + # its position in the array, instead of always picking the first entry. + (["internal_user", "proxy_admin"], LitellmUserRoles.PROXY_ADMIN), + (["proxy_admin", "internal_user"], LitellmUserRoles.PROXY_ADMIN), + ( + ["internal_user_viewer", "internal_user", "proxy_admin_viewer"], + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ), + (["org_admin", "proxy_admin_viewer"], LitellmUserRoles.ORG_ADMIN), + # Unknown roles are ignored; the single valid one is selected. + (["some_custom_role", "internal_user"], LitellmUserRoles.INTERNAL_USER), + (["PROXY_ADMIN", "internal_user"], LitellmUserRoles.PROXY_ADMIN), + (["internal_user"], LitellmUserRoles.INTERNAL_USER), + # No valid roles at all. + (["some_custom_role", "another_unknown_role"], None), + ([], None), + ], +) +def test_selects_most_permissive_app_role(app_roles, expected): + assert get_most_permissive_litellm_user_role(app_roles) == expected