diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index e48f68a2d0a..66c2e54f5c6 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -1294,7 +1294,7 @@ async def _create_user_if_not_exists(user_id: str, created_via: str = "scim_grou if litellm.default_internal_user_params: default_role = litellm.default_internal_user_params.get("user_role") - new_user_request: Final = NewUserRequest( + new_user_request: Final = SCIMNewUserRequest( user_id=user_id, user_email=user_id, # We don't have email from group membership user_alias=None, @@ -1873,7 +1873,7 @@ async def create_user( admin_group: Final = await _get_scim_admin_group() resolved_role: Final = _resolve_scim_user_role(user.groups or [], admin_group, default_role) - new_user_request: Final = NewUserRequest( + new_user_request: Final = SCIMNewUserRequest( user_id=user_id, user_email=user_data["user_email"], user_alias=user_data["user_alias"], diff --git a/litellm/types/proxy/management_endpoints/scim_v2.py b/litellm/types/proxy/management_endpoints/scim_v2.py index fadb309e0cd..72a39c61967 100644 --- a/litellm/types/proxy/management_endpoints/scim_v2.py +++ b/litellm/types/proxy/management_endpoints/scim_v2.py @@ -13,6 +13,7 @@ from pydantic import ( from pydantic_core.core_schema import SerializerFunctionWrapHandler from litellm.types.llms.base import LiteLLMBaseModel +from litellm.types.proxy.management_endpoints.user_requests import NewUserRequest SCIM_ENTERPRISE_USER_SCHEMA: Final = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" SCIM_ENTERPRISE_METADATA_KEY: Final = "scim_enterprise" @@ -26,6 +27,14 @@ _SCIM_SERIALIZED_FIELDS_ADAPTER: Final = TypeAdapter( ) +class SCIMNewUserRequest(NewUserRequest): + """Preserve opaque directory IDs when provisioning SCIM users and group members.""" + + model_config = ConfigDict(frozen=True) + + user_id: str | None = None + + class LiteLLM_UserScimMetadata(LiteLLMBaseModel): """ Scim metadata stored in LiteLLM_UserTable.metadata diff --git a/tests/unit/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/unit/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index 180362f45f1..94e0f91ecd1 100644 --- a/tests/unit/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/unit/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -411,10 +411,11 @@ async def test_create_user_without_groups_defers_to_default_team(mocker: MockerF @pytest.mark.asyncio -async def test_create_user_with_groups_keeps_idp_teams(mocker: MockerFixture, monkeypatch): +@pytest.mark.parametrize("user_id", ["new-user", " employee-42 "]) +async def test_create_user_with_groups_keeps_idp_teams(mocker: MockerFixture, monkeypatch, user_id: str): scim_user = SCIMUser( schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], - userName="new-user", + userName=user_id, emails=[SCIMUserEmail(value="new@example.com")], groups=[SCIMUserGroup(value="idp-team")], ) @@ -428,10 +429,12 @@ async def test_create_user_with_groups_keeps_idp_teams(mocker: MockerFixture, mo await create_user(user=scim_user) assert new_user_mock.call_args.kwargs["data"].teams == ["idp-team"] + assert new_user_mock.call_args.kwargs["data"].user_id == user_id @pytest.mark.asyncio -async def test_create_user_if_not_exists_defers_to_default_team(mocker: MockerFixture, monkeypatch): +@pytest.mark.parametrize("user_id", ["group-user", " employee-42 "]) +async def test_create_user_if_not_exists_defers_to_default_team(mocker: MockerFixture, monkeypatch, user_id: str): monkeypatch.setattr( "litellm.default_internal_user_params", {"teams": [{"team_id": "default-team", "max_budget_in_team": 25}]}, @@ -439,14 +442,15 @@ async def test_create_user_if_not_exists_defers_to_default_team(mocker: MockerFi ) new_user_mock = mocker.patch( # test-quality-ok: new_user is imported inside the helper, not injectable "litellm.proxy.management_endpoints.internal_user_endpoints.new_user", - AsyncMock(return_value=NewUserResponse(user_id="group-user", key="k")), + AsyncMock(return_value=NewUserResponse(user_id=user_id, key="k")), ) - created = await _create_user_if_not_exists(user_id="group-user") + created = await _create_user_if_not_exists(user_id=user_id) assert created is not None assert new_user_mock.call_args.kwargs["data"].teams is None assert new_user_mock.call_args.kwargs["user_api_key_dict"] == UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN) + assert new_user_mock.call_args.kwargs["data"].user_id == user_id @pytest.mark.asyncio