fix(scim): keep team memberships when PUT /Users carries no groups

Okta sends profile updates as full PUTs with no groups or groups: [], since SCIM User.groups is readOnly and membership is synced through /Groups. The PUT handler diffed that empty list against the stored teams, removed the user from every team (which also deletes their team keys) and recomputed the role from an empty group list. Treat an empty groups list on PUT as unspecified: keep the stored teams and leave the role alone. Explicit non-empty groups still replace memberships as before

Claude-Session: https://claude.ai/code/session_01CqwUV4Ywnu5aUjXx1UhJrM
This commit is contained in:
ryan-crabbe-berri 2026-09-03 15:46:44 -07:00
parent 0429339204
commit 07dd8a7e47
3 changed files with 69 additions and 5 deletions

View file

@ -1774,22 +1774,25 @@ async def update_user(
roles=user_data["roles"],
)
# SCIM User.groups is readOnly (RFC 7643 4.1.2): IdPs sync membership via /Groups and send
# no groups or `groups: []` on profile PUTs, so empty means unspecified, not "remove from every team"
target_teams: Final = user_data["teams"] or existing_user.teams
await _handle_team_membership_changes(
user_id=user_id,
existing_teams=existing_user.teams or [],
new_teams=user_data["teams"],
existing_teams=existing_user.teams,
new_teams=target_teams,
)
update_data: Final = {
"user_email": user_data["user_email"],
"user_alias": user_data["user_alias"],
"sso_user_id": user_data["sso_user_id"],
"teams": user_data["teams"],
"teams": target_teams,
"metadata": safe_dumps(metadata),
}
admin_group: Final = await _get_scim_admin_group()
if admin_group is not None:
if admin_group is not None and user_data["teams"]:
update_data["user_role"] = _resolve_scim_user_role(
user.groups or [], admin_group, _default_scim_user_role()
)

View file

@ -1257,6 +1257,67 @@ async def test_update_user_success(mocker):
assert call_args[1]["data"]["teams"] == ["new-team"]
@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):
"""Okta profile PUTs carry no `groups` or `groups: []`; neither may drop teams (and their keys) or recompute role"""
from litellm.proxy.proxy_server import proxy_config
async def mock_get_config():
return {"litellm_settings": {"scim_admin_group": "litellm-admins"}}
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False)
existing_user = mocker.MagicMock()
existing_user.teams = ["litellm-admins", "engineering"]
existing_user.metadata = {}
scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
userName="okta-user",
name=SCIMUserName(familyName="Renamed", givenName="Okta"),
emails=[SCIMUserEmail(value="okta@example.com")],
**({} if groups is None else {"groups": groups}),
)
response_scim_user = SCIMUser(
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
id="okta-user",
userName="okta-user",
emails=[SCIMUserEmail(value="okta@example.com")],
)
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={"user_id": "okta-user"})
mocker.patch( # test-quality-ok: update_user's 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: update_user's 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: update_user's 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),
)
patch_membership = mocker.patch( # test-quality-ok: roster writes are module-level, not injectable
"litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership",
AsyncMock(),
)
result = await update_user(user_id="okta-user", user=scim_user)
assert result == response_scim_user
patch_membership.assert_not_awaited()
update_data = mock_prisma_client.db.litellm_usertable.update.call_args.kwargs["data"]
assert update_data["teams"] == ["litellm-admins", "engineering"]
assert "user_role" not in update_data
@pytest.mark.asyncio
async def test_update_user_not_found(mocker):
"""Should raise 404 when user doesn't exist"""

View file

@ -3,7 +3,7 @@
"limit": 22328
},
"LIT002": {
"limit": 26760
"limit": 26758
},
"LIT003": {
"limit": 261