feat(scim): place SCIM groups in the organization a configured group attribute names

This commit is contained in:
jesus 2026-11-01 01:33:24 +00:00
parent a1c5829f6b
commit 17f56db78b
6 changed files with 651 additions and 10 deletions

View file

@ -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",

View file

@ -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 ``<extension URN>:<field>`` 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

View file

@ -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

View file

@ -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)

View file

@ -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()

View file

@ -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: {