diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 5a80583b207..494263d73ef 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -54886,6 +54886,8 @@ "type": "object" }, "SCIMGroup": { + "additionalProperties": true, + "description": "Custom attributes stay on the model so ``scim_settings.organization_id_attribute``\ncan read the one that names the team's organization.", "properties": { "displayName": { "title": "Displayname", diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index e48f68a2d0a..cb0b0ae6d15 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -34,6 +34,7 @@ from litellm.constants import SCIM_GROUP_MEMBER_LOOKUP_BATCH_SIZE from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.models.user import SCIMPlaceholder from litellm.proxy._types import ( + LiteLLM_OrganizationTableWithMembers, LiteLLM_TeamTable, LiteLLM_UserTable, LitellmUserRoles, @@ -51,6 +52,7 @@ from litellm.proxy.auth.auth_checks import ( # noqa: F401 # legacy module expo _delete_cache_key_object, # pyright: ignore[reportPrivateUsage,reportUnusedImport] # backwards-compatible package export delete_cache_key_object, delete_cache_team_object, + get_org_object_by_alias, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import ( @@ -69,6 +71,7 @@ from litellm.proxy.management_endpoints.scim.scim_transformations import ( ScimTransformations, ) from litellm.proxy.management_endpoints.team_endpoints import ( + _auto_add_team_members_to_organization, # pyright: ignore[reportPrivateUsage] # same rule as /team/update delete_validated_team_rows, new_team, team_member_add, @@ -101,6 +104,7 @@ from litellm.proxy.utils import ( # noqa: F401 # legacy module exports premium_user_check, ) from litellm.repositories.chunked_in import find_many_in +from litellm.repositories.organization_repository import OrganizationRepository from litellm.repositories.team_repository import TeamRepository from litellm.repositories.user_repository import UserRepository from litellm.repositories.verification_token_repository import ( @@ -436,6 +440,23 @@ def _default_scim_user_role() -> ScimUserRole: return LitellmUserRoles.INTERNAL_USER_VIEW_ONLY +async def _loaded_proxy_config() -> object: + from litellm.proxy.proxy_server import proxy_config + + return await proxy_config.get_config() + + +async def _proxy_litellm_settings() -> Mapping[str, object]: + """``litellm_settings`` of the loaded proxy config, empty when it cannot be read so every + SCIM setting falls back to its default-off behaviour.""" + try: + config: Final = _json_object_fields(await _loaded_proxy_config()) or _NO_FIELDS + except Exception as e: + verbose_proxy_logger.warning("Error reading litellm_settings for SCIM, defaulting to none: %s", e) + return _NO_FIELDS + return _json_object_fields(config.get("litellm_settings")) or _NO_FIELDS + + async def _get_scim_admin_group() -> str | None: """ Get the scim_admin_group setting from litellm_settings. @@ -443,15 +464,91 @@ async def _get_scim_admin_group() -> str | None: Returns the configured admin group identifier, or None when unset so callers leave a user's global role untouched (default-safe). """ - try: - from litellm.proxy.proxy_server import proxy_config + admin_group: Final = (await _proxy_litellm_settings()).get("scim_admin_group") + return admin_group if isinstance(admin_group, str) and admin_group else None - config: Final = await proxy_config.get_config() - litellm_settings: Final = config.get("litellm_settings", {}) or {} - return litellm_settings.get("scim_admin_group") or None - except Exception as e: - verbose_proxy_logger.warning("Error reading scim_admin_group setting, defaulting to None: %s", e) + +async def _get_scim_organization_id_attribute() -> str | None: + """The SCIM Group attribute ``litellm_settings.scim_settings.organization_id_attribute`` names, + whose value is the alias of the organization the team belongs to. None when unset, so SCIM + keeps leaving a team's organization alone.""" + scim_settings: Final = _json_object_fields((await _proxy_litellm_settings()).get("scim_settings")) or _NO_FIELDS + attribute: Final = scim_settings.get("organization_id_attribute") + return attribute if isinstance(attribute, str) and attribute else None + + +def _scim_group_attribute_value(group: SCIMGroup, attribute: str) -> object | None: + """The value of a custom Group attribute, matched case-insensitively (RFC 7643 Section 2.1) + either as a top-level key or as ``:`` nested under its extension.""" + extras: Final[Mapping[str, object]] = group.model_extra or {} + wanted: Final = attribute.lower() + direct: Final = tuple(value for key, value in extras.items() if key.lower() == wanted) + if direct: + return direct[0] + urn, _, field = wanted.rpartition(":") + extensions: Final = tuple(value for key, value in extras.items() if key.lower() == urn) + extension: Final = _json_object_fields(extensions[0]) if extensions else None + if extension is None: return None + nested: Final = tuple(value for key, value in extension.items() if key.lower() == field) + return nested[0] if nested else None + + +def _patched_organization_alias(attribute: str, value: object, configured: str) -> tuple[bool, object]: + """Whether a PATCH (attribute, value) pair targets the configured organization attribute, either + by its full path or as a field of the extension object a path-less op carries, and the alias it sets.""" + wanted: Final = configured.lower() + if attribute == wanted: + return True, value + urn, _, field = wanted.rpartition(":") + extension: Final = _json_object_fields(value) if urn and attribute == urn else None + if extension is None: + return False, None + nested: Final = tuple(nested for key, nested in extension.items() if key.lower() == field) + return (True, nested[0]) if nested else (False, None) + + +async def _organization_id_for_alias(alias: str) -> str: + """Resolve an organization alias to its id, raising the SCIM 404 ``get_org_object_by_alias`` + builds when no organization carries it. Never creates one.""" + from litellm.proxy.proxy_server import prisma_client, user_api_key_cache + + organization: Final = await get_org_object_by_alias( + org_alias=alias, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache + ) + if organization is None or organization.organization_id is None: + raise HTTPException(status_code=404, detail={"error": f"Organization with alias '{alias}' not found"}) + return organization.organization_id + + +async def _organization_id_for_scim_group(group: SCIMGroup) -> str | None: + """The organization_id the configured Group attribute assigns, or None when the setting or the + attribute is absent so the team keeps today's behaviour (default_team_params on create, untouched on PUT).""" + attribute: Final = await _get_scim_organization_id_attribute() + if attribute is None: + return None + alias: Final = _scim_group_attribute_value(group, attribute) + if not isinstance(alias, str) or not alias: + return None + return await _organization_id_for_alias(alias) + + +async def _add_group_members_to_organization(team: LiteLLM_TeamTable, prisma_client: PrismaClient) -> None: + """Pull the team's members into its organization the way /team/update does for proxy admins.""" + if team.organization_id is None: + return + organization_row: Final = await OrganizationRepository(prisma_client).table.find_unique( + where={"organization_id": team.organization_id}, include={"members": True} + ) + if organization_row is None: + return + await _auto_add_team_members_to_organization( + team=team, + organization=LiteLLM_OrganizationTableWithMembers.model_validate( + organization_row.model_dump(exclude={"teams"}) + ), + prisma_client=prisma_client, + ) def _resolve_scim_user_role( @@ -2942,9 +3039,11 @@ def _new_team_request_with_defaults( team_id: str, team_alias: str | None, members_with_roles: Sequence[Member], + organization_id: str | None = None, ) -> NewTeamRequest: """Build the SCIM group's team request, applying litellm.default_team_params - (including models) the same way SSO auto-created teams do.""" + (including models) the same way SSO auto-created teams do. An organization the + SCIM group names overrides default_team_params.organization_id.""" default_params: Final = litellm.default_team_params defaults: Final[Mapping[str, object]] = ( deepcopy(default_params) @@ -2958,9 +3057,11 @@ def _new_team_request_with_defaults( **(default_metadata if isinstance(default_metadata, dict) else {}), SCIM_MANAGED_TEAM_METADATA_KEY: True, } + organization: Final = {"organization_id": organization_id} if organization_id is not None else {} return NewTeamRequest.model_validate( { **defaults, + **organization, "team_id": team_id, "team_alias": team_alias, "members_with_roles": members_with_roles, @@ -3002,12 +3103,14 @@ async def create_group( # Extract and validate group members (all users must exist) member_result: Final = await _extract_group_member_ids(group) + organization_id: Final = await _organization_id_for_scim_group(group) created_team: Final = await new_team( data=_new_team_request_with_defaults( team_id=team_id, team_alias=group.displayName, members_with_roles=(), + organization_id=organization_id, ), http_request=Request(scope={"type": "http", "path": "/scim/v2/Groups"}), user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), @@ -3017,6 +3120,8 @@ async def create_group( if member_result.all_member_ids else LiteLLM_TeamTable.model_validate(created_team) ) + if organization_id is not None: + await _add_group_members_to_organization(team, prisma_client) await _recompute_scim_member_roles(prisma_client, member_result.all_member_ids) return await ScimTransformations.transform_litellm_team_to_scim_group(team) except Exception as e: @@ -3058,9 +3163,11 @@ async def update_group( SCIM_MANAGED_TEAM_METADATA_KEY: True, } + organization_id: Final = await _organization_id_for_scim_group(group) update_data: Final = { "team_alias": group.displayName, "metadata": safe_dumps(updated_metadata), + **({"organization_id": organization_id} if organization_id is not None else {}), } # Update team in database @@ -3080,6 +3187,8 @@ async def update_group( # A rename can flip whether this group matches scim_admin_group by display # name, so retained members must be re-resolved too, not just the ones whose # membership changed. + if organization_id is not None: + await _add_group_members_to_organization(sync.team, prisma_client) alias_changed: Final = existing_team.team_alias != group.displayName await _recompute_scim_member_roles(prisma_client, _roles_to_recompute(sync, alias_changed)) @@ -3245,12 +3354,24 @@ async def _process_group_patch_operations( # everyone already in the team. current_members: Final = set(await _get_team_member_user_ids_from_team(existing_team)) final_members = current_members.copy() + organization_attribute: Final = await _get_scim_organization_id_attribute() for op in patch_ops.Operations: for attribute, value in _group_patch_attribute_values(op): op_type = op.op + targets_organization, organization_alias = ( + _patched_organization_alias(attribute, value, organization_attribute) + if organization_attribute is not None + else (False, None) + ) - if attribute == "displayname": + if targets_organization: + update_data["organization_id"] = ( + None + if op_type == "remove" or not organization_alias + else await _organization_id_for_alias(str(organization_alias)) + ) + elif attribute == "displayname": if op_type == "remove": update_data["team_alias"] = None else: @@ -3478,6 +3599,8 @@ async def patch_group( await _apply_group_patch_updates(group_id, update_data, prisma_client, existing_team.team_alias) sync: Final = await _sync_group_roster(group_id, plan, "scim_group_patch", prisma_client) + if update_data.get("organization_id") is not None: + await _add_group_members_to_organization(sync.team, prisma_client) # A rename can flip whether this group matches scim_admin_group by display # name, so retained members must be re-resolved too, not just the ones whose diff --git a/litellm/types/proxy/management_endpoints/scim_v2.py b/litellm/types/proxy/management_endpoints/scim_v2.py index fadb309e0cd..2b42cfa4065 100644 --- a/litellm/types/proxy/management_endpoints/scim_v2.py +++ b/litellm/types/proxy/management_endpoints/scim_v2.py @@ -152,6 +152,11 @@ class SCIMMember(LiteLLMBaseModel): class SCIMGroup(SCIMResource): + """Custom attributes stay on the model so ``scim_settings.organization_id_attribute`` + can read the one that names the team's organization.""" + + model_config = ConfigDict(extra="allow") + displayName: str members: list[SCIMMember] | None = None diff --git a/tests/integration/management/test_scim_group_organization.py b/tests/integration/management/test_scim_group_organization.py new file mode 100644 index 00000000000..fbe2ed2795c --- /dev/null +++ b/tests/integration/management/test_scim_group_organization.py @@ -0,0 +1,213 @@ +import uuid +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +import httpx +import pytest +import yaml +from integration._support.client import Gateway, gateway_from_environment, object_value +from integration._support.database import read_rows +from integration._support.process import owned_proxy_process +from pydantic import JsonValue, TypeAdapter + +pytestmark: Final = pytest.mark.shares_worker_state + +SCIM_HEADERS: Final = {"Content-Type": "application/scim+json"} +SCIM_CORE_GROUP_SCHEMA: Final = "urn:ietf:params:scim:schemas:core:2.0:Group" +SCIM_PATCH_SCHEMA: Final = "urn:ietf:params:scim:api:messages:2.0:PatchOp" +SCIM_CONFIG: Final = TypeAdapter(dict[str, JsonValue]) +ORGANIZATION_ATTRIBUTE: Final = "org_id" + + +@dataclass(frozen=True, slots=True) +class OrganizationMappingRig: + gateway: Gateway + engineering_alias: str + engineering_id: str + finance_alias: str + finance_id: str + member: str + + +def _owned_scim_config(path: Path, settings: Mapping[str, JsonValue]) -> Path: + config: Final = object_value( + SCIM_CONFIG.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + ) + litellm_settings: Final = object_value(config["litellm_settings"]) + path.write_text(yaml.safe_dump({**config, "litellm_settings": {**litellm_settings, **settings}})) + return path + + +def _delete_group_if_present(gateway: Gateway, group_id: str) -> None: + if read_rows('SELECT team_id FROM "LiteLLM_TeamTable" WHERE team_id = %s', (group_id,)): + deleted: Final = gateway.request("DELETE", f"/scim/v2/Groups/{group_id}", headers=SCIM_HEADERS) + assert deleted.status_code == 204, deleted.text + assert read_rows('SELECT team_id FROM "LiteLLM_TeamTable" WHERE team_id = %s', (group_id,)) == [] + + +def _team_organization(team_id: str) -> str | None: + rows: Final = read_rows('SELECT organization_id FROM "LiteLLM_TeamTable" WHERE team_id = %s', (team_id,)) + assert len(rows) == 1, rows + organization_id: Final = rows[0]["organization_id"] + assert organization_id is None or isinstance(organization_id, str), rows + return organization_id + + +def _organization_memberships(user_id: str) -> set[str]: + rows: Final = read_rows( + 'SELECT organization_id FROM "LiteLLM_OrganizationMembership" WHERE user_id = %s', (user_id,) + ) + return {str(row["organization_id"]) for row in rows} + + +def _group_body( + group_id: str, display_name: str, members: tuple[str, ...], **attributes: JsonValue +) -> dict[str, JsonValue]: + return { + "schemas": [SCIM_CORE_GROUP_SCHEMA], + "id": group_id, + "displayName": display_name, + "members": [{"value": member} for member in members], + **attributes, + } + + +def _patch_body(*operations: dict[str, JsonValue]) -> dict[str, JsonValue]: + return {"schemas": [SCIM_PATCH_SCHEMA], "Operations": list(operations)} + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[OrganizationMappingRig]: + directory: Final = tmp_path_factory.mktemp("scim-group-organization") + config: Final = _owned_scim_config( + directory / "config.yaml", {"scim_settings": {"organization_id_attribute": ORGANIZATION_ATTRIBUTE}} + ) + with ( + gateway_from_environment() as gateway, + owned_proxy_process(gateway, directory, {}, config=config) as owned, + owned.gateway.scenario() as scenario, + ): + suffix: Final = uuid.uuid4().hex[:8] + engineering_alias: Final = f"engineering-{suffix}" + finance_alias: Final = f"finance-{suffix}" + yield OrganizationMappingRig( + gateway=owned.gateway, + engineering_alias=engineering_alias, + engineering_id=scenario.organization(organization_alias=engineering_alias), + finance_alias=finance_alias, + finance_id=scenario.organization(organization_alias=finance_alias), + member=scenario.user(user_role="internal_user"), + ) + + +def _create_group(rig: OrganizationMappingRig, group_id: str, **attributes: JsonValue) -> httpx.Response: + return rig.gateway.request( + "POST", + "/scim/v2/Groups", + _group_body(group_id, f"Infrastructure {group_id[:8]}", (rig.member,), **attributes), + headers=SCIM_HEADERS, + ) + + +@pytest.mark.timeout(300) +def test_scim_group_post_places_the_team_in_the_organization_the_attribute_names(rig: OrganizationMappingRig) -> None: + group_id: Final = str(uuid.uuid4()) + try: + created: Final = _create_group(rig, group_id, org_id=rig.engineering_alias) + assert created.status_code == 201, created.text + assert _team_organization(group_id) == rig.engineering_id + assert rig.engineering_id in _organization_memberships(rig.member) + info: Final = rig.gateway.request("GET", f"/team/info?team_id={group_id}") + assert info.status_code == 200, info.text + assert info.json()["team_info"]["organization_id"] == rig.engineering_id, info.text + finally: + _delete_group_if_present(rig.gateway, group_id) + + +@pytest.mark.timeout(300) +def test_scim_group_post_without_the_attribute_leaves_the_team_outside_any_organization( + rig: OrganizationMappingRig, +) -> None: + group_id: Final = str(uuid.uuid4()) + try: + created: Final = _create_group(rig, group_id, department=rig.engineering_alias) + assert created.status_code == 201, created.text + assert _team_organization(group_id) is None + finally: + _delete_group_if_present(rig.gateway, group_id) + + +@pytest.mark.timeout(300) +def test_scim_group_post_with_an_unknown_organization_alias_is_a_404_and_creates_no_team( + rig: OrganizationMappingRig, +) -> None: + group_id: Final = str(uuid.uuid4()) + missing_alias: Final = f"missing-{uuid.uuid4().hex[:8]}" + created: Final = _create_group(rig, group_id, org_id=missing_alias) + assert created.status_code == 404, created.text + assert missing_alias in created.text + assert read_rows('SELECT team_id FROM "LiteLLM_TeamTable" WHERE team_id = %s', (group_id,)) == [] + assert ( + read_rows( + 'SELECT organization_id FROM "LiteLLM_OrganizationTable" WHERE organization_alias = %s', (missing_alias,) + ) + == [] + ) + + +@pytest.mark.timeout(300) +def test_scim_group_put_and_patch_move_the_team_between_organizations(rig: OrganizationMappingRig) -> None: + group_id: Final = str(uuid.uuid4()) + try: + created: Final = _create_group(rig, group_id, org_id=rig.engineering_alias) + assert created.status_code == 201, created.text + + replaced: Final = rig.gateway.request( + "PUT", + f"/scim/v2/Groups/{group_id}", + _group_body(group_id, "Infrastructure renamed", (rig.member,), org_id=rig.finance_alias), + headers=SCIM_HEADERS, + ) + assert replaced.status_code == 200, replaced.text + assert _team_organization(group_id) == rig.finance_id + assert rig.finance_id in _organization_memberships(rig.member) + + kept: Final = rig.gateway.request( + "PUT", + f"/scim/v2/Groups/{group_id}", + _group_body(group_id, "Infrastructure renamed", (rig.member,)), + headers=SCIM_HEADERS, + ) + assert kept.status_code == 200, kept.text + assert _team_organization(group_id) == rig.finance_id, "a PUT without the attribute leaves the organization" + + patched: Final = rig.gateway.request( + "PATCH", + f"/scim/v2/Groups/{group_id}", + _patch_body({"op": "replace", "path": ORGANIZATION_ATTRIBUTE, "value": rig.engineering_alias}), + headers=SCIM_HEADERS, + ) + assert patched.status_code == 200, patched.text + assert _team_organization(group_id) == rig.engineering_id + + unknown: Final = rig.gateway.request( + "PATCH", + f"/scim/v2/Groups/{group_id}", + _patch_body({"op": "replace", "path": ORGANIZATION_ATTRIBUTE, "value": "missing-org"}), + headers=SCIM_HEADERS, + ) + assert unknown.status_code == 404, unknown.text + assert _team_organization(group_id) == rig.engineering_id + + removed: Final = rig.gateway.request( + "PATCH", + f"/scim/v2/Groups/{group_id}", + _patch_body({"op": "remove", "path": ORGANIZATION_ATTRIBUTE}), + headers=SCIM_HEADERS, + ) + assert removed.status_code == 200, removed.text + assert _team_organization(group_id) is None + finally: + _delete_group_if_present(rig.gateway, group_id) diff --git a/tests/unit/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/unit/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index 180362f45f1..25ae6933f4f 100644 --- a/tests/unit/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/unit/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -4,6 +4,7 @@ import json import logging import time from collections.abc import Awaitable, Callable, Mapping, Sequence +from datetime import datetime, timezone from itertools import chain from types import MappingProxyType from typing import Final, assert_never @@ -16,6 +17,7 @@ from pytest_mock import MockerFixture import litellm.proxy.management_endpoints.scim.scim_v2 as scim_v2_module from litellm.proxy._types import ( + LiteLLM_OrganizationTableWithMembers, LiteLLM_TeamTable, LiteLLM_UserTable, LitellmUserRoles, @@ -7066,3 +7068,293 @@ async def test_group_push_of_500_members_reads_the_user_table_once_and_writes_th assert prisma_client.db.litellm_usertable.find_many.await_count == 1 prisma_client.db.litellm_usertable.find_unique.assert_not_awaited() prisma_client.db.litellm_teamtable.find_many.assert_not_awaited() + + +_ORG_ATTRIBUTE_SETTINGS: Final = {"scim_settings": {"organization_id_attribute": "org_id"}} +_LITELLM_GROUP_URN: Final = "urn:ietf:params:scim:schemas:extension:litellm:2.0:Group" + + +def _organization(alias: str, organization_id: str) -> LiteLLM_OrganizationTableWithMembers: + """What the organization table answers for an organization with no members yet.""" + return LiteLLM_OrganizationTableWithMembers( + organization_id=organization_id, + organization_alias=alias, + budget_id="budget-1", + created_by="admin", + updated_by="admin", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + updated_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + + +def _organization_mapping_mocks( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + litellm_settings: Mapping[str, object], + existing_team: LiteLLM_TeamTable | None, +) -> tuple[MagicMock, AsyncMock, AsyncMock]: + """Prisma, alias lookup and org auto-add doubles for a Group write under ``litellm_settings``. + The alias lookup answers org-123 for "engineering" and the 404 ``get_org_object_by_alias`` + raises for anything else.""" + from litellm.proxy.proxy_server import proxy_config + + async def mock_get_config(): + return {"litellm_settings": dict(litellm_settings)} + + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + monkeypatch.setattr("litellm.default_team_params", None, raising=False) + + async def lookup( + org_alias: str, prisma_client: object, user_api_key_cache: object + ) -> LiteLLM_OrganizationTableWithMembers: + if org_alias == "engineering": + return _organization(org_alias, "org-123") + raise HTTPException(status_code=404, detail={"error": f"Organization with alias '{org_alias}' doesn't exist"}) + + lookup_mock = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.get_org_object_by_alias", AsyncMock(side_effect=lookup) + ) + auto_add_mock = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._auto_add_team_members_to_organization", AsyncMock() + ) + + prisma_client = mocker.MagicMock() + prisma_client.db = mocker.MagicMock() + prisma_client.db.litellm_teamtable = mocker.MagicMock() + prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team) + prisma_client.db.litellm_teamtable.find_many = AsyncMock(return_value=()) + prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=existing_team) + prisma_client.db.litellm_usertable = mocker.MagicMock() + prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock()) + prisma_client.db.litellm_usertable.find_many = AsyncMock(side_effect=_rows_by_exact_id(_user_row_for)) + prisma_client.db.litellm_organizationtable = mocker.MagicMock() + prisma_client.db.litellm_organizationtable.find_unique = AsyncMock( + return_value=_organization("engineering", "org-123") + ) + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=prisma_client), + ) + mocker.patch("litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles", AsyncMock()) + return prisma_client, lookup_mock, auto_add_mock + + +def _org_team(members: Sequence[str], organization_id: str | None = None) -> LiteLLM_TeamTable: + return LiteLLM_TeamTable( + team_id="org-group-1", + team_alias="Infrastructure", + members_with_roles=[Member(user_id=member, role="user") for member in members], + metadata={}, + organization_id=organization_id, + ) + + +def _org_group(**attributes: object) -> SCIMGroup: + return SCIMGroup.model_validate( + { + "schemas": ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "id": "org-group-1", + "displayName": "Infrastructure", + "members": [{"value": "user1"}], + **attributes, + } + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("litellm_settings", "attributes"), + [ + (_ORG_ATTRIBUTE_SETTINGS, {"org_id": "engineering"}), + (_ORG_ATTRIBUTE_SETTINGS, {"ORG_ID": "engineering"}), + ( + {"scim_settings": {"organization_id_attribute": f"{_LITELLM_GROUP_URN}:org_id"}}, + {_LITELLM_GROUP_URN: {"org_id": "engineering"}}, + ), + ], + ids=["top-level", "case-insensitive", "extension-urn"], +) +async def test_create_group_places_team_in_organization_named_by_configured_attribute( # test-quality-ok: new_team is the DB boundary, its organization_id argument is the effect + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + litellm_settings: Mapping[str, object], + attributes: Mapping[str, object], +) -> None: + """POST /Groups with ``scim_settings.organization_id_attribute`` set resolves the attribute's + value as an organization alias and creates the team inside that organization, pulling the + members in as org members.""" + from litellm.proxy.management_endpoints.scim.scim_transformations import ScimTransformations + + group = _org_group(**attributes) + prisma_client, _, auto_add_mock = _organization_mapping_mocks(mocker, monkeypatch, litellm_settings, None) + prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None) + new_team_mock = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.new_team", + AsyncMock(return_value=_org_team((), "org-123").model_dump()), + ) + _stub_roster_sync(mocker, _org_team(("user1",), "org-123")) + mocker.patch.object(ScimTransformations, "transform_litellm_team_to_scim_group", AsyncMock(return_value=group)) + + await create_group(group=group) + + assert new_team_mock.await_args.kwargs["data"].organization_id == "org-123" + auto_add_mock.assert_awaited_once() + assert auto_add_mock.await_args.kwargs["team"].organization_id == "org-123" + assert auto_add_mock.await_args.kwargs["organization"].organization_id == "org-123" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("litellm_settings", "attributes"), + [ + ({}, {"org_id": "engineering"}), + (_ORG_ATTRIBUTE_SETTINGS, {}), + (_ORG_ATTRIBUTE_SETTINGS, {"org_id": ""}), + (_ORG_ATTRIBUTE_SETTINGS, {"department": "engineering"}), + ], + ids=["setting-unset", "attribute-absent", "attribute-empty", "other-attribute"], +) +async def test_create_group_without_the_mapping_keeps_default_team_params_organization( # test-quality-ok: new_team is the DB boundary, no organization_id reaching it is the effect + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + litellm_settings: Mapping[str, object], + attributes: Mapping[str, object], +) -> None: + """The mapping is opt-in: without the setting, or when the group lacks the attribute, the + team request carries no organization so new_team applies default_team_params as today.""" + from litellm.proxy.management_endpoints.scim.scim_transformations import ScimTransformations + + group = _org_group(**attributes) + prisma_client, lookup_mock, auto_add_mock = _organization_mapping_mocks( + mocker, monkeypatch, litellm_settings, None + ) + prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None) + new_team_mock = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.new_team", + AsyncMock(return_value=_org_team(()).model_dump()), + ) + _stub_roster_sync(mocker, _org_team(("user1",))) + mocker.patch.object(ScimTransformations, "transform_litellm_team_to_scim_group", AsyncMock(return_value=group)) + + await create_group(group=group) + + assert new_team_mock.await_args.kwargs["data"].organization_id is None + lookup_mock.assert_not_awaited() + auto_add_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_create_group_with_an_unknown_organization_alias_answers_404_and_creates_nothing( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + """An attribute naming an organization that does not exist is a 404 SCIM error. Neither the + team nor an organization gets created.""" + group = _org_group(org_id="nonexistent") + prisma_client, _, _ = _organization_mapping_mocks(mocker, monkeypatch, _ORG_ATTRIBUTE_SETTINGS, None) + prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None) + new_team_mock = mocker.patch("litellm.proxy.management_endpoints.scim.scim_v2.new_team", AsyncMock()) + sync_mock = _stub_roster_sync(mocker) + + with pytest.raises(ProxyException) as raised: + await create_group(group=group) + + assert raised.value.code == "404" + assert "nonexistent" in str(raised.value.message) + new_team_mock.assert_not_awaited() + sync_mock.assert_not_awaited() + prisma_client.db.litellm_organizationtable.create.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("attributes", "expected_organization_id", "members_pulled_in"), + [ + ({"org_id": "engineering"}, "org-123", True), + ({}, "org-before", False), + ], + ids=["attribute-moves-team", "absent-attribute-leaves-organization"], +) +async def test_update_group_moves_team_to_the_organization_the_attribute_names( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + attributes: Mapping[str, object], + expected_organization_id: str, + members_pulled_in: bool, +) -> None: + """PUT /Groups/{id} carrying the configured attribute writes the resolved organization_id + onto the team and adds the synced roster to that organization. A PUT without the attribute + leaves whatever organization the team already has.""" + from litellm.proxy.management_endpoints.scim.scim_transformations import ScimTransformations + + group = _org_group(**attributes) + existing_team = _org_team(("user1",), "org-before") + prisma_client, _, auto_add_mock = _organization_mapping_mocks( + mocker, monkeypatch, _ORG_ATTRIBUTE_SETTINGS, existing_team + ) + _stub_roster_sync(mocker, _org_team(("user1",), expected_organization_id)) + mocker.patch.object(ScimTransformations, "transform_litellm_team_to_scim_group", AsyncMock(return_value=group)) + + await update_group(group_id="org-group-1", group=group) + + written = prisma_client.db.litellm_teamtable.update.await_args.kwargs["data"] + assert written.get("organization_id", "org-before") == expected_organization_id + assert auto_add_mock.await_count == (1 if members_pulled_in else 0) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("operation", "expected_organization_id"), + [ + ({"op": "replace", "path": "org_id", "value": "engineering"}, "org-123"), + ({"op": "add", "value": {"org_id": "engineering"}}, "org-123"), + ({"op": "replace", "path": f"{_LITELLM_GROUP_URN}:org_id", "value": "engineering"}, "org-123"), + ({"op": "remove", "path": "org_id"}, None), + ], + ids=["replace-path", "pathless-add", "urn-path-with-urn-setting", "remove-clears"], +) +async def test_patch_group_sets_or_clears_the_organization_the_attribute_names( + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, + operation: Mapping[str, object], + expected_organization_id: str | None, +) -> None: + """PATCH /Groups/{id} on the configured attribute resolves the alias into organization_id + (add/replace, by path, path-less or under an extension URN) and a remove clears it.""" + path = operation.get("path") + setting = str(path) if isinstance(path, str) and path.startswith("urn:") else "org_id" + existing_team = _org_team(("user1",), "org-before") + prisma_client, _, auto_add_mock = _organization_mapping_mocks( + mocker, monkeypatch, {"scim_settings": {"organization_id_attribute": setting}}, existing_team + ) + patch_ops = SCIMPatchOp.model_validate( + {"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], "Operations": [dict(operation)]} + ) + + update_data, final_members, _ = await _process_group_patch_operations(patch_ops, existing_team, prisma_client) + + assert update_data["organization_id"] == expected_organization_id + assert final_members == {"user1"} + assert "org_id" not in update_data["metadata"] + auto_add_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_patch_group_with_an_unknown_organization_alias_answers_404_and_writes_nothing( + mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +) -> None: + existing_team = _org_team(("user1",), "org-before") + prisma_client, _, _ = _organization_mapping_mocks(mocker, monkeypatch, _ORG_ATTRIBUTE_SETTINGS, existing_team) + sync_mock = _stub_roster_sync(mocker, existing_team) + patch_ops = SCIMPatchOp.model_validate( + { + "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations": [{"op": "replace", "path": "org_id", "value": "nonexistent"}], + } + ) + + with pytest.raises(ProxyException) as raised: + await patch_group(group_id="org-group-1", patch_ops=patch_ops) + + assert raised.value.code == "404" + prisma_client.db.litellm_teamtable.update.assert_not_awaited() + sync_mock.assert_not_awaited() diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 96d4c0e1bc4..af973e36080 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -45163,7 +45163,11 @@ export interface components { /** Supported */ supported: boolean; }; - /** SCIMGroup */ + /** + * SCIMGroup + * @description Custom attributes stay on the model so ``scim_settings.organization_id_attribute`` + * can read the one that names the team's organization. + */ SCIMGroup: { /** Displayname */ displayName: string; @@ -45179,6 +45183,8 @@ export interface components { } | null; /** Schemas */ schemas: string[]; + } & { + [key: string]: unknown; }; /** SCIMListResponse */ SCIMListResponse: {