diff --git a/docs/my-website/docs/proxy/admin_ui_sso.md b/docs/my-website/docs/proxy/admin_ui_sso.md index 0438c264685..dba563a327b 100644 --- a/docs/my-website/docs/proxy/admin_ui_sso.md +++ b/docs/my-website/docs/proxy/admin_ui_sso.md @@ -130,6 +130,17 @@ GENERIC_INCLUDE_CLIENT_ID = "false" # some providers enforce that the client_id GENERIC_SCOPE = "openid profile email" # default scope openid is sometimes not enough to retrieve basic user info like first_name and last_name located in profile scope ``` +**Assigning User Roles via SSO** + +Use `GENERIC_USER_ROLE_ATTRIBUTE` to specify which attribute in the SSO token contains the user's role. The role value must be one of the following supported LiteLLM roles: + +- `proxy_admin` - Admin over the platform +- `proxy_admin_viewer` - Can login, view all keys, view all spend (read-only) +- `internal_user` - Can login, view/create/delete their own keys, view their spend +- `internal_user_view_only` - Can login, view their own keys, view their own spend + +Nested attribute paths are supported (e.g., `claims.role` or `attributes.litellm_role`). + - Set Redirect URI, if your provider requires it - Set a redirect url = `/sso/callback` ```shell diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index fea797d9cc6..d1db21a2706 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -265,6 +265,10 @@ def generic_response_convertor( "GENERIC_USER_PROVIDER_ATTRIBUTE", "provider" ) + generic_user_role_attribute_name = os.getenv( + "GENERIC_USER_ROLE_ATTRIBUTE", "role" + ) + verbose_proxy_logger.debug( f" generic_user_id_attribute_name: {generic_user_id_attribute_name}\n generic_user_email_attribute_name: {generic_user_email_attribute_name}" ) @@ -277,6 +281,17 @@ def generic_response_convertor( team_ids = jwt_handler.get_team_ids_from_jwt(cast(dict, response)) all_teams.extend(team_ids) + # Extract user role from SSO response + user_role_from_sso = get_nested_value(response, generic_user_role_attribute_name) + user_role: Optional[LitellmUserRoles] = None + if user_role_from_sso is not None: + role = get_litellm_user_role(user_role_from_sso) + if role is not None: + user_role = role + verbose_proxy_logger.debug( + f"Found valid LitellmUserRoles '{role.value}' from SSO attribute '{generic_user_role_attribute_name}'" + ) + return CustomOpenID( id=get_nested_value(response, generic_user_id_attribute_name), display_name=get_nested_value( @@ -287,7 +302,7 @@ def generic_response_convertor( last_name=get_nested_value(response, generic_user_last_name_attribute_name), provider=get_nested_value(response, generic_provider_attribute_name), team_ids=all_teams, - user_role=None, + user_role=user_role, ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 710d27e072c..500fc67de89 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -2438,6 +2438,70 @@ class TestGenericResponseConvertorNestedAttributes: assert result.display_name == "user-sub-123" # Top-level attribute works +class TestGenericResponseConvertorUserRole: + """Test generic_response_convertor user role extraction from SSO token""" + + def test_generic_response_convertor_extracts_valid_user_role(self): + """ + Test that generic_response_convertor extracts a valid LiteLLM user role + from the SSO token using the GENERIC_USER_ROLE_ATTRIBUTE env var. + """ + from litellm.proxy._types import LitellmUserRoles + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + sso_response = { + "preferred_username": "testuser", + "email": "test@example.com", + "sub": "Test User", + "role": "proxy_admin", + } + + with patch.dict( + os.environ, + {"GENERIC_USER_ROLE_ATTRIBUTE": "role"}, + ): + result = generic_response_convertor( + response=sso_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + ) + + assert isinstance(result, CustomOpenID) + assert result.user_role == LitellmUserRoles.PROXY_ADMIN + + def test_generic_response_convertor_ignores_invalid_user_role(self): + """ + Test that generic_response_convertor ignores invalid role values + and sets user_role to None. + """ + from litellm.proxy.management_endpoints.ui_sso import generic_response_convertor + + mock_jwt_handler = MagicMock(spec=JWTHandler) + mock_jwt_handler.get_team_ids_from_jwt.return_value = [] + + sso_response = { + "preferred_username": "testuser", + "email": "test@example.com", + "role": "invalid_role_value", + } + + with patch.dict( + os.environ, + {"GENERIC_USER_ROLE_ATTRIBUTE": "role"}, + ): + result = generic_response_convertor( + response=sso_response, + jwt_handler=mock_jwt_handler, + sso_jwt_handler=None, + ) + + assert isinstance(result, CustomOpenID) + assert result.user_role is None + + class TestGetGenericSSORedirectParams: """Test _get_generic_sso_redirect_params state parameter priority handling"""