diff --git a/litellm/__init__.py b/litellm/__init__.py index eebd2dad91e..08317090fc2 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -437,6 +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 default_team_settings: Optional[List] = None max_user_budget: Optional[float] = None default_max_internal_user_budget: Optional[float] = None diff --git a/litellm/constants.py b/litellm/constants.py index 8713bd49f57..4307f851d95 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1633,6 +1633,7 @@ SECRET_MANAGER_REFRESH_INTERVAL: Final = int(os.getenv("SECRET_MANAGER_REFRESH_I LITELLM_SETTINGS_SAFE_DB_OVERRIDES: Final = [ "default_internal_user_params", "default_team_params", + "scim_settings", "public_mcp_servers", "public_agent_groups", "public_model_groups", diff --git a/litellm/proxy/management_endpoints/scim/README_SCIM.md b/litellm/proxy/management_endpoints/scim/README_SCIM.md index eb628a78060..27a759c5128 100644 --- a/litellm/proxy/management_endpoints/scim/README_SCIM.md +++ b/litellm/proxy/management_endpoints/scim/README_SCIM.md @@ -60,6 +60,22 @@ https://your-litellm-proxy-url/scim/v2 Most identity providers will require authentication. You should use a valid LiteLLM API key with administrative privileges. +### Organization mappings + +SCIM-provisioned teams can be assigned to a LiteLLM organization based on the group's `displayName`. Configure `litellm_settings.scim_settings.organization_mappings` in the proxy config, or from the Admin UI under Settings -> SCIM: + +```yaml +litellm_settings: + scim_settings: + organization_mappings: + - group_display_name_pattern: "Engineering-.*" + organization_id: org-engineering + - group_display_name_pattern: "Sales" + organization_id: org-sales +``` + +`group_display_name_pattern` is a regex fully matched against the group displayName, so a plain group name works as an exact match. Mappings are evaluated in order and the first match wins. Groups that match no mapping keep the current behavior (`default_team_params.organization_id` if set, otherwise no organization). Mappings also apply when a group is renamed through PUT or PATCH; a rename that matches a mapping moves the team to that organization, and a rename that matches nothing leaves the team's organization unchanged. A mapping that points at an organization that does not exist fails the group write with a 400 that names the missing organization id + ## Features - Full CRUD operations for users and groups diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index 67370e3511c..a64435f75e4 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -59,6 +59,7 @@ from litellm.proxy.utils import ( _premium_user_check, handle_exception_on_proxy, ) +from litellm.repositories.organization_repository import OrganizationRepository from litellm.repositories.table_repositories import ( InvitationLinkRepository, OrganizationMembershipRepository, @@ -414,6 +415,57 @@ async def _get_scim_admin_group() -> str | None: return None +async def _get_scim_settings() -> SCIMSettings: + """ + Get the scim_settings from litellm_settings. + + An invalid or missing block resolves to empty settings so a config typo + degrades to the pre-feature behavior instead of failing every group write. + """ + try: + from litellm.proxy.proxy_server import proxy_config + + config: Final = await proxy_config.get_config() + litellm_settings: Final = config.get("litellm_settings", {}) or {} + raw_settings: Final = litellm_settings.get("scim_settings") + if not raw_settings: + return SCIMSettings() + return SCIMSettings.model_validate(raw_settings) + except ValidationError as e: + verbose_proxy_logger.warning("Invalid litellm_settings.scim_settings, ignoring: %s", e) + return SCIMSettings() + except Exception as e: # noqa: BLE001 # a config read failure degrades to no mappings instead of failing every group write + verbose_proxy_logger.warning("Error reading scim_settings, defaulting to empty: %s", e) + 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.""" + return next( + ( + mapping.organization_id + for mapping in settings.organization_mappings + if re.fullmatch(mapping.group_display_name_pattern, display_name) + ), + None, + ) + + +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.""" + organization_exists: Final = await OrganizationRepository(prisma_client).exists( + organization_id, id_field="organization_id" + ) + if not organization_exists: + raise HTTPException( + status_code=400, + detail={ + "error": f"Organization not found for organization_id={organization_id}. " + "Create the organization or fix litellm_settings.scim_settings.organization_mappings." + }, + ) + + def _resolve_scim_user_role( groups: list[SCIMUserGroup], admin_group: str | None, @@ -2410,6 +2462,10 @@ 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( + group.displayName, await _get_scim_settings() + ) + # Create team in database created_team: Final = await new_team( data=NewTeamRequest( @@ -2417,6 +2473,7 @@ async def create_group( team_alias=group.displayName, members_with_roles=members_with_roles, metadata={SCIM_MANAGED_TEAM_METADATA_KEY: True}, + organization_id=mapped_organization_id, ), http_request=Request(scope={"type": "http", "path": "/scim/v2/Groups"}), user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), @@ -2465,9 +2522,19 @@ async def update_group( SCIM_MANAGED_TEAM_METADATA_KEY: True, } + mapped_organization_id: Final = _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) + update_data: Final = { "team_alias": group.displayName, "metadata": safe_dumps(updated_metadata), + **({"organization_id": mapped_organization_id} if organization_changed else {}), } # Update team in database @@ -2733,6 +2800,18 @@ async def patch_group( intended_add: Final = final_members - snapshot_members intended_remove: Final = snapshot_members - final_members + 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( + 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) + update_data["organization_id"] = patch_mapped_organization_id + # Apply the metadata/displayName updates to the database updated_team = await _apply_group_patch_updates(group_id, update_data, prisma_client) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index a1eb7ed06eb..4291c096dc6 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -36,6 +36,7 @@ from litellm.repositories.table_repositories import ( UISettingsRepository, ) from litellm.repositories.team_repository import TeamRepository +from litellm.types.proxy.management_endpoints.scim_v2 import SCIMSettings from litellm.types.proxy.management_endpoints.ui_sso import ( DefaultTeamSSOParams, SSOConfig, @@ -179,6 +180,10 @@ class DefaultTeamSettingsResponse(SettingsResponse): """Response model for default team settings""" +class SCIMSettingsResponse(SettingsResponse): + """Response model for SCIM settings""" + + class UIThemeSettingsResponse(SettingsResponse): """Response model for UI theme settings""" @@ -778,7 +783,7 @@ async def update_default_team_member_budget(teams: list[NewUserRequestTeam], use async def _update_litellm_setting( - settings: DefaultInternalUserParams | DefaultTeamSSOParams | MCPSemanticFilterSettings, + settings: DefaultInternalUserParams | DefaultTeamSSOParams | MCPSemanticFilterSettings | SCIMSettings, settings_key: str, success_message: str, user_api_key_dict: UserAPIKeyAuth, @@ -900,6 +905,52 @@ async def update_default_team_settings( ) +@router.get( + "/get/scim_settings", + tags=["SCIM Settings"], + dependencies=[Depends(user_api_key_auth)], + response_model=SCIMSettingsResponse, +) +async def get_scim_settings(): + """ + Get the SCIM settings (litellm_settings.scim_settings). + Returns a structured object with values and descriptions for UI display. + """ + from litellm.proxy.proxy_server import proxy_config + + config: Final = await proxy_config.get_config() + + return await _get_settings_with_schema( + settings_key="scim_settings", + settings_class=SCIMSettings, + config=config, + ) + + +@router.patch( + "/update/scim_settings", + tags=["SCIM Settings"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_scim_settings( + settings: SCIMSettings, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection +): + """ + Update the SCIM settings (litellm_settings.scim_settings). + Organization mappings assign SCIM-provisioned teams to organizations by group displayName. + """ + for mapping in settings.organization_mappings: + await _validate_default_organization_exists(mapping.organization_id) + + return await _update_litellm_setting( + settings=settings, + settings_key="scim_settings", + success_message="SCIM settings updated successfully", + user_api_key_dict=user_api_key_dict, + ) + + @router.get( "/get/sso_settings", tags=["SSO Settings"], diff --git a/litellm/types/proxy/management_endpoints/scim_v2.py b/litellm/types/proxy/management_endpoints/scim_v2.py index 1612ea03817..bd0c1a399a9 100644 --- a/litellm/types/proxy/management_endpoints/scim_v2.py +++ b/litellm/types/proxy/management_endpoints/scim_v2.py @@ -1,3 +1,4 @@ +import re from typing import Any, Final, Literal, Optional, Union from fastapi import HTTPException @@ -31,6 +32,35 @@ class LiteLLM_UserScimMetadata(BaseModel): familyName: str | None = None +class SCIMGroupOrganizationMapping(BaseModel): + """Maps SCIM groups to a LiteLLM organization by their directory display name.""" + + group_display_name_pattern: str = Field( + description="Regex fully matched against the SCIM group displayName (a plain group name works as an exact match)", + ) + organization_id: str = Field( + description="Organization assigned to teams whose SCIM group displayName matches the pattern", + ) + + @field_validator("group_display_name_pattern") + @classmethod + def _validate_pattern_compiles(cls, v: str) -> str: + try: + re.compile(v) + except re.error as e: + raise ValueError(f"Invalid regex pattern '{v}': {e}") from e + return v + + +class SCIMSettings(BaseModel): + """litellm_settings.scim_settings: proxy-level SCIM provisioning behavior.""" + + organization_mappings: tuple[SCIMGroupOrganizationMapping, ...] = Field( + default=(), + description="Ordered displayName-to-organization mappings for SCIM-provisioned teams; the first matching entry wins", + ) + + # SCIM Resource Models class SCIMResource(BaseModel): schemas: list[str] diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py index 50a057c9b73..e1c657dc5d6 100644 --- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py @@ -49,10 +49,12 @@ from litellm.types.proxy.management_endpoints.scim_v2 import ( SCIM_MANAGED_TEAM_METADATA_KEY, SCIM_TEAM_DATA_METADATA_KEY, SCIMGroup, + SCIMGroupOrganizationMapping, SCIMMember, SCIMPatchOp, SCIMPatchOperation, SCIMServiceProviderConfig, + SCIMSettings, SCIMUser, SCIMUserEmail, SCIMUserGroup, @@ -5556,3 +5558,258 @@ async def test_patch_group_404s_when_team_deleted_mid_request(mocker): assert exc_info.value.code == "404" assert f"Group not found with ID: {group_id}" in exc_info.value.message + + +def _org_mapping_settings() -> SCIMSettings: + return SCIMSettings( + organization_mappings=[ + SCIMGroupOrganizationMapping(group_display_name_pattern="Engineering-.*", organization_id="org-eng"), + SCIMGroupOrganizationMapping(group_display_name_pattern=".*", organization_id="org-catchall"), + ] + ) + + +def _mock_group_prisma_client(mocker: MockerFixture, existing_team=None): + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_teamtable = mocker.MagicMock() + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=existing_team) + mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=existing_team) + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=mocker.MagicMock()) + mock_prisma_client.db.litellm_usertable.find_many = AsyncMock(return_value=()) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client), + ) + return mock_prisma_client + + +@pytest.mark.asyncio +async def test_create_group_assigns_first_matching_organization_mapping(mocker: MockerFixture): # test-quality-ok: asserts the NewTeamRequest payload new_team receives, the composed artifact + """POST /Groups with a matching scim_settings organization mapping must create + the team with the first matching organization_id.""" + from litellm.proxy.management_endpoints.scim.scim_transformations import ( + ScimTransformations, + ) + + scim_group = SCIMGroup( + schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], + id="team-eng-1", + displayName="Engineering-Platform", + members=[], + ) + + _mock_group_prisma_client(mocker) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._get_scim_settings", + AsyncMock(return_value=_org_mapping_settings()), + ) + new_team_mock = mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2.new_team", + AsyncMock(return_value=mocker.MagicMock()), + ) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles", + AsyncMock(), + ) + mocker.patch.object( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + ScimTransformations, + "transform_litellm_team_to_scim_group", + AsyncMock(return_value=scim_group), + ) + + await create_group(group=scim_group) + + assert new_team_mock.call_args.kwargs["data"].organization_id == "org-eng" + + +@pytest.mark.asyncio +async def test_create_group_without_matching_mapping_leaves_organization_unset(mocker: MockerFixture): # test-quality-ok: asserts the NewTeamRequest payload new_team receives, the composed artifact + """POST /Groups with no matching organization mapping must not set an organization.""" + from litellm.proxy.management_endpoints.scim.scim_transformations import ( + ScimTransformations, + ) + + scim_group = SCIMGroup( + schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], + id="team-sales-1", + displayName="Sales", + members=[], + ) + + _mock_group_prisma_client(mocker) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._get_scim_settings", + AsyncMock( + return_value=SCIMSettings( + organization_mappings=[ + SCIMGroupOrganizationMapping( + group_display_name_pattern="Engineering-.*", organization_id="org-eng" + ) + ] + ) + ), + ) + new_team_mock = mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2.new_team", + AsyncMock(return_value=mocker.MagicMock()), + ) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles", + AsyncMock(), + ) + mocker.patch.object( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + ScimTransformations, + "transform_litellm_team_to_scim_group", + AsyncMock(return_value=scim_group), + ) + + await create_group(group=scim_group) + + assert new_team_mock.call_args.kwargs["data"].organization_id is None + + +@pytest.mark.asyncio +async def test_update_group_rename_assigns_mapped_organization(mocker: MockerFixture): + """A PUT rename into a mapped display name must move the team into the mapped + organization after validating the organization exists.""" + from litellm.proxy._types import LiteLLM_TeamTable, Member + from litellm.proxy.management_endpoints.scim.scim_transformations import ( + ScimTransformations, + ) + + group_id = "team-1" + existing_team = LiteLLM_TeamTable( + team_id=group_id, + team_alias="Sales", + members=["user1"], + members_with_roles=[Member(user_id="user1", role="user")], + metadata={}, + ) + scim_group_update = SCIMGroup( + schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], + id=group_id, + displayName="Engineering-Platform", + members=[SCIMMember(value="user1")], + ) + + mock_prisma_client = _mock_group_prisma_client(mocker, existing_team=existing_team) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._get_scim_settings", + AsyncMock(return_value=_org_mapping_settings()), + ) + validate_mock = mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._validate_mapped_organization_exists", + AsyncMock(), + ) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2.patch_team_membership", + AsyncMock(), + ) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles", + AsyncMock(), + ) + mocker.patch.object( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + ScimTransformations, + "transform_litellm_team_to_scim_group", + AsyncMock(return_value=scim_group_update), + ) + + await update_group(group_id=group_id, group=scim_group_update) + + validate_mock.assert_awaited_once_with(mock_prisma_client, "org-eng") + update_call_data = mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs["data"] + assert update_call_data["organization_id"] == "org-eng" + + +@pytest.mark.asyncio +async def test_update_group_mapped_to_unknown_organization_returns_400(mocker: MockerFixture): + """A mapping to a nonexistent organization must fail with an actionable 400 + instead of writing a dangling organization id.""" + from litellm.proxy._types import LiteLLM_TeamTable, Member, ProxyException + from litellm.repositories.organization_repository import OrganizationRepository + + group_id = "team-1" + existing_team = LiteLLM_TeamTable( + team_id=group_id, + team_alias="Sales", + members=["user1"], + members_with_roles=[Member(user_id="user1", role="user")], + metadata={}, + ) + scim_group_update = SCIMGroup( + schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"], + id=group_id, + displayName="Engineering-Platform", + members=[SCIMMember(value="user1")], + ) + + mock_prisma_client = _mock_group_prisma_client(mocker, existing_team=existing_team) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._get_scim_settings", + AsyncMock(return_value=_org_mapping_settings()), + ) + exists_mock = AsyncMock(return_value=False) + mocker.patch.object(OrganizationRepository, "exists", exists_mock) # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + + with pytest.raises(ProxyException) as exc_info: + await update_group(group_id=group_id, group=scim_group_update) + + assert exc_info.value.code == "400" + assert "org-eng" in exc_info.value.message + mock_prisma_client.db.litellm_teamtable.update.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_patch_group_rename_assigns_mapped_organization(mocker: MockerFixture): + """A PATCH displayName op into a mapped name must move the team into the + mapped organization, mirroring the PUT path.""" + from litellm.proxy._types import LiteLLM_TeamTable, Member + from litellm.proxy.management_endpoints.scim.scim_transformations import ( + ScimTransformations, + ) + + group_id = "team-1" + existing_team = LiteLLM_TeamTable( + team_id=group_id, + team_alias="Sales", + members=["user1"], + members_with_roles=[Member(user_id="user1", role="user")], + metadata={}, + ) + patch_ops = SCIMPatchOp( + schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations=[SCIMPatchOperation(op="replace", path="displayName", value="Engineering-Platform")], + ) + + mock_prisma_client = _mock_group_prisma_client(mocker, existing_team=existing_team) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._get_scim_settings", + AsyncMock(return_value=_org_mapping_settings()), + ) + validate_mock = mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._validate_mapped_organization_exists", + AsyncMock(), + ) + mocker.patch( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + "litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles", + AsyncMock(), + ) + mocker.patch.object( # test-quality-ok: stubs the collaborator to pin the org mapping the endpoint computes + ScimTransformations, + "transform_litellm_team_to_scim_group", + AsyncMock(return_value=SCIMGroup(schemas=[], id=group_id, displayName="Engineering-Platform")), + ) + + await patch_group(group_id=group_id, patch_ops=patch_ops) + + validate_mock.assert_awaited_once_with(mock_prisma_client, "org-eng") + update_call_data = mock_prisma_client.db.litellm_teamtable.update.call_args.kwargs["data"] + assert update_call_data["organization_id"] == "org-eng" + + +def test_scim_settings_rejects_invalid_regex_pattern(): + with pytest.raises(ValueError, match="Invalid regex pattern"): + SCIMGroupOrganizationMapping(group_display_name_pattern="Engineering-[", organization_id="org-eng") diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index dc256ccf718..75f8c1b04f3 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -2980,6 +2980,50 @@ def test_update_default_team_settings_without_organization_skips_lookup( assert mock_proxy_config["save_call_count"]() == 1 +def test_update_scim_settings_rejects_unknown_organization(mock_proxy_config, mock_auth, mock_organization_lookup): + """A SCIM organization mapping to a nonexistent org must fail at save time, + not later during group provisioning.""" + mock_organization_lookup["existing_organization_ids"].add("real-org") + + resp = client.patch( + "/update/scim_settings", + json={"organization_mappings": [{"group_display_name_pattern": "Eng-.*", "organization_id": "ghost-org"}]}, + ) + + assert resp.status_code == 400, resp.text + assert "ghost-org" in resp.json()["detail"]["error"] + assert mock_proxy_config["save_call_count"]() == 0 + + +def test_update_scim_settings_saves_when_organizations_exist(mock_proxy_config, mock_auth, mock_organization_lookup): + """Mappings to real organizations save and round-trip through the GET endpoint.""" + mock_organization_lookup["existing_organization_ids"].add("real-org") + + resp = client.patch( + "/update/scim_settings", + json={"organization_mappings": [{"group_display_name_pattern": "Eng-.*", "organization_id": "real-org"}]}, + ) + + assert resp.status_code == 200, resp.text + assert resp.json()["settings"]["organization_mappings"][0]["organization_id"] == "real-org" + assert mock_proxy_config["save_call_count"]() == 1 + + get_resp = client.get("/get/scim_settings") + assert get_resp.status_code == 200, get_resp.text + assert get_resp.json()["values"]["organization_mappings"][0]["group_display_name_pattern"] == "Eng-.*" + + +def test_update_scim_settings_rejects_invalid_regex_pattern(mock_proxy_config, mock_auth, mock_organization_lookup): + """An invalid regex must be rejected by validation before anything is saved.""" + resp = client.patch( + "/update/scim_settings", + json={"organization_mappings": [{"group_display_name_pattern": "Eng-[", "organization_id": "real-org"}]}, + ) + + assert resp.status_code == 422, resp.text + assert mock_proxy_config["save_call_count"]() == 0 + + def test_update_mcp_semantic_filter_settings_requires_proxy_admin(monkeypatch): """Non-admin callers must not mutate global MCP semantic filter settings.""" from litellm.proxy._types import UserAPIKeyAuth diff --git a/ui/litellm-dashboard/src/components/SCIM.tsx b/ui/litellm-dashboard/src/components/SCIM.tsx index 12b47aeee6a..203d302176e 100644 --- a/ui/litellm-dashboard/src/components/SCIM.tsx +++ b/ui/litellm-dashboard/src/components/SCIM.tsx @@ -1,8 +1,8 @@ import React, { useState, useEffect } from "react"; import { z } from "zod/v4"; -import { keyCreateCall } from "./networking"; +import { getScimSettings, keyCreateCall, updateScimSettings } from "./networking"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { CircleAlert, CirclePlus, Copy, Info, KeyRound, Link } from "lucide-react"; +import { Building2, CircleAlert, CirclePlus, Copy, Info, KeyRound, Link, Trash2 } from "lucide-react"; import { parseErrorMessage } from "./shared/errorUtils"; import { toast } from "@/lib/toast"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; @@ -27,11 +27,30 @@ const scimTokenSchema = z.object({ type SCIMTokenFormValues = z.infer; +interface SCIMOrganizationMapping { + group_display_name_pattern: string; + organization_id: string; +} + const SCIMConfig: React.FC = ({ accessToken, userID, proxySettings }) => { const form = useZodForm(scimTokenSchema, { defaultValues: { key_alias: "" } }); const [isCreatingToken, setIsCreatingToken] = useState(false); const [tokenData, setTokenData] = useState(null); const [baseUrl, setBaseUrl] = useState(""); + const [orgMappings, setOrgMappings] = useState([]); + const [isSavingMappings, setIsSavingMappings] = useState(false); + + useEffect(() => { + if (!accessToken) return; + getScimSettings(accessToken) + .then((data) => { + const mappings = data?.values?.organization_mappings; + if (Array.isArray(mappings)) { + setOrgMappings(mappings); + } + }) + .catch((error) => console.error("Failed to load SCIM settings:", error)); + }, [accessToken]); useEffect(() => { let url = ""; @@ -48,6 +67,28 @@ const SCIMConfig: React.FC = ({ accessToken, userID, proxySetti const scimBaseUrl = `${baseUrl}/scim/v2`; + const handleSaveOrgMappings = async () => { + if (!accessToken) { + toast.fromError("You need to be logged in to update SCIM settings"); + return; + } + const incomplete = orgMappings.some((m) => !m.group_display_name_pattern.trim() || !m.organization_id.trim()); + if (incomplete) { + toast.fromError("Each mapping needs both a group name pattern and an organization ID"); + return; + } + try { + setIsSavingMappings(true); + await updateScimSettings(accessToken, { organization_mappings: orgMappings }); + toast.success("SCIM organization mappings saved"); + } catch (error: any) { + console.error("Error saving SCIM settings:", error); + toast.fromError("Failed to save SCIM settings: " + parseErrorMessage(error)); + } finally { + setIsSavingMappings(false); + } + }; + const handleCreateSCIMToken = async (values: SCIMTokenFormValues) => { if (!accessToken || !userID) { toast.fromError("You need to be logged in to create a SCIM token"); @@ -183,6 +224,79 @@ const SCIMConfig: React.FC = ({ accessToken, userID, proxySetti )} + + {/* Step 3: Organization Mappings */} +
+
+
3
+

+ + Organization Mappings +

+
+

+ Automatically assign SCIM-provisioned teams to organizations based on their group display name. The + pattern is a regex fully matched against the SCIM group displayName; a plain group name works as an + exact match. The first matching entry wins. +

+
+ {orgMappings.map((mapping, index) => ( +
+ + setOrgMappings((prev) => + prev.map((m, i) => (i === index ? { ...m, group_display_name_pattern: e.target.value } : m)), + ) + } + /> + + setOrgMappings((prev) => + prev.map((m, i) => (i === index ? { ...m, organization_id: e.target.value } : m)), + ) + } + /> + +
+ ))} +
+ + +
+
+
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 5b6d70b4771..3a79b88311c 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -5495,6 +5495,26 @@ export const updateDefaultTeamSettings = async (accessToken: string, settings: R } }; +export const getScimSettings = async (accessToken: string) => { + try { + const data = await apiClient.get(`/get/scim_settings`, { accessToken }); + return data; + } catch (error) { + console.error("Failed to fetch SCIM settings:", error); + throw error; + } +}; + +export const updateScimSettings = async (accessToken: string, settings: Record) => { + try { + const data = await apiClient.patch(`/update/scim_settings`, { accessToken, body: settings }); + return data; + } catch (error) { + console.error("Failed to update SCIM settings:", error); + throw error; + } +}; + export const getTeamPermissionsCall = async (accessToken: string, teamId: string) => { try { let url = proxyBaseUrl diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4ccf7b59fbc..8eaee1436d5 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -4585,6 +4585,27 @@ export interface paths { patch?: never; trace?: never; }; + "/get/scim_settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Scim Settings + * @description Get the SCIM settings (litellm_settings.scim_settings). + * Returns a structured object with values and descriptions for UI display. + */ + get: operations["get_scim_settings_get_scim_settings_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/get/sso_settings": { parameters: { query?: never; @@ -15271,6 +15292,27 @@ export interface paths { patch: operations["update_mcp_semantic_filter_settings_update_mcp_semantic_filter_settings_patch"]; trace?: never; }; + "/update/scim_settings": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update Scim Settings + * @description Update the SCIM settings (litellm_settings.scim_settings). + * Organization mappings assign SCIM-provisioned teams to organizations by group displayName. + */ + patch: operations["update_scim_settings_update_scim_settings_patch"]; + trace?: never; + }; "/update/sso_settings": { parameters: { query?: never; @@ -33015,6 +33057,22 @@ export interface components { /** Schemas */ schemas: string[]; }; + /** + * SCIMGroupOrganizationMapping + * @description Maps SCIM groups to a LiteLLM organization by their directory display name. + */ + SCIMGroupOrganizationMapping: { + /** + * Group Display Name Pattern + * @description Regex fully matched against the SCIM group displayName (a plain group name works as an exact match) + */ + group_display_name_pattern: string; + /** + * Organization Id + * @description Organization assigned to teams whose SCIM group displayName matches the pattern + */ + organization_id: string; + }; /** SCIMListResponse */ SCIMListResponse: { /** Resources */ @@ -33121,6 +33179,32 @@ export interface components { */ sort: components["schemas"]["SCIMFeature"]; }; + /** + * SCIMSettings + * @description litellm_settings.scim_settings: proxy-level SCIM provisioning behavior. + */ + SCIMSettings: { + /** + * Organization Mappings + * @description Ordered displayName-to-organization mappings for SCIM-provisioned teams; the first matching entry wins + * @default [] + */ + organization_mappings: components["schemas"]["SCIMGroupOrganizationMapping"][]; + }; + /** + * SCIMSettingsResponse + * @description Response model for SCIM settings + */ + SCIMSettingsResponse: { + /** Field Schema */ + field_schema: { + [key: string]: unknown; + }; + /** Values */ + values: { + [key: string]: unknown; + }; + }; /** SCIMUser */ SCIMUser: { /** @@ -43830,6 +43914,26 @@ export interface operations { }; }; }; + get_scim_settings_get_scim_settings_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SCIMSettingsResponse"]; + }; + }; + }; + }; get_sso_settings_get_sso_settings_get: { parameters: { query?: never; @@ -55696,6 +55800,39 @@ export interface operations { }; }; }; + update_scim_settings_update_scim_settings_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SCIMSettings"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; update_sso_settings_update_sso_settings_patch: { parameters: { query?: never;