mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
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:
parent
6d0367ce35
commit
2b616fc479
7 changed files with 659 additions and 23 deletions
|
|
@ -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, ...]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
137
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
137
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue