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:
yassin 2026-08-27 01:29:48 +00:00
parent f677292901
commit c7bb52914d
11 changed files with 753 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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