fix(scim): preserve opaque IDs during user provisioning

This commit is contained in:
Mrinal Chanshetty 2026-11-03 21:43:11 -08:00
parent 134d837412
commit c80cce69b4
3 changed files with 20 additions and 7 deletions

View file

@ -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"],

View file

@ -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

View file

@ -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