mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
Merge pull request #41830 from BerriAI/litellm_scim_multivalued_optional_value
fix(scim): accept entitlements and roles entries without a value on SCIM user PUT
This commit is contained in:
commit
6e3b6d6d03
6 changed files with 104 additions and 9 deletions
|
|
@ -38542,6 +38542,7 @@
|
|||
"type": "object"
|
||||
},
|
||||
"SCIMMultiValuedAttribute": {
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"display": {
|
||||
"anyOf": [
|
||||
|
|
@ -38577,13 +38578,17 @@
|
|||
"title": "Type"
|
||||
},
|
||||
"value": {
|
||||
"title": "Value",
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Value"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"value"
|
||||
],
|
||||
"title": "SCIMMultiValuedAttribute",
|
||||
"type": "object"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import json
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
|
|
@ -1303,6 +1304,75 @@ 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):
|
||||
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):
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -36801,7 +36801,9 @@ export interface components {
|
|||
/** Type */
|
||||
type?: string | null;
|
||||
/** Value */
|
||||
value: string;
|
||||
value?: string | null;
|
||||
} & {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** SCIMPatchOp */
|
||||
SCIMPatchOp: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue