fix(scim): bound org mapping regex evaluation and enforce destination org policy on team moves

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-08-27 02:14:38 +00:00
parent e9cf591352
commit f0f75f552c
3 changed files with 54 additions and 13 deletions

View file

@ -437,7 +437,7 @@ upperbound_key_generate_params: Optional[LiteLLM_UpperboundKeyGenerateParams] =
key_generation_settings: Optional["StandardKeyGenerationConfig"] = None
default_internal_user_params: Optional[Dict] = None
default_team_params: Optional[Union[DefaultTeamSSOParams, Dict]] = None
scim_settings: Optional[Dict] = None # mutable-ok: config loader assigns the parsed litellm_settings dict, like default_internal_user_params
scim_settings: Optional[Dict] = None # mutable-ok: config loader assigns the parsed dict
default_team_settings: Optional[List] = None
max_user_budget: Optional[float] = None
default_max_internal_user_budget: Optional[float] = None

View file

@ -4,6 +4,7 @@
This is an enterprise feature and requires a premium license.
"""
import asyncio
import re
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
@ -50,6 +51,7 @@ from litellm.proxy.management_endpoints.scim.scim_transformations import (
ScimTransformations,
)
from litellm.proxy.management_endpoints.team_endpoints import (
fetch_and_validate_organization,
new_team,
team_member_add,
team_member_delete,
@ -426,7 +428,7 @@ async def _get_scim_settings() -> SCIMSettings:
from litellm.proxy.proxy_server import proxy_config
config: Final = await proxy_config.get_config()
litellm_settings: Final = config.get("litellm_settings", {}) or {}
litellm_settings: Final = config.get("litellm_settings") or {}
raw_settings: Final = litellm_settings.get("scim_settings")
if not raw_settings:
return SCIMSettings()
@ -439,8 +441,10 @@ async def _get_scim_settings() -> SCIMSettings:
return SCIMSettings()
def _resolve_scim_group_organization_id(display_name: str, settings: SCIMSettings) -> str | None:
"""First organization mapping whose pattern fully matches the group displayName, or None."""
SCIM_ORG_MAPPING_MATCH_TIMEOUT_SECONDS: Final = 1.0
def _first_matching_organization_id(display_name: str, settings: SCIMSettings) -> str | None:
return next(
(
mapping.organization_id
@ -451,8 +455,38 @@ def _resolve_scim_group_organization_id(display_name: str, settings: SCIMSetting
)
async def _validate_mapped_organization_exists(prisma_client: PrismaClient, organization_id: str) -> None:
"""An unknown mapped organization is an admin config error; name it instead of writing a dangling id."""
async def _resolve_scim_group_organization_id(display_name: str, settings: SCIMSettings) -> str | None:
"""First organization mapping whose pattern fully matches the group displayName, or None.
Matching runs off the event loop with a timeout so a backtracking-heavy
pattern cannot stall SCIM and unrelated proxy requests.
"""
if not settings.organization_mappings:
return None
try:
return await asyncio.wait_for(
asyncio.to_thread(_first_matching_organization_id, display_name, settings),
timeout=SCIM_ORG_MAPPING_MATCH_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError as e:
raise HTTPException(
status_code=400,
detail={
"error": f"Evaluating litellm_settings.scim_settings.organization_mappings against group displayName '{display_name}' timed out. "
"Simplify the regex patterns to avoid catastrophic backtracking."
},
) from e
async def _validate_mapped_organization_exists(
prisma_client: PrismaClient, organization_id: str, existing_team: LiteLLM_TeamTable
) -> None:
"""An unknown mapped organization is an admin config error; name it instead of writing a dangling id.
A known organization must also pass the same destination policy checks as /team/update
(budget, models, tpm/rpm limits).
"""
from litellm.proxy.proxy_server import llm_router
organization_exists: Final = await OrganizationRepository(prisma_client).exists(
organization_id, id_field="organization_id"
)
@ -464,6 +498,13 @@ async def _validate_mapped_organization_exists(prisma_client: PrismaClient, orga
"Create the organization or fix litellm_settings.scim_settings.organization_mappings."
},
)
await fetch_and_validate_organization(
organization_id=organization_id,
existing_team_row=existing_team,
llm_router=llm_router,
prisma_client=prisma_client,
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN),
)
def _resolve_scim_user_role(
@ -2462,7 +2503,7 @@ async def create_group(
member_result: Final = await _extract_group_member_ids(group)
members_with_roles = [Member(user_id=member_id, role="user") for member_id in member_result.all_member_ids]
mapped_organization_id: Final = _resolve_scim_group_organization_id(
mapped_organization_id: Final = await _resolve_scim_group_organization_id(
group.displayName, await _get_scim_settings()
)
@ -2522,14 +2563,14 @@ async def update_group(
SCIM_MANAGED_TEAM_METADATA_KEY: True,
}
mapped_organization_id: Final = _resolve_scim_group_organization_id(
mapped_organization_id: Final = await _resolve_scim_group_organization_id(
group.displayName, await _get_scim_settings()
)
organization_changed: Final = (
mapped_organization_id is not None and mapped_organization_id != existing_team.organization_id
)
if organization_changed and mapped_organization_id is not None:
await _validate_mapped_organization_exists(prisma_client, mapped_organization_id)
await _validate_mapped_organization_exists(prisma_client, mapped_organization_id, existing_team)
update_data: Final = {
"team_alias": group.displayName,
@ -2802,14 +2843,14 @@ async def patch_group(
effective_alias: Final = update_data.get("team_alias", existing_team.team_alias)
if isinstance(effective_alias, str):
patch_mapped_organization_id: Final = _resolve_scim_group_organization_id(
patch_mapped_organization_id: Final = await _resolve_scim_group_organization_id(
effective_alias, await _get_scim_settings()
)
if (
patch_mapped_organization_id is not None
and patch_mapped_organization_id != existing_team.organization_id
):
await _validate_mapped_organization_exists(prisma_client, patch_mapped_organization_id)
await _validate_mapped_organization_exists(prisma_client, patch_mapped_organization_id, existing_team)
update_data["organization_id"] = patch_mapped_organization_id
# Apply the metadata/displayName updates to the database

View file

@ -5719,7 +5719,7 @@ async def test_update_group_rename_assigns_mapped_organization(mocker: MockerFix
await update_group(group_id=group_id, group=scim_group_update)
validate_mock.assert_awaited_once_with(mock_prisma_client, "org-eng")
validate_mock.assert_awaited_once_with(mock_prisma_client, "org-eng", existing_team)
update_call_data = mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs["data"]
assert update_call_data["organization_id"] == "org-eng"
@ -5805,7 +5805,7 @@ async def test_patch_group_rename_assigns_mapped_organization(mocker: MockerFixt
await patch_group(group_id=group_id, patch_ops=patch_ops)
validate_mock.assert_awaited_once_with(mock_prisma_client, "org-eng")
validate_mock.assert_awaited_once_with(mock_prisma_client, "org-eng", existing_team)
update_call_data = mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs["data"]
assert update_call_data["organization_id"] == "org-eng"