mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
feat(scim): assign SCIM-provisioned teams to organizations from group display name mappings
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
f677292901
commit
c7bb52914d
11 changed files with 753 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<typeof scimTokenSchema>;
|
||||
|
||||
interface SCIMOrganizationMapping {
|
||||
group_display_name_pattern: string;
|
||||
organization_id: string;
|
||||
}
|
||||
|
||||
const SCIMConfig: React.FC<SCIMConfigProps> = ({ accessToken, userID, proxySettings }) => {
|
||||
const form = useZodForm(scimTokenSchema, { defaultValues: { key_alias: "" } });
|
||||
const [isCreatingToken, setIsCreatingToken] = useState(false);
|
||||
const [tokenData, setTokenData] = useState<any>(null);
|
||||
const [baseUrl, setBaseUrl] = useState("<your_proxy_base_url>");
|
||||
const [orgMappings, setOrgMappings] = useState<SCIMOrganizationMapping[]>([]);
|
||||
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 = "<your_proxy_base_url>";
|
||||
|
|
@ -48,6 +67,28 @@ const SCIMConfig: React.FC<SCIMConfigProps> = ({ 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<SCIMConfigProps> = ({ accessToken, userID, proxySetti
|
|||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 3: Organization Mappings */}
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded-full bg-info/15 text-info mr-2">3</div>
|
||||
<h3 className="text-lg font-medium flex items-center">
|
||||
<Building2 className="h-5 w-5 mr-2" />
|
||||
Organization Mappings
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground mb-3">
|
||||
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.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{orgMappings.map((mapping, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={mapping.group_display_name_pattern}
|
||||
placeholder="Group display name pattern (e.g. Engineering-.*)"
|
||||
aria-label="Group display name pattern"
|
||||
onChange={(e) =>
|
||||
setOrgMappings((prev) =>
|
||||
prev.map((m, i) => (i === index ? { ...m, group_display_name_pattern: e.target.value } : m)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
value={mapping.organization_id}
|
||||
placeholder="Organization ID"
|
||||
aria-label="Organization ID"
|
||||
onChange={(e) =>
|
||||
setOrgMappings((prev) =>
|
||||
prev.map((m, i) => (i === index ? { ...m, organization_id: e.target.value } : m)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
aria-label="Remove mapping"
|
||||
onClick={() => setOrgMappings((prev) => prev.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex items-center"
|
||||
onClick={() =>
|
||||
setOrgMappings((prev) => [...prev, { group_display_name_pattern: "", organization_id: "" }])
|
||||
}
|
||||
>
|
||||
<CirclePlus />
|
||||
Add Mapping
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSavingMappings}
|
||||
aria-busy={isSavingMappings}
|
||||
className="flex items-center"
|
||||
onClick={handleSaveOrgMappings}
|
||||
>
|
||||
{isSavingMappings ? <UiLoadingSpinner className="size-4" /> : null}
|
||||
Save Mappings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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<string, any>) => {
|
||||
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
|
||||
|
|
|
|||
137
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
137
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue