diff --git a/litellm/proxy/management_endpoints/scim/scim_errors.py b/litellm/proxy/management_endpoints/scim/scim_errors.py new file mode 100644 index 0000000000..9e09f6e095 --- /dev/null +++ b/litellm/proxy/management_endpoints/scim/scim_errors.py @@ -0,0 +1,13 @@ +from fastapi import HTTPException + + +class ScimUserAlreadyExists(HTTPException): + """ + Exception raised when a user already exists in the database. + """ + def __init__(self, message: str, scim_type: str = "uniqueness"): + super().__init__(status_code=409, detail=message) + self.message = message + self.scim_type = scim_type + self.schemas = ["urn:ietf:params:scim:api:messages:2.0:Error"] + \ No newline at end of file diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index 9b2799cc4a..e1dce38fb8 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -29,9 +29,14 @@ from litellm.proxy._types import ( ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.internal_user_endpoints import new_user +from litellm.proxy.management_endpoints.scim.scim_errors import ScimUserAlreadyExists from litellm.proxy.management_endpoints.scim.scim_transformations import ( ScimTransformations, ) +from litellm.proxy.management_endpoints.scim.utils import ( + _check_user_exists, + _extract_error_message, +) from litellm.proxy.management_endpoints.team_endpoints import new_team from litellm.proxy.utils import _premium_user_check, handle_exception_on_proxy from litellm.types.proxy.management_endpoints.scim_v2 import * @@ -151,7 +156,6 @@ async def get_user( except Exception as e: raise handle_exception_on_proxy(e) - @scim_router.post( "/Users", response_model=SCIMUser, @@ -171,45 +175,47 @@ async def create_user( try: verbose_proxy_logger.debug("SCIM CREATE USER request: %s", user) - # Extract email from SCIM user - user_email = None - if user.emails and len(user.emails) > 0: - user_email = user.emails[0].value - - # Check if user already exists - existing_user = None - if user.userName: - existing_user = await prisma_client.db.litellm_usertable.find_unique( - where={"user_id": user.userName} - ) - - if existing_user: - raise HTTPException( - status_code=409, - detail={"error": f"User already exists with username: {user.userName}"}, - ) - - # Create user in database + + # Extract user data + user_email = user.emails[0].value if user.emails else None user_id = user.userName or str(uuid.uuid4()) - created_user = await new_user( - data=NewUserRequest( - user_id=user_id, - user_email=user_email, - user_alias=user.name.givenName, - teams=[group.value for group in user.groups] if user.groups else None, - metadata={ - "scim_metadata": LiteLLM_UserScimMetadata( - givenName=user.name.givenName, - familyName=user.name.familyName, - ).model_dump() - }, - auto_create_key=False, - ), - ) - scim_user = await ScimTransformations.transform_litellm_user_to_scim_user( - user=created_user - ) - return scim_user + + # Check for duplicate username + if await _check_user_exists(prisma_client, user.userName): + raise ScimUserAlreadyExists( + message=f"User already exists with username: {user.userName}" + ) + + # Attempt to create user + try: + created_user = await new_user( + data=NewUserRequest( + user_id=user_id, + user_email=user_email, + user_alias=user.name.givenName, + teams=[group.value for group in user.groups] if user.groups else None, + metadata={ + "scim_metadata": LiteLLM_UserScimMetadata( + givenName=user.name.givenName, + familyName=user.name.familyName, + ).model_dump() + }, + auto_create_key=False, + ), + ) + except HTTPException as e: + # Convert duplicate email errors to SCIM 409 + if e.status_code == 400 and "already exists" in str(e.detail): + raise ScimUserAlreadyExists( + message=_extract_error_message(e) + ) + raise e + + # Transform and return SCIM user + return await ScimTransformations.transform_litellm_user_to_scim_user(created_user) + + except HTTPException: + raise # Let HTTPExceptions (including ScimUserAlreadyExists) propagate directly except Exception as e: raise handle_exception_on_proxy(e) diff --git a/litellm/proxy/management_endpoints/scim/utils.py b/litellm/proxy/management_endpoints/scim/utils.py new file mode 100644 index 0000000000..e899781b20 --- /dev/null +++ b/litellm/proxy/management_endpoints/scim/utils.py @@ -0,0 +1,20 @@ +from fastapi import HTTPException + + +async def _check_user_exists(prisma_client, user_name: str) -> bool: + """Check if user already exists by username""" + if not user_name: + return False + + existing_user = await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_name} + ) + return existing_user is not None + + +def _extract_error_message(http_exception: HTTPException) -> str: + """Extract error message from HTTPException detail""" + if isinstance(http_exception.detail, dict): + return http_exception.detail.get("error", "User already exists") + return str(http_exception.detail) + diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py new file mode 100644 index 0000000000..8f7ec1f9b4 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -0,0 +1,42 @@ +from unittest.mock import AsyncMock + +import pytest + +from litellm.proxy.management_endpoints.scim.scim_errors import ScimUserAlreadyExists +from litellm.proxy.management_endpoints.scim.scim_v2 import create_user +from litellm.types.proxy.management_endpoints.scim_v2 import ( + SCIMUser, + SCIMUserEmail, + SCIMUserName, +) + + +@pytest.mark.asyncio +async def test_create_user_existing_user_conflict(mocker): + """If a user already exists, create_user should raise ScimUserAlreadyExists""" + + scim_user = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + userName="existing-user", + name=SCIMUserName(familyName="User", givenName="Existing"), + emails=[SCIMUserEmail(value="existing@example.com")], + ) + + mock_prisma = mocker.MagicMock() + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._check_user_exists", + AsyncMock(return_value=True), + ) + mocked_new_user = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.new_user", + AsyncMock(), + ) + + with pytest.raises(ScimUserAlreadyExists) as exc_info: + await create_user(user=scim_user) + + assert exc_info.value.status_code == 409 + assert "existing-user" in exc_info.value.message + mocked_new_user.assert_not_called()