From f247f1031b874bc3133f5bd200f4dcc0a5befa18 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Mon, 2 Jun 2025 13:53:26 -0700 Subject: [PATCH] [Fix] Fix SCIM running patch operation case sensitivity (#11335) * fix: fix SCIM patch op * test: test SCIM patch op --- .../proxy/management_endpoints/scim_v2.py | 14 ++++- .../scim/test_scim_transformations.py | 62 ++++++++++++++++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/litellm/types/proxy/management_endpoints/scim_v2.py b/litellm/types/proxy/management_endpoints/scim_v2.py index 0c2f16e025..fa78b8b650 100644 --- a/litellm/types/proxy/management_endpoints/scim_v2.py +++ b/litellm/types/proxy/management_endpoints/scim_v2.py @@ -1,7 +1,7 @@ from typing import Any, Dict, List, Literal, Optional, Union from fastapi import HTTPException -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, field_validator class LiteLLM_UserScimMetadata(BaseModel): @@ -72,10 +72,20 @@ class SCIMListResponse(BaseModel): # SCIM PATCH Operation Models class SCIMPatchOperation(BaseModel): - op: Literal["add", "remove", "replace"] + op: str path: Optional[str] = None value: Optional[Any] = None + @field_validator("op", mode="before") + @classmethod + def normalize_op(cls, v): + if isinstance(v, str): + v_lower = v.lower() + if v_lower not in {"add", "remove", "replace"}: + raise ValueError("op must be add, remove, or replace") + return v_lower + return v + class SCIMPatchOp(BaseModel): schemas: List[str] = ["urn:ietf:params:scim:api:messages:2.0:PatchOp"] 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 9432fab68f..f2b374657e 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 @@ -18,7 +18,12 @@ from litellm.proxy._types import LiteLLM_TeamTable, LiteLLM_UserTable, Member from litellm.proxy.management_endpoints.scim.scim_transformations import ( ScimTransformations, ) -from litellm.types.proxy.management_endpoints.scim_v2 import SCIMGroup, SCIMUser +from litellm.types.proxy.management_endpoints.scim_v2 import ( + SCIMGroup, + SCIMPatchOp, + SCIMPatchOperation, + SCIMUser, +) # Mock data @@ -223,3 +228,58 @@ class TestScimTransformations: member_without_email = Member(user_id="user-456", user_email=None, role="user") result = ScimTransformations._get_scim_member_value(member_without_email) assert result == ScimTransformations.DEFAULT_SCIM_MEMBER_VALUE + + +class TestSCIMPatchOperations: + """Test SCIM PATCH operation validation and case-insensitive handling""" + + def test_scim_patch_operation_lowercase(self): + """Test that lowercase operations are accepted""" + op = SCIMPatchOperation(op="add", path="members", value=[{"value": "user123"}]) + assert op.op == "add" + + op = SCIMPatchOperation(op="remove", path='members[value eq "user123"]') + assert op.op == "remove" + + op = SCIMPatchOperation(op="replace", path="displayName", value="New Name") + assert op.op == "replace" + + def test_scim_patch_operation_uppercase(self): + """Test that uppercase operations are normalized to lowercase""" + op = SCIMPatchOperation(op="ADD", path="members", value=[{"value": "user123"}]) + assert op.op == "add" + + op = SCIMPatchOperation(op="REMOVE", path='members[value eq "user123"]') + assert op.op == "remove" + + op = SCIMPatchOperation(op="REPLACE", path="displayName", value="New Name") + assert op.op == "replace" + + def test_scim_patch_operation_mixed_case(self): + """Test that mixed case operations are normalized to lowercase""" + op = SCIMPatchOperation(op="Add", path="members", value=[{"value": "user123"}]) + assert op.op == "add" + + op = SCIMPatchOperation(op="Remove", path='members[value eq "user123"]') + assert op.op == "remove" + + op = SCIMPatchOperation(op="Replace", path="displayName", value="New Name") + assert op.op == "replace" + + def test_scim_patch_operation_with_optional_fields(self): + """Test SCIMPatchOperation with and without optional fields""" + # Operation with all fields + op_full = SCIMPatchOperation( + op="Add", + path="members", + value=[{"value": "user123", "display": "User 123"}], + ) + assert op_full.op == "add" + assert op_full.path == "members" + assert op_full.value == [{"value": "user123", "display": "User 123"}] + + # Operation with minimal fields (only op is required) + op_minimal = SCIMPatchOperation(op="Remove") + assert op_minimal.op == "remove" + assert op_minimal.path is None + assert op_minimal.value is None