fix(scim): accept entitlements and roles entries without a value on SCIM user PUT

SCIMMultiValuedAttribute required value, so a PUT /scim/v2/Users/{id} that
carried an IdP-specific entitlements entry such as {"groups": [...]} failed
body validation with 422 and the suspend (active: false) never reached
update_user. value is now optional and unknown members are kept, so the
suspend is applied, keys are blocked, and the entries are stored as sent

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan 2026-09-18 15:46:33 +00:00
parent 8fc9c46d1a
commit b6410d563b
6 changed files with 106 additions and 9 deletions

View file

@ -38245,6 +38245,7 @@
"type": "object"
},
"SCIMMultiValuedAttribute": {
"additionalProperties": true,
"properties": {
"display": {
"anyOf": [
@ -38280,13 +38281,17 @@
"title": "Type"
},
"value": {
"title": "Value",
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Value"
}
},
"required": [
"value"
],
"title": "SCIMMultiValuedAttribute",
"type": "object"
},

View file

@ -2107,7 +2107,7 @@ def _handle_multi_valued_attribute_update(path: str, op_type: str, value: object
except ValidationError:
raise HTTPException(
status_code=400,
detail={"error": f"Invalid value for {base}: expected a list of objects with a 'value' sub-attribute"},
detail={"error": f"Invalid value for {base}: expected a list of objects or strings"},
)
dumped: Final = [attr.model_dump(exclude_none=True) for attr in attrs]

View file

@ -61,7 +61,9 @@ class SCIMUserGroup(BaseModel):
class SCIMMultiValuedAttribute(BaseModel):
value: str
model_config = ConfigDict(extra="allow")
value: str | None = None
display: str | None = None
type: str | None = None
primary: bool | None = None

View file

@ -422,7 +422,7 @@ def test_apply_patch_ops_invalid_entitlements_value_raises_400():
patch_ops = SCIMPatchOp(
Operations=[
SCIMPatchOperation(
op="replace", path="entitlements", value=[{"display": "no value"}]
op="replace", path="entitlements", value=[42]
)
]
)
@ -433,6 +433,22 @@ def test_apply_patch_ops_invalid_entitlements_value_raises_400():
assert exc_info.value.status_code == 400
def test_apply_patch_ops_replace_entitlements_without_value_member_is_stored_as_sent():
patch_ops = SCIMPatchOp(
Operations=[
SCIMPatchOperation(
op="replace", path="entitlements", value=[{"groups": ["S0506MKA55L"]}]
)
]
)
update_data, _ = _apply_patch_ops(
existing_user=_user_with_metadata({}), patch_ops=patch_ops
)
assert update_data["metadata"]["scim_entitlements"] == [{"groups": ["S0506MKA55L"]}]
def test_apply_patch_ops_add_without_value_raises_400_naming_value_member():
patch_ops = SCIMPatchOp(
Operations=[SCIMPatchOperation(op="add", path="entitlements")]

View file

@ -1,3 +1,4 @@
import json
import logging
import time
from collections.abc import Callable, Mapping, Sequence
@ -1303,6 +1304,77 @@ async def test_update_user_success(mocker):
assert call_args[1]["data"]["teams"] == ["new-team"]
@pytest.mark.asyncio
async def test_update_user_put_with_valueless_entitlements_deactivates_user(scim_test_client, mocker):
"""A suspend PUT whose entitlements entries carry no `value` member (an IdP-specific shape)
must not be rejected by body validation: the user is deactivated and the entries are stored as sent"""
existing_user = mocker.MagicMock()
existing_user.teams = []
existing_user.metadata = {"scim_active": True}
updated_user = {
"user_id": "suspend-me",
"user_email": "suspend@example.com",
"user_alias": None,
"teams": [],
"metadata": "{}",
}
response_scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
id="suspend-me",
userName="suspend-me",
active=False,
)
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.update = AsyncMock(return_value=updated_user)
mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=mock_prisma_client),
)
mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable
"litellm.proxy.management_endpoints.scim.scim_v2._check_user_exists",
AsyncMock(return_value=existing_user),
)
mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable
"litellm.proxy.management_endpoints.scim.scim_v2._handle_team_membership_changes",
AsyncMock(),
)
set_keys_blocked_mock = mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable
"litellm.proxy.management_endpoints.scim.scim_v2._set_user_keys_blocked",
AsyncMock(return_value=1),
)
mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=response_scim_user),
)
async with scim_test_client as client:
response = await client.put(
"/scim/v2/Users/suspend-me",
json={
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName": "suspend-me",
"emails": [{"value": "suspend@example.com", "primary": True}],
"entitlements": [{"groups": ["S0506MKA55L", "S0506MKA56M"]}],
"roles": [{"display": "Viewer"}],
"active": False,
},
)
assert response.status_code == 200, response.text
assert response.json()["active"] is False
written_metadata = json.loads(mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]["metadata"])
assert written_metadata["scim_active"] is False
assert written_metadata["scim_entitlements"] == [{"groups": ["S0506MKA55L", "S0506MKA56M"]}]
assert written_metadata["scim_roles"] == [{"display": "Viewer"}]
set_keys_blocked_mock.assert_awaited_once_with(user_id="suspend-me", blocked=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("groups", [None, []], ids=["groups-omitted", "groups-empty"])
async def test_update_user_without_groups_preserves_memberships_and_role(mocker, monkeypatch, groups):

View file

@ -36612,7 +36612,9 @@ export interface components {
/** Type */
type?: string | null;
/** Value */
value: string;
value?: string | null;
} & {
[key: string]: unknown;
};
/** SCIMPatchOp */
SCIMPatchOp: {