feat(scim): add placeholder listing and merge so a shadowed account can be healed (#39231)

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-02 01:04:53 +00:00 committed by GitHub
parent 6d0367ce35
commit 2b616fc479
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 659 additions and 23 deletions

View file

@ -7,7 +7,7 @@ Canonical definition for ``litellm_usertable``. Re-exported from
from datetime import datetime
from pydantic import ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
from litellm.models.object_permission import LiteLLM_ObjectPermissionTable
from litellm.models.organization_membership import (
@ -67,3 +67,11 @@ class LiteLLM_UserTable(LiteLLMPydanticObjectBase):
if not self.models:
return True
return model_name in self.models
class SCIMPlaceholder(BaseModel):
"""A user row keyed by a value that names another account by SSO identity or email."""
placeholder_user_id: str
resolved_user_ids: tuple[str, ...]
team_ids: tuple[str, ...]

View file

@ -32002,6 +32002,62 @@
"title": "SCIMPatchOperation",
"type": "object"
},
"SCIMPlaceholder": {
"description": "A user row keyed by a value that names another account by SSO identity or email.",
"properties": {
"placeholder_user_id": {
"title": "Placeholder User Id",
"type": "string"
},
"resolved_user_ids": {
"items": {
"type": "string"
},
"title": "Resolved User Ids",
"type": "array"
},
"team_ids": {
"items": {
"type": "string"
},
"title": "Team Ids",
"type": "array"
}
},
"required": [
"placeholder_user_id",
"resolved_user_ids",
"team_ids"
],
"title": "SCIMPlaceholder",
"type": "object"
},
"SCIMPlaceholderMergeResult": {
"properties": {
"merged_into_user_id": {
"title": "Merged Into User Id",
"type": "string"
},
"placeholder_user_id": {
"title": "Placeholder User Id",
"type": "string"
},
"team_ids": {
"items": {
"type": "string"
},
"title": "Team Ids",
"type": "array"
}
},
"required": [
"placeholder_user_id",
"merged_into_user_id",
"team_ids"
],
"title": "SCIMPlaceholderMergeResult",
"type": "object"
},
"SCIMServiceProviderConfig": {
"properties": {
"authenticationSchemes": {
@ -33641,6 +33697,129 @@
"scim"
]
}
},
"/scim/v2/placeholders": {
"get": {
"description": "List user rows whose id is another account's SSO identity or email.\n\nAn earlier release provisioned a group member it could not match as a user keyed\nby the raw member value, and that row now shadows the account the value really\nnames, so every push of that member is refused. This lists those rows so an\noperator can fold each one into the account it shadows with\n``POST /scim/v2/placeholders/{user_id}/merge``. A row that has an SSO identity of\nits own or owns virtual keys is left out: someone uses that account.",
"operationId": "list_placeholders_scim_v2_placeholders_get",
"parameters": [
{
"in": "query",
"name": "feature",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Feature"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/SCIMPlaceholder"
},
"title": "Response List Placeholders Scim V2 Placeholders Get",
"type": "array"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "List Placeholders",
"tags": [
"scim"
]
}
},
"/scim/v2/placeholders/{user_id}/merge": {
"post": {
"description": "Fold a placeholder user into the one account its id names by SSO identity or email.\n\nThe account is added to every team the placeholder is on, then the placeholder is\ndeleted the way ``DELETE /scim/v2/Users/{id}`` deletes a user, so the next group\npush resolves the member value to the real account. Refused with 409 when the row\nhas an SSO identity of its own, owns virtual keys, or names no account or several.",
"operationId": "merge_placeholder_scim_v2_placeholders__user_id__merge_post",
"parameters": [
{
"in": "path",
"name": "user_id",
"required": true,
"schema": {
"title": "User ID",
"type": "string"
}
},
{
"in": "query",
"name": "feature",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Feature"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SCIMPlaceholderMergeResult"
}
}
},
"description": "Successful Response"
},
"422": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
},
"description": "Validation Error"
}
},
"security": [
{
"APIKeyHeader": []
}
],
"summary": "Merge Placeholder",
"tags": [
"scim"
]
}
}
}
},

View file

@ -29,6 +29,7 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.models.user import SCIMPlaceholder
from litellm.proxy._types import (
LiteLLM_TeamTable,
LiteLLM_UserTable,
@ -1862,6 +1863,89 @@ async def delete_user(
raise handle_exception_on_proxy(e)
@scim_router.get(
"/placeholders",
response_model=tuple[SCIMPlaceholder, ...],
dependencies=(Depends(user_api_key_auth),),
)
async def list_placeholders() -> tuple[SCIMPlaceholder, ...]:
"""
List user rows whose id is another account's SSO identity or email.
An earlier release provisioned a group member it could not match as a user keyed
by the raw member value, and that row now shadows the account the value really
names, so every push of that member is refused. This lists those rows so an
operator can fold each one into the account it shadows with
``POST /scim/v2/placeholders/{user_id}/merge``. A row that has an SSO identity of
its own or owns virtual keys is left out: someone uses that account.
"""
try:
prisma_client: Final = await _get_prisma_client_or_raise_exception()
async with prisma_client.tx() as tx:
return await UserRepository(prisma_client).find_shadowing_placeholders(tx)
except Exception as e:
raise handle_exception_on_proxy(e)
def _placeholder_rejection(placeholder: LiteLLM_UserTable, resolved: tuple[str, ...], key_count: int) -> str | None:
if placeholder.sso_user_id is not None:
return f"User '{placeholder.user_id}' has an SSO identity of its own, so it is an account someone signs in to"
if key_count:
return f"User '{placeholder.user_id}' owns {key_count} virtual keys. Move or delete them before merging it"
if not resolved:
return f"User '{placeholder.user_id}' shadows no account: no other user has that id as SSO identity or email"
if len(resolved) > 1:
return (
f"User '{placeholder.user_id}' names {len(resolved)} accounts ({', '.join(resolved)}). Resolve that first"
)
return None
@scim_router.post(
"/placeholders/{user_id}/merge",
response_model=SCIMPlaceholderMergeResult,
dependencies=(Depends(user_api_key_auth),),
)
async def merge_placeholder(
user_id: str = Path(..., title="User ID"),
) -> SCIMPlaceholderMergeResult:
"""
Fold a placeholder user into the one account its id names by SSO identity or email.
The account is added to every team the placeholder is on, then the placeholder is
deleted the way ``DELETE /scim/v2/Users/{id}`` deletes a user, so the next group
push resolves the member value to the real account. Refused with 409 when the row
has an SSO identity of its own, owns virtual keys, or names no account or several.
"""
try:
prisma_client: Final = await _get_prisma_client_or_raise_exception()
placeholder: Final = await _check_user_exists(user_id)
resolved: Final = tuple(
other for other in await _users_named_by_member_value(user_id, prisma_client, take=None) if other != user_id
)
owned_keys: Final[_UserIdWhere] = {"user_id": user_id}
keys: Final = await _table(VerificationTokenRepository(prisma_client)).find_many(where=owned_keys)
rejection: Final = _placeholder_rejection(placeholder, resolved, len(keys))
if rejection is not None:
detail: Final[_ScimErrorDetail] = {"error": rejection}
raise HTTPException(status_code=409, detail=detail)
target_user_id: Final = resolved[0]
team_ids: Final = tuple(placeholder.teams)
for team_id in team_ids:
await _add_user_to_team(user_id=target_user_id, team_id=team_id)
await delete_user(user_id=user_id)
await _recompute_scim_member_roles(prisma_client, (target_user_id,))
verbose_proxy_logger.info(
"SCIM: merged placeholder user '%s' into '%s', moving teams %s", user_id, target_user_id, team_ids
)
return SCIMPlaceholderMergeResult(
placeholder_user_id=user_id, merged_into_user_id=target_user_id, team_ids=team_ids
)
except Exception as e:
raise handle_exception_on_proxy(e)
def _parse_member_entry(entry: object) -> SCIMMember | None:
"""Parse one entry of a SCIM patch value, or None when it carries no id."""
if isinstance(entry, str):

View file

@ -6,15 +6,34 @@ import json
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final
from litellm.models.user import LiteLLM_UserTable
from pydantic import TypeAdapter
from litellm.models.user import LiteLLM_UserTable, SCIMPlaceholder
from litellm.repositories.base_repository import BaseRepository, DbRecord, record_to_dict
from litellm.repositories.prisma_protocols import TableActions
if TYPE_CHECKING:
from prisma import Prisma
from prisma import models as prisma_models
_JSON_ENCODED_COLUMNS: Final = frozenset({"metadata", "model_spend", "model_max_budget"})
_SHADOWING_PLACEHOLDERS_SQL: Final = """
SELECT p.user_id AS placeholder_user_id,
array_agg(r.user_id ORDER BY r.user_id) AS resolved_user_ids,
p.teams AS team_ids
FROM "LiteLLM_UserTable" p
JOIN "LiteLLM_UserTable" r
ON r.user_id <> p.user_id
AND (r.sso_user_id = p.user_id OR LOWER(r.user_email) = LOWER(p.user_id))
WHERE p.sso_user_id IS NULL
AND NOT EXISTS (SELECT 1 FROM "LiteLLM_VerificationToken" k WHERE k.user_id = p.user_id)
GROUP BY p.user_id, p.teams
ORDER BY p.user_id
"""
_PLACEHOLDER_ROWS_ADAPTER: Final = TypeAdapter(tuple[SCIMPlaceholder, ...])
class UserRepository(BaseRepository[LiteLLM_UserTable]):
"""Repository for user database operations."""
@ -59,6 +78,11 @@ class UserRepository(BaseRepository[LiteLLM_UserTable]):
"""Find all users in a team."""
return await self.find_many(where={"teams": {"has": team_id}})
async def find_shadowing_placeholders(self, tx: "Prisma") -> tuple[SCIMPlaceholder, ...]:
"""Users with no SSO id and no virtual keys whose id is another user's SSO id or email."""
rows: Final = await tx.query_raw(_SHADOWING_PLACEHOLDERS_SQL)
return _PLACEHOLDER_ROWS_ADAPTER.validate_python(rows)
async def count_billable_users(self) -> int:
"""Number of users that count toward the license seat limit.

View file

@ -150,6 +150,12 @@ class SCIMGroup(SCIMResource):
members: list[SCIMMember] | None = None
class SCIMPlaceholderMergeResult(BaseModel):
placeholder_user_id: str
merged_into_user_id: str
team_ids: tuple[str, ...]
# SCIM List Response Models
class SCIMListResponse(BaseModel):
schemas: list[str] = ["urn:ietf:params:scim:api:messages:2.0:ListResponse"]

View file

@ -1,7 +1,8 @@
import logging
import time
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from itertools import chain
from types import MappingProxyType
from typing import Final
from unittest.mock import AsyncMock, MagicMock, call
@ -38,6 +39,7 @@ from litellm.proxy.management_endpoints.scim.scim_v2 import (
get_groups,
get_users,
get_service_provider_config,
merge_placeholder,
patch_group,
patch_team_membership,
patch_user,
@ -52,6 +54,7 @@ from litellm.types.proxy.management_endpoints.scim_v2 import (
SCIMMember,
SCIMPatchOp,
SCIMPatchOperation,
SCIMPlaceholderMergeResult,
SCIMServiceProviderConfig,
SCIMUser,
SCIMUserEmail,
@ -778,13 +781,17 @@ async def test_handle_existing_user_by_email_without_teams_preserves_memberships
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user",
AsyncMock(return_value=None),
)
mock_team_member_add = mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add",
AsyncMock(),
mock_team_member_add = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add",
AsyncMock(),
)
)
mock_team_member_delete = mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
AsyncMock(),
mock_team_member_delete = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the helper
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete",
AsyncMock(),
)
)
new_user_request = NewUserRequest(
@ -4470,9 +4477,11 @@ async def test_create_group_applies_default_team_params(
"litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
AsyncMock(return_value=_member_resolution_prisma(mocker, users=set(), teams=set())),
)
new_team_mock = mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_group
"litellm.proxy.management_endpoints.scim.scim_v2.new_team",
AsyncMock(return_value=mocker.MagicMock()),
new_team_mock = (
mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_group
"litellm.proxy.management_endpoints.scim.scim_v2.new_team",
AsyncMock(return_value=mocker.MagicMock()),
)
)
mocker.patch( # test-quality-ok: endpoint collaborators are module-level, not injectable into create_group
"litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_team_to_scim_group",
@ -4927,9 +4936,7 @@ async def test_process_group_patch_remove_by_the_id_the_directory_added_with(
@pytest.mark.asyncio
async def test_process_group_patch_remove_still_drops_a_placeholder_by_its_literal_id(
mocker, scim_upsert_user_enabled
):
async def test_process_group_patch_remove_still_drops_a_placeholder_by_its_literal_id(mocker, scim_upsert_user_enabled):
"""An earlier release put unmatched ids on the roster verbatim, so a remove has to
keep clearing the id as written even once it also resolves."""
patch_ops = SCIMPatchOp(
@ -4940,7 +4947,10 @@ async def test_process_group_patch_remove_still_drops_a_placeholder_by_its_liter
team_id="parent-group",
team_alias="Parent Group",
members=[],
members_with_roles=[Member(user_id="legacy@example.com", role="user"), Member(user_id="keep-user", role="user")],
members_with_roles=[
Member(user_id="legacy@example.com", role="user"),
Member(user_id="keep-user", role="user"),
],
)
_, final_members, _ = await _process_group_patch_operations(
@ -5105,11 +5115,8 @@ async def test_process_group_patch_remove_refuses_when_two_members_share_the_id(
assert "more than one member of this group" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_resolve_group_member_ids_exact_user_id_wins_when_it_names_nobody_else(
mocker, scim_upsert_user_enabled
):
async def test_resolve_group_member_ids_exact_user_id_wins_when_it_names_nobody_else(mocker, scim_upsert_user_enabled):
"""The canonical user id stays authoritative, including when the same account also
holds that value as its email, which is how a SCIM-provisioned account is keyed."""
prisma_client = _member_resolution_prisma(
@ -5171,9 +5178,7 @@ async def test_resolve_group_member_ids_refuses_a_user_id_that_names_another_acc
assert exc_info.value.status_code == 400
assert "member-id" in str(exc_info.value.detail)
create_user_mock.assert_not_called()
assert any(
record.levelno >= logging.WARNING and "someone-else" in record.getMessage() for record in caplog.records
)
assert any(record.levelno >= logging.WARNING and "someone-else" in record.getMessage() for record in caplog.records)
@pytest.mark.asyncio
@ -5711,3 +5716,196 @@ async def test_patch_group_404s_when_team_deleted_mid_request(mocker):
assert exc_info.value.code == "404"
assert f"Group not found with ID: {group_id}" in exc_info.value.message
_SHADOW_MEMBER_VALUE: Final = "00u1shadow"
_SHADOWED_ACCOUNT: Final = "real-1"
_SHADOWED_GROUP: Final = "grp-eng"
def _shadowed_tenant_rows() -> tuple[LiteLLM_UserTable, ...]:
"""A placeholder keyed by the raw member value, and the real account that value names by SSO id."""
return (
LiteLLM_UserTable(user_id=_SHADOW_MEMBER_VALUE, user_email=_SHADOW_MEMBER_VALUE, teams=[_SHADOWED_GROUP]),
LiteLLM_UserTable(user_id=_SHADOWED_ACCOUNT, user_email="alice@example.com", sso_user_id=_SHADOW_MEMBER_VALUE),
)
def _shadow_tenant_prisma(
mocker: MockerFixture,
*,
rows: Sequence[LiteLLM_UserTable],
keys_owned_by: Mapping[str, int] = MappingProxyType({}),
) -> MagicMock:
"""Prisma fake whose user rows are live: deleting one removes it from every later lookup."""
users: Final[dict[str, LiteLLM_UserTable]] = {row.user_id: row for row in rows}
team: Final = LiteLLM_TeamTable(
team_id=_SHADOWED_GROUP,
members=[_SHADOW_MEMBER_VALUE],
members_with_roles=[Member(user_id=_SHADOW_MEMBER_VALUE, role="user")],
metadata={SCIM_MANAGED_TEAM_METADATA_KEY: True},
)
async def find_unique(where: Mapping[str, str]) -> LiteLLM_UserTable | None:
return users.get(where["user_id"])
def clause_matches(row: LiteLLM_UserTable, clause: Mapping[str, object]) -> bool:
if "user_id" in clause:
return row.user_id == clause["user_id"]
if "sso_user_id" in clause:
return row.sso_user_id == clause["sso_user_id"]
email_filter: Final = clause["user_email"]
assert isinstance(email_filter, dict)
return (row.user_email or "").casefold() == str(email_filter["equals"]).casefold()
async def identity_rows(where: Mapping[str, object], take: int | None = None) -> tuple[LiteLLM_UserTable, ...]:
clauses: Final = where["OR"]
assert isinstance(clauses, list)
matched: Final = tuple(row for row in users.values() if any(clause_matches(row, clause) for clause in clauses))
return matched[:take] if take else matched
async def delete(where: Mapping[str, str]) -> LiteLLM_UserTable | None:
return users.pop(where["user_id"], None)
async def keys_for(where: Mapping[str, object]) -> tuple[MagicMock, ...]:
return tuple(mocker.MagicMock() for _ in range(keys_owned_by.get(str(where["user_id"]), 0)))
async def team_lookup(where: Mapping[str, str]) -> LiteLLM_TeamTable | None:
return team if where["team_id"] == team.team_id else None
prisma_client = mocker.MagicMock()
prisma_client.db = mocker.MagicMock()
prisma_client.db.litellm_usertable = mocker.MagicMock()
prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=find_unique)
prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=identity_rows)
prisma_client.db.litellm_usertable.delete = AsyncMock(side_effect=delete)
prisma_client.db.litellm_teamtable = mocker.MagicMock()
prisma_client.db.litellm_teamtable.find_unique = AsyncMock(side_effect=team_lookup)
prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=team)
prisma_client.db.litellm_verificationtoken = mocker.MagicMock()
prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(side_effect=keys_for)
prisma_client.db.litellm_invitationlink = mocker.MagicMock(delete_many=AsyncMock(return_value=0))
prisma_client.db.litellm_organizationmembership = mocker.MagicMock(delete_many=AsyncMock(return_value=0))
prisma_client.db.litellm_teammembership = mocker.MagicMock(delete_many=AsyncMock(return_value=0))
return prisma_client
@pytest.fixture
def shadowed_tenant(mocker, monkeypatch, scim_upsert_user_enabled) -> MagicMock:
from litellm.proxy import proxy_server
prisma_client: Final = _shadow_tenant_prisma(mocker, rows=_shadowed_tenant_rows())
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
return prisma_client
async def _push_shadow_member(prisma_client: MagicMock):
return await _resolve_group_member_ids(
members=[SCIMMember(value=_SHADOW_MEMBER_VALUE)],
created_via="scim_group_membership",
prisma_client=prisma_client,
)
@pytest.mark.asyncio
async def test_merge_placeholder_hands_the_group_to_the_shadowed_account(mocker, shadowed_tenant):
"""Every group push of the shadowing value is refused until the placeholder is folded into
the real account; after the merge the same push resolves to that account."""
team_member_add_mock = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add", AsyncMock()
)
)
team_member_delete_mock = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", AsyncMock()
)
)
with pytest.raises(HTTPException) as before:
await _push_shadow_member(shadowed_tenant)
assert before.value.status_code == 400
result: Final = await merge_placeholder(user_id=_SHADOW_MEMBER_VALUE)
assert result == SCIMPlaceholderMergeResult(
placeholder_user_id=_SHADOW_MEMBER_VALUE,
merged_into_user_id=_SHADOWED_ACCOUNT,
team_ids=(_SHADOWED_GROUP,),
)
added: Final = team_member_add_mock.call_args.kwargs["data"]
assert (added.team_id, added.member.user_id) == (_SHADOWED_GROUP, _SHADOWED_ACCOUNT)
dropped: Final = team_member_delete_mock.call_args.kwargs["data"]
assert (dropped.team_id, dropped.user_id) == (_SHADOWED_GROUP, _SHADOW_MEMBER_VALUE)
shadowed_tenant.db.litellm_teammembership.delete_many.assert_awaited_once_with(
where={"user_id": _SHADOW_MEMBER_VALUE}
)
shadowed_tenant.db.litellm_usertable.delete.assert_awaited_once_with(where={"user_id": _SHADOW_MEMBER_VALUE})
after: Final = await _push_shadow_member(shadowed_tenant)
assert after.all_member_ids == [_SHADOWED_ACCOUNT]
assert after.created_users == []
@pytest.mark.asyncio
async def test_merge_placeholder_keeps_the_placeholder_when_the_roster_write_fails(mocker, shadowed_tenant):
"""If the real account cannot join the team, the placeholder stays on it, or the membership is gone
from both accounts."""
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add",
AsyncMock(side_effect=Exception("database connection lost")),
)
team_member_delete_mock = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_delete", AsyncMock()
)
)
with pytest.raises(ProxyException):
await merge_placeholder(user_id=_SHADOW_MEMBER_VALUE)
team_member_delete_mock.assert_not_awaited()
shadowed_tenant.db.litellm_usertable.delete.assert_not_awaited()
assert await shadowed_tenant.db.litellm_usertable.find_unique(where={"user_id": _SHADOW_MEMBER_VALUE}) is not None
@pytest.mark.parametrize(
("rows", "keys_owned_by", "merged", "reason"),
[
pytest.param(_shadowed_tenant_rows(), {}, _SHADOWED_ACCOUNT, "SSO identity of its own", id="real-account"),
pytest.param(
_shadowed_tenant_rows(), {_SHADOW_MEMBER_VALUE: 2}, _SHADOW_MEMBER_VALUE, "2 virtual keys", id="owns-keys"
),
pytest.param(_shadowed_tenant_rows()[:1], {}, _SHADOW_MEMBER_VALUE, "shadows no account", id="names-nobody"),
pytest.param(
(*_shadowed_tenant_rows(), LiteLLM_UserTable(user_id="real-2", user_email=_SHADOW_MEMBER_VALUE.upper())),
{},
_SHADOW_MEMBER_VALUE,
"names 2 accounts (real-1, real-2)",
id="names-two-accounts",
),
],
)
@pytest.mark.asyncio
async def test_merge_placeholder_refuses_rows_that_are_not_a_lone_placeholder(
mocker, monkeypatch, scim_upsert_user_enabled, rows, keys_owned_by, merged, reason
):
"""Only a row with no SSO identity and no keys whose id names exactly one other account is folded;
anything else could move memberships to the wrong person, so nothing is written."""
from litellm.proxy import proxy_server
prisma_client: Final = _shadow_tenant_prisma(mocker, rows=rows, keys_owned_by=keys_owned_by)
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
team_member_add_mock = (
mocker.patch( # test-quality-ok: roster helpers are module-level, not injectable into the endpoint
"litellm.proxy.management_endpoints.scim.scim_v2.team_member_add", AsyncMock()
)
)
with pytest.raises(ProxyException) as exc_info:
await merge_placeholder(user_id=merged)
assert int(exc_info.value.code) == 409
assert reason in str(exc_info.value.message)
team_member_add_mock.assert_not_awaited()
prisma_client.db.litellm_usertable.delete.assert_not_awaited()

View file

@ -13289,6 +13289,58 @@ export interface paths {
patch: operations["patch_user_scim_v2_Users__user_id__patch"];
trace?: never;
};
"/scim/v2/placeholders": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Placeholders
* @description List user rows whose id is another account's SSO identity or email.
*
* An earlier release provisioned a group member it could not match as a user keyed
* by the raw member value, and that row now shadows the account the value really
* names, so every push of that member is refused. This lists those rows so an
* operator can fold each one into the account it shadows with
* ``POST /scim/v2/placeholders/{user_id}/merge``. A row that has an SSO identity of
* its own or owns virtual keys is left out: someone uses that account.
*/
get: operations["list_placeholders_scim_v2_placeholders_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/scim/v2/placeholders/{user_id}/merge": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Merge Placeholder
* @description Fold a placeholder user into the one account its id names by SSO identity or email.
*
* The account is added to every team the placeholder is on, then the placeholder is
* deleted the way ``DELETE /scim/v2/Users/{id}`` deletes a user, so the next group
* push resolves the member value to the real account. Refused with 409 when the row
* has an SSO identity of its own, owns virtual keys, or names no account or several.
*/
post: operations["merge_placeholder_scim_v2_placeholders__user_id__merge_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/search": {
parameters: {
query?: never;
@ -34927,6 +34979,27 @@ export interface components {
/** Value */
value?: unknown | null;
};
/**
* SCIMPlaceholder
* @description A user row keyed by a value that names another account by SSO identity or email.
*/
SCIMPlaceholder: {
/** Placeholder User Id */
placeholder_user_id: string;
/** Resolved User Ids */
resolved_user_ids: string[];
/** Team Ids */
team_ids: string[];
};
/** SCIMPlaceholderMergeResult */
SCIMPlaceholderMergeResult: {
/** Merged Into User Id */
merged_into_user_id: string;
/** Placeholder User Id */
placeholder_user_id: string;
/** Team Ids */
team_ids: string[];
};
/** SCIMServiceProviderConfig */
SCIMServiceProviderConfig: {
/** Authenticationschemes */
@ -55858,6 +55931,70 @@ export interface operations {
};
};
};
list_placeholders_scim_v2_placeholders_get: {
parameters: {
query?: {
feature?: string | null;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SCIMPlaceholder"][];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
merge_placeholder_scim_v2_placeholders__user_id__merge_post: {
parameters: {
query?: {
feature?: string | null;
};
header?: never;
path: {
user_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SCIMPlaceholderMergeResult"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
search_search_post: {
parameters: {
query?: {