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 <krrish-berri-2@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-05-01 18:23:44 +00:00
parent c8fb77f119
commit e3325193d8
No known key found for this signature in database
2 changed files with 211 additions and 15 deletions

View file

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

View file

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