mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-02 07:13:02 +00:00
feat(scim): place SCIM groups in the organization a configured group attribute names
This commit is contained in:
parent
a1c5829f6b
commit
17f56db78b
6 changed files with 651 additions and 10 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
213
tests/integration/management/test_scim_group_organization.py
Normal file
213
tests/integration/management/test_scim_group_organization.py
Normal 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)
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
8
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue