From e3325193d806110602f6a467940bc178887fd71d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 1 May 2026 18:23:44 +0000 Subject: [PATCH] feat(scim): add opt-in auto_create_keys_for_scim_users flag Add a new litellm_settings flag 'auto_create_keys_for_scim_users' that, when set to True, causes SCIM user provisioning to also auto-generate an API key for the new user. This applies to both: 1. POST /scim/v2/Users (direct SCIM user creation) 2. _create_user_if_not_exists (users created via group membership) The flag defaults to False, preserving existing behavior where SCIM provisioning creates user accounts without API keys. Usage in config: litellm_settings: auto_create_keys_for_scim_users: true Also refactors the config-reading logic into a shared _get_litellm_setting helper used by both scim_upsert_user and the new flag. Co-authored-by: Krrish Dholakia --- .../management_endpoints/scim/scim_v2.py | 58 ++++-- .../scim/test_scim_v2_endpoints.py | 168 ++++++++++++++++++ 2 files changed, 211 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py index 4c472ed7f21..613cc737326 100644 --- a/litellm/proxy/management_endpoints/scim/scim_v2.py +++ b/litellm/proxy/management_endpoints/scim/scim_v2.py @@ -207,6 +207,30 @@ def _build_scim_metadata( return metadata +async def _get_litellm_setting(setting_name: str, default: bool) -> bool: + """ + Read a boolean flag from litellm_settings in the proxy config. + + Args: + setting_name: The key to look up in litellm_settings. + default: Value returned when the key is absent or on error. + + Returns: + The boolean value of the setting, or *default* on missing key / error. + """ + try: + from litellm.proxy.proxy_server import proxy_config + + config = await proxy_config.get_config() + litellm_settings = config.get("litellm_settings", {}) or {} + return bool(litellm_settings.get(setting_name, default)) + except Exception as e: + verbose_proxy_logger.warning( + f"Error reading {setting_name} setting, defaulting to {default}: {e}" + ) + return default + + async def _get_scim_upsert_user_setting() -> bool: """ Get the scim_upsert_user setting from litellm_settings. @@ -215,21 +239,21 @@ async def _get_scim_upsert_user_setting() -> bool: True if scim_upsert_user is not set or is True (default behavior), False if scim_upsert_user is explicitly set to False (SCIM 2.0 strict mode) """ - try: - from litellm.proxy.proxy_server import proxy_config + return await _get_litellm_setting("scim_upsert_user", default=True) - config = await proxy_config.get_config() - litellm_settings = config.get("litellm_settings", {}) or {} - scim_upsert_user = litellm_settings.get("scim_upsert_user", True) - # Default to True if not set (backward compatibility) - return bool(scim_upsert_user) - except Exception as e: - verbose_proxy_logger.warning( - f"Error reading scim_upsert_user setting, defaulting to True: {e}" - ) - # Default to True for backward compatibility - return True +async def _get_auto_create_key_for_scim_user_setting() -> bool: + """ + Get the auto_create_keys_for_scim_users setting from litellm_settings. + + When True, SCIM user provisioning (POST /scim/v2/Users and group-membership + auto-creation) will also auto-generate an API key for the new user. + + Returns: + False by default (backward-compatible). True only when the admin + explicitly opts in via litellm_settings.auto_create_keys_for_scim_users. + """ + return await _get_litellm_setting("auto_create_keys_for_scim_users", default=False) async def _extract_group_member_ids(group: SCIMGroup) -> GroupMemberExtractionResult: @@ -364,13 +388,15 @@ async def _create_user_if_not_exists( if litellm.default_internal_user_params: default_role = litellm.default_internal_user_params.get("user_role") + auto_create_key = await _get_auto_create_key_for_scim_user_setting() + new_user_request = NewUserRequest( user_id=user_id, user_email=user_id, # We don't have email from group membership user_alias=None, teams=[], # Teams will be added separately metadata={"created_via": created_via}, - auto_create_key=False, + auto_create_key=auto_create_key, user_role=default_role, ) @@ -871,13 +897,15 @@ async def create_user( if litellm.default_internal_user_params: default_role = litellm.default_internal_user_params.get("user_role") + auto_create_key = await _get_auto_create_key_for_scim_user_setting() + new_user_request = NewUserRequest( user_id=user_id, user_email=user_data["user_email"], user_alias=user_data["user_alias"], teams=user_data["teams"], metadata=metadata, - auto_create_key=False, + auto_create_key=auto_create_key, user_role=default_role, ) 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 ad53e87e555..b698ba2aeb3 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 @@ -11,6 +11,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.management_endpoints.scim.scim_v2 import ( UserProvisionerHelpers, + _create_user_if_not_exists, _extract_group_member_ids, _handle_team_membership_changes, _process_group_patch_operations, @@ -1601,3 +1602,170 @@ async def test_process_group_patch_operations_with_flag_false_rejects( assert exc_info.value.status_code == 400 assert "does not exist" in str(exc_info.value.detail) assert "new-user-1" in str(exc_info.value.detail) + + +# --------------------------------------------------------------------------- +# Tests for auto_create_keys_for_scim_users setting +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_user_auto_create_key_false_by_default(mocker, monkeypatch): + """ + When auto_create_keys_for_scim_users is not set (default), SCIM user + creation should pass auto_create_key=False to new_user. + """ + + scim_user = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + userName="scim-nokey-user", + name=SCIMUserName(familyName="User", givenName="NoKey"), + emails=[SCIMUserEmail(value="nokey@example.com")], + ) + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) + + monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client), + ) + + async def mock_get_config(): + return {"litellm_settings": {}} + + from litellm.proxy.proxy_server import proxy_config + + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + + new_user_mock = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.new_user", + AsyncMock(return_value=NewUserRequest(user_id="scim-nokey-user")), + ) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", + AsyncMock(return_value=scim_user), + ) + + await create_user(user=scim_user) + + called_args = new_user_mock.call_args.kwargs["data"] + assert called_args.auto_create_key is False + + +@pytest.mark.asyncio +async def test_create_user_auto_create_key_true_when_enabled(mocker, monkeypatch): + """ + When auto_create_keys_for_scim_users is True, SCIM user creation + should pass auto_create_key=True to new_user. + """ + + scim_user = SCIMUser( + schemas=["urn:ietf:params:scim:schemas:core:2.0:User"], + userName="scim-key-user", + name=SCIMUserName(familyName="User", givenName="WithKey"), + emails=[SCIMUserEmail(value="withkey@example.com")], + ) + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.db = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable = mocker.MagicMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None) + mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) + + monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception", + AsyncMock(return_value=mock_prisma_client), + ) + + async def mock_get_config(): + return {"litellm_settings": {"auto_create_keys_for_scim_users": True}} + + from litellm.proxy.proxy_server import proxy_config + + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + + new_user_mock = mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.new_user", + AsyncMock(return_value=NewUserRequest(user_id="scim-key-user")), + ) + + mocker.patch( + "litellm.proxy.management_endpoints.scim.scim_v2.ScimTransformations.transform_litellm_user_to_scim_user", + AsyncMock(return_value=scim_user), + ) + + await create_user(user=scim_user) + + called_args = new_user_mock.call_args.kwargs["data"] + assert called_args.auto_create_key is True + + +@pytest.mark.asyncio +async def test_create_user_if_not_exists_auto_create_key_false_by_default( + mocker, monkeypatch +): + """ + _create_user_if_not_exists should pass auto_create_key=False when + auto_create_keys_for_scim_users is not set (default). + """ + monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) + + async def mock_get_config(): + return {"litellm_settings": {}} + + from litellm.proxy.proxy_server import proxy_config + + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + + new_user_mock = mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.new_user", + AsyncMock(return_value=NewUserRequest(user_id="group-user")), + ) + + result = await _create_user_if_not_exists( + user_id="group-user", created_via="scim_group_membership" + ) + + assert result is not None + called_args = new_user_mock.call_args.kwargs["data"] + assert called_args.auto_create_key is False + + +@pytest.mark.asyncio +async def test_create_user_if_not_exists_auto_create_key_true_when_enabled( + mocker, monkeypatch +): + """ + _create_user_if_not_exists should pass auto_create_key=True when + auto_create_keys_for_scim_users is True. + """ + monkeypatch.setattr("litellm.default_internal_user_params", None, raising=False) + + async def mock_get_config(): + return {"litellm_settings": {"auto_create_keys_for_scim_users": True}} + + from litellm.proxy.proxy_server import proxy_config + + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + + new_user_mock = mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.new_user", + AsyncMock(return_value=NewUserRequest(user_id="group-user-key")), + ) + + result = await _create_user_if_not_exists( + user_id="group-user-key", created_via="scim_group_membership" + ) + + assert result is not None + called_args = new_user_mock.call_args.kwargs["data"] + assert called_args.auto_create_key is True