From b25496efea77bc7d2e95be6cbda4ebef3a8a510f Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Sat, 20 Jun 2026 14:24:34 -0700 Subject: [PATCH] feat(scim): ingest enterprise extension attributes into user metadata Map the SCIM enterprise extension block (urn:ietf:params:scim:schemas:extension:enterprise:2.0:User) onto SCIMUser so create and PUT persist employeeNumber, costCenter, organization, division, department, and manager into LiteLLM_UserTable.metadata under scim_enterprise, and round-trip them back out on read. This lets financial reporting group spend by fields like cost center and department. The enterprise block holds directory-only HR attributes, so it is kept out of the generic user management responses (/user/info, /v2/user/info, and /user/list), which non-proxy-admin callers such as team and org admins can use to read other users. The data still lands in metadata for reporting and still round-trips through the SCIM read endpoints, which build their response from the user row directly. Resolves LIT-3617 --- .../internal_user_endpoints.py | 25 +++- .../scim/scim_transformations.py | 11 +- .../management_endpoints/scim/scim_v2.py | 17 ++- .../proxy/management_endpoints/scim_v2.py | 51 ++++++- .../scim/test_scim_transformations.py | 74 ++++++++++ .../scim/test_scim_v2_endpoints.py | 54 +++++++ .../test_internal_user_endpoints.py | 138 ++++++++++++++++++ 7 files changed, 364 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index ba7013570fe..c3ddf4deb1a 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -57,6 +57,9 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) +from litellm.types.proxy.management_endpoints.scim_v2 import ( + SCIM_ENTERPRISE_METADATA_KEY, +) from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( BulkUpdateUserRequest, BulkUpdateUserResponse, @@ -719,6 +722,17 @@ async def _get_user_info_teams( return team_list, teams_1 +def _redact_scim_enterprise_metadata( + metadata: Optional[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + """SCIM enterprise attributes are persisted in user metadata so reporting can + group on them, but they are directory-only fields that generic user-info + endpoints must not surface; SCIM clients read them through the SCIM endpoints.""" + if not isinstance(metadata, dict) or SCIM_ENTERPRISE_METADATA_KEY not in metadata: + return metadata + return {k: v for k, v in metadata.items() if k != SCIM_ENTERPRISE_METADATA_KEY} + + def _build_user_info_response( user_id: Optional[str], user_info: Optional[Any], @@ -739,6 +753,9 @@ def _build_user_info_response( ) if isinstance(_user_info, dict): _user_info.pop("password", None) + _user_info["metadata"] = _redact_scim_enterprise_metadata( + _user_info.get("metadata") + ) return UserInfoResponse( user_id=user_id, @@ -983,7 +1000,7 @@ async def user_info_v2( models=user_data.get("models") or [], budget_duration=user_data.get("budget_duration"), budget_reset_at=user_data.get("budget_reset_at"), - metadata=user_data.get("metadata"), + metadata=_redact_scim_enterprise_metadata(user_data.get("metadata")), created_at=user_data.get("created_at"), updated_at=user_data.get("updated_at"), sso_user_id=user_data.get("sso_user_id"), @@ -2098,9 +2115,13 @@ async def get_users( user_list: List[LiteLLM_UserTableWithKeyCount] = [] if users is not None: for user in users: + user_dump = user.model_dump() + user_dump["metadata"] = _redact_scim_enterprise_metadata( + user_dump.get("metadata") + ) user_list.append( LiteLLM_UserTableWithKeyCount( - **user.model_dump(), key_count=user_key_counts.get(user.user_id, 0) + **user_dump, key_count=user_key_counts.get(user.user_id, 0) ) ) else: diff --git a/litellm/proxy/management_endpoints/scim/scim_transformations.py b/litellm/proxy/management_endpoints/scim/scim_transformations.py index d1e00f87b69..866f5baf3a4 100644 --- a/litellm/proxy/management_endpoints/scim/scim_transformations.py +++ b/litellm/proxy/management_endpoints/scim/scim_transformations.py @@ -50,8 +50,16 @@ async def transform_litellm_user_to_scim_user( scim_active = metadata.get("scim_active") active = True if scim_active is None else bool(scim_active) + schemas = ["urn:ietf:params:scim:schemas:core:2.0:User"] + enterprise_user = None + if metadata.get(SCIM_ENTERPRISE_METADATA_KEY): + enterprise_user = SCIMEnterpriseUser.model_validate( + metadata[SCIM_ENTERPRISE_METADATA_KEY] + ) + schemas.append(SCIM_ENTERPRISE_USER_SCHEMA) + return SCIMUser( - schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + schemas=schemas, id=user.user_id, userName=ScimTransformations._get_scim_user_name(user), displayName=ScimTransformations._get_scim_user_name(user), @@ -62,6 +70,7 @@ async def transform_litellm_user_to_scim_user( emails=emails, groups=groups, active=active, + enterprise_user=enterprise_user, meta={ "resourceType": "User", "created": user_created_at, diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index 0798d1a510d..bf15e6b1fab 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -118,6 +118,7 @@ class ScimUserData(TypedDict): given_name: Optional[str] family_name: Optional[str] active: Optional[bool] + enterprise: Optional[SCIMEnterpriseUser] class GroupMemberExtractionResult(BaseModel): @@ -199,11 +200,15 @@ def _extract_scim_user_data(user: SCIMUser) -> ScimUserData: "given_name": user.name.givenName if user.name else None, "family_name": user.name.familyName if user.name else None, "active": user.active, + "enterprise": user.enterprise_user, } def _build_scim_metadata( - given_name: Optional[str], family_name: Optional[str], active: Optional[bool] = None + given_name: Optional[str], + family_name: Optional[str], + active: Optional[bool] = None, + enterprise: Optional[SCIMEnterpriseUser] = None, ) -> Dict[str, Any]: """Build metadata dictionary with SCIM data.""" metadata: Dict[str, Any] = { @@ -216,6 +221,11 @@ def _build_scim_metadata( if active is not None: metadata["scim_active"] = active + if enterprise is not None: + metadata[SCIM_ENTERPRISE_METADATA_KEY] = enterprise.model_dump( + by_alias=True, exclude_none=True + ) + return metadata @@ -999,7 +1009,9 @@ async def create_user( # Create user in database user_id = user.userName or str(uuid.uuid4()) metadata = _build_scim_metadata( - user_data["given_name"], user_data["family_name"] + user_data["given_name"], + user_data["family_name"], + enterprise=user_data["enterprise"], ) default_role: Optional[ @@ -1088,6 +1100,7 @@ async def update_user( user_data["given_name"], user_data["family_name"], scim_active_for_metadata, + enterprise=user_data["enterprise"], ) await _handle_team_membership_changes( diff --git a/litellm/types/proxy/management_endpoints/scim_v2.py b/litellm/types/proxy/management_endpoints/scim_v2.py index c5fdc66154f..6270d2d0925 100644 --- a/litellm/types/proxy/management_endpoints/scim_v2.py +++ b/litellm/types/proxy/management_endpoints/scim_v2.py @@ -1,7 +1,20 @@ from typing import Any, Dict, List, Literal, Optional, Union from fastapi import HTTPException -from pydantic import BaseModel, ConfigDict, EmailStr, field_validator +from pydantic import ( + BaseModel, + ConfigDict, + EmailStr, + Field, + field_validator, + model_serializer, +) +from pydantic_core.core_schema import SerializerFunctionWrapHandler + +SCIM_ENTERPRISE_USER_SCHEMA = ( + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" +) +SCIM_ENTERPRISE_METADATA_KEY = "scim_enterprise" class LiteLLM_UserScimMetadata(BaseModel): @@ -42,13 +55,49 @@ class SCIMUserGroup(BaseModel): type: Optional[str] = "direct" # direct or indirect +class SCIMUserManager(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + value: Optional[str] = None + displayName: Optional[str] = None + ref: Optional[str] = Field(default=None, alias="$ref") + + +class SCIMEnterpriseUser(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + employeeNumber: Optional[str] = None + costCenter: Optional[str] = None + organization: Optional[str] = None + division: Optional[str] = None + department: Optional[str] = None + manager: Optional[SCIMUserManager] = None + + class SCIMUser(SCIMResource): + model_config = ConfigDict(populate_by_name=True) + userName: Optional[str] = None name: Optional[SCIMUserName] = None displayName: Optional[str] = None active: bool = True emails: Optional[List[SCIMUserEmail]] = None groups: Optional[List[SCIMUserGroup]] = None + enterprise_user: Optional[SCIMEnterpriseUser] = Field( + default=None, + alias=SCIM_ENTERPRISE_USER_SCHEMA, + serialization_alias=SCIM_ENTERPRISE_USER_SCHEMA, + ) + + @model_serializer(mode="wrap") + def _omit_absent_enterprise( + self, handler: SerializerFunctionWrapHandler + ) -> Dict[str, Any]: + dumped = handler(self) + if self.enterprise_user is None: + dumped.pop(SCIM_ENTERPRISE_USER_SCHEMA, None) + dumped.pop("enterprise_user", None) + return dumped class SCIMMember(BaseModel): diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_transformations.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_transformations.py index 21d41e0992b..ad0e7010325 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_transformations.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_transformations.py @@ -13,7 +13,10 @@ ScimTransformations, ) from litellm.types.proxy.management_endpoints.scim_v2 import ( + SCIM_ENTERPRISE_USER_SCHEMA, + SCIMEnterpriseUser, SCIMPatchOperation, + SCIMUser, ) @@ -149,6 +152,77 @@ async def test_transform_user_with_scim_metadata( assert scim_user.name.givenName == "Test" assert scim_user.name.familyName == "User" + @pytest.mark.asyncio + async def test_transform_user_with_enterprise_metadata(self, mock_prisma_client): + mock_client, mock_find_unique = mock_prisma_client + mock_find_unique.return_value = None + + user = LiteLLM_UserTable( + user_id="user-ent", + user_email="ent@example.com", + user_alias=None, + teams=[], + created_at=None, + updated_at=None, + metadata={ + "scim_enterprise": {"costCenter": "CC-42", "department": "Platform"} + }, + ) + + with patch("litellm.proxy.proxy_server.prisma_client", mock_client): + scim_user = await ScimTransformations.transform_litellm_user_to_scim_user( + user + ) + + assert scim_user.enterprise_user is not None + assert scim_user.enterprise_user.costCenter == "CC-42" + assert scim_user.enterprise_user.department == "Platform" + assert SCIM_ENTERPRISE_USER_SCHEMA in scim_user.schemas + + @pytest.mark.asyncio + async def test_transform_user_without_enterprise_metadata_omits_schema( + self, mock_user, mock_prisma_client + ): + mock_client, mock_find_unique = mock_prisma_client + team1 = LiteLLM_TeamTable( + team_id="team-1", team_alias="Team One", members_with_roles=[] + ) + team2 = LiteLLM_TeamTable( + team_id="team-2", team_alias="Team Two", members_with_roles=[] + ) + mock_find_unique.side_effect = [team1, team2] + + with patch("litellm.proxy.proxy_server.prisma_client", mock_client): + scim_user = await ScimTransformations.transform_litellm_user_to_scim_user( + mock_user + ) + + assert scim_user.enterprise_user is None + assert SCIM_ENTERPRISE_USER_SCHEMA not in scim_user.schemas + + def test_scim_user_serialization_omits_absent_enterprise_urn(self): + without_enterprise = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + id="user-1", + userName="user@example.com", + ) + dumped = without_enterprise.model_dump(by_alias=True) + assert SCIM_ENTERPRISE_USER_SCHEMA not in dumped + assert "enterprise_user" not in dumped + assert SCIM_ENTERPRISE_USER_SCHEMA not in dumped["schemas"] + + with_enterprise = SCIMUser( + schemas=[ + "urn:ietf:params:scim:schemas:core:2.0:User", + SCIM_ENTERPRISE_USER_SCHEMA, + ], + id="user-2", + userName="ent@example.com", + enterprise_user=SCIMEnterpriseUser(costCenter="CC-42"), + ) + dumped_ent = with_enterprise.model_dump(by_alias=True) + assert dumped_ent[SCIM_ENTERPRISE_USER_SCHEMA]["costCenter"] == "CC-42" + @pytest.mark.asyncio async def test_transform_litellm_team_to_scim_group( self, mock_team, mock_prisma_client 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 index ad893012807..79bd74109e8 100644 --- 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 @@ -24,6 +24,7 @@ update_user, ) from litellm.types.proxy.management_endpoints.scim_v2 import ( + SCIM_ENTERPRISE_USER_SCHEMA, SCIMGroup, SCIMMember, SCIMPatchOp, @@ -115,6 +116,59 @@ async def test_create_user_defaults_to_viewer(mocker, monkeypatch): assert called_args.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY +@pytest.mark.asyncio +async def test_create_user_ingests_enterprise_extension(mocker, monkeypatch): + """A SCIM create payload carrying the enterprise extension block should land + in the created user's metadata under scim_enterprise""" + + scim_user = SCIMUser.model_validate( + { + "schemas": [ + "urn:ietf:params:scim:schemas:core:2.0:User", + SCIM_ENTERPRISE_USER_SCHEMA, + ], + "userName": "ent-user", + "name": {"familyName": "User", "givenName": "Ent"}, + "emails": [{"value": "ent@example.com"}], + SCIM_ENTERPRISE_USER_SCHEMA: { + "costCenter": "CC-42", + "department": "Platform", + }, + } + ) + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) + + monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client), + ) + + new_user_mock = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.new_user", + AsyncMock(return_value=NewUserRequest(user_id="ent-user")), + ) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", + AsyncMock(return_value=scim_user), + ) + + await create_user(user=scim_user) + + created_metadata = new_user_mock.call_args.kwargs["data"].metadata + assert created_metadata["scim_enterprise"] == { + "costCenter": "CC-42", + "department": "Platform", + } + + @pytest.mark.asyncio async def test_create_user_uses_default_internal_user_params_role(mocker, monkeypatch): """If role is set in default_internal_user_params, new user should use that role""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 627958cef93..9b7fc817e4e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -657,6 +657,56 @@ async def mock_get_user_key_counts(*args, **kwargs): assert user_response.key_count == 0 +@pytest.mark.asyncio +async def test_get_users_redacts_scim_enterprise_metadata(mocker): + """ + /user/list must strip scim_enterprise from each user's metadata while leaving + the rest of the metadata intact, matching the user-info endpoints. + """ + mock_prisma_client = mocker.MagicMock() + + mock_user_row = mocker.MagicMock() + mock_user_row.user_id = "listed-user" + mock_user_row.model_dump.return_value = { + "user_id": "listed-user", + "user_email": "listed@example.com", + "user_role": "internal_user", + "metadata": { + "scim_metadata": {"givenName": "Jane", "familyName": "Doe"}, + "scim_enterprise": {"costCenter": "CC-42", "department": "Platform"}, + }, + } + + async def mock_find_many(*args, **kwargs): + return [mock_user_row] + + async def mock_count(*args, **kwargs): + return 1 + + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + mock_prisma_client.db.litellm_usertable.count = mock_count + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + async def mock_get_user_key_counts(*args, **kwargs): + return {"listed-user": 0} + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_key_counts", + mock_get_user_key_counts, + ) + + admin_key = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + response = await get_users( + page=1, page_size=1, user_api_key_dict=admin_key, organization_ids=None + ) + + listed = response["users"][0] + assert listed.metadata == { + "scim_metadata": {"givenName": "Jane", "familyName": "Doe"} + } + assert "scim_enterprise" not in (listed.metadata or {}) + + def test_validate_sort_params(): """ Test that validate_sort_params returns None if sort_by is None @@ -2167,6 +2217,94 @@ async def mock_find_unique(*args, **kwargs): assert response.metadata == {"team": "engineering"} +@pytest.mark.asyncio +async def test_user_info_v2_redacts_scim_enterprise_metadata(mocker): + """ + SCIM enterprise attributes are persisted in metadata for reporting, but + /v2/user/info must not surface them; the rest of metadata is preserved. + """ + from fastapi import Request + + from litellm.proxy._types import UserInfoV2Response + from litellm.proxy.management_endpoints.internal_user_endpoints import user_info_v2 + + mock_prisma_client = mocker.MagicMock() + + mock_user_row = mocker.MagicMock() + mock_user_row.model_dump.return_value = { + "user_id": "target-user-123", + "user_email": "target@example.com", + "metadata": { + "scim_metadata": {"givenName": "Jane", "familyName": "Doe"}, + "scim_enterprise": { + "costCenter": "CC-42", + "department": "Platform", + "employeeNumber": "E-1001", + }, + }, + "teams": ["team-1"], + } + + async def mock_find_unique(*args, **kwargs): + if kwargs.get("where", {}).get("user_id") == "target-user-123": + return mock_user_row + return None + + mock_prisma_client.db.litellm_usertable.find_unique = mocker.AsyncMock( + side_effect=mock_find_unique + ) + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + mock_request = mocker.MagicMock(spec=Request) + + admin_key = UserAPIKeyAuth( + user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN + ) + + response = await user_info_v2( + request=mock_request, + user_id="target-user-123", + user_api_key_dict=admin_key, + ) + + assert isinstance(response, UserInfoV2Response) + assert response.metadata == { + "scim_metadata": {"givenName": "Jane", "familyName": "Doe"} + } + assert "scim_enterprise" not in (response.metadata or {}) + + +def test_build_user_info_response_redacts_scim_enterprise_metadata(): + """ + The shared /user/info builder strips scim_enterprise from the returned user row + while leaving every other metadata key intact. + """ + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _build_user_info_response, + ) + + user_row = { + "user_id": "target-user-123", + "metadata": { + "scim_metadata": {"givenName": "Jane"}, + "scim_enterprise": {"costCenter": "CC-42"}, + }, + } + + response = _build_user_info_response( + user_id="target-user-123", + user_info=user_row, + keys=None, + team_list=[], + teams_1=None, + ) + + assert response.user_info is not None + assert response.user_info["metadata"] == {"scim_metadata": {"givenName": "Jane"}} + assert "scim_enterprise" not in response.user_info["metadata"] + + @pytest.mark.asyncio async def test_user_info_v2_internal_user_can_query_self(mocker): """