mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge 560e5da320 into 9071ca503e
This commit is contained in:
commit
fc749f2c1c
2 changed files with 207 additions and 28 deletions
|
|
@ -294,6 +294,35 @@ def _custom_key_update_hook(
|
|||
return hooks.user_custom_key_update
|
||||
|
||||
|
||||
async def _enforce_custom_key_update_policy(
|
||||
hook: Callable[..., Awaitable[Mapping[str, object]]] | None,
|
||||
data: UpdateKeyRequest,
|
||||
) -> None:
|
||||
if hook is None:
|
||||
return
|
||||
if not inspect.iscoroutinefunction(hook):
|
||||
raise ValueError("user_custom_key_update must be a coroutine")
|
||||
result: Final = await hook(data)
|
||||
if not result.get("decision", True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=result.get("message", "Authentication Failed - Custom Auth Rule"),
|
||||
)
|
||||
|
||||
|
||||
def _regenerate_request_as_update_request(key: str, data: RegenerateKeyRequest) -> UpdateKeyRequest | None:
|
||||
changed_fields: Final = MappingProxyType(
|
||||
{
|
||||
field: value
|
||||
for field, value in data.model_dump(exclude_unset=True).items()
|
||||
if field in UpdateKeyRequest.model_fields and field != "key"
|
||||
}
|
||||
)
|
||||
if not changed_fields:
|
||||
return None
|
||||
return UpdateKeyRequest(key=key, **changed_fields)
|
||||
|
||||
|
||||
class _LegacyDumpable(Protocol):
|
||||
def dict(self) -> Mapping[str, object]: ...
|
||||
|
||||
|
|
@ -3066,19 +3095,7 @@ async def update_key_fn(
|
|||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
|
||||
# Custom key update hook
|
||||
custom_key_update_hook: Final[Callable[..., Awaitable[Mapping[str, object]]] | None] = _custom_key_update_hook(
|
||||
proxy_server
|
||||
)
|
||||
if custom_key_update_hook is not None:
|
||||
if inspect.iscoroutinefunction(custom_key_update_hook):
|
||||
result: Final = await custom_key_update_hook(data)
|
||||
else:
|
||||
raise ValueError("user_custom_key_update must be a coroutine")
|
||||
decision: Final = result.get("decision", True)
|
||||
message: Final = result.get("message", "Authentication Failed - Custom Auth Rule")
|
||||
if not decision:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=message)
|
||||
await _enforce_custom_key_update_policy(hook=_custom_key_update_hook(proxy_server), data=data)
|
||||
|
||||
# Enforce upperbound key params on update (don't fill defaults)
|
||||
_enforce_upperbound_key_params(data, fill_defaults=False)
|
||||
|
|
@ -5069,6 +5086,7 @@ async def _execute_virtual_key_regeneration(
|
|||
proxy_logging_obj: ProxyLogging,
|
||||
) -> GenerateKeyResponse:
|
||||
"""Generate new token, update DB, invalidate cache, and return response."""
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.proxy_server import hash_token
|
||||
|
||||
# Mirror the /key/update ownership rebind guard. See helper docstring.
|
||||
|
|
@ -5116,6 +5134,9 @@ async def _execute_virtual_key_regeneration(
|
|||
|
||||
non_default_values = {}
|
||||
if data is not None:
|
||||
update_request: Final = _regenerate_request_as_update_request(key=hashed_api_key, data=data)
|
||||
if update_request is not None:
|
||||
await _enforce_custom_key_update_policy(hook=_custom_key_update_hook(proxy_server), data=update_request)
|
||||
# Enforce upperbound key params on regenerate (don't fill defaults)
|
||||
_enforce_upperbound_key_params(data, fill_defaults=False)
|
||||
non_default_values = await prepare_key_update_data(data=data, existing_key_row=key_in_db)
|
||||
|
|
@ -5134,6 +5155,13 @@ async def _execute_virtual_key_regeneration(
|
|||
prisma_client=prisma_client,
|
||||
)
|
||||
|
||||
await _persist_deleted_verification_tokens(
|
||||
keys=[key_in_db],
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
)
|
||||
|
||||
# If grace period set, insert deprecated key so old key remains valid
|
||||
await _insert_deprecated_key(
|
||||
prisma_client=prisma_client,
|
||||
|
|
@ -5432,17 +5460,6 @@ async def regenerate_key_fn(
|
|||
if litellm_changed_by is not None and not isinstance(litellm_changed_by, str):
|
||||
litellm_changed_by = None
|
||||
|
||||
# Save the old key record to deleted table before regeneration.
|
||||
# This preserves key_alias and team_id metadata for historical spend records.
|
||||
# If this fails, abort the regeneration to avoid permanently losing the
|
||||
# old hash→metadata mapping.
|
||||
await _persist_deleted_verification_tokens(
|
||||
keys=[_key_in_db],
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
litellm_changed_by=litellm_changed_by,
|
||||
)
|
||||
|
||||
return await _execute_virtual_key_regeneration(
|
||||
prisma_client=prisma_client,
|
||||
key_in_db=_key_in_db,
|
||||
|
|
|
|||
|
|
@ -24,18 +24,21 @@ from litellm.proxy._types import (
|
|||
LitellmUserRoles,
|
||||
Member,
|
||||
ProxyException,
|
||||
RegenerateKeyRequest,
|
||||
ResetSpendRequest,
|
||||
UpdateKeyRequest,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import _delete_cache_key_object, _project_cache_key
|
||||
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.litellm_core_utils.duration_parser import duration_in_seconds
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_check_org_key_limits,
|
||||
_check_project_key_limits,
|
||||
_check_team_key_limits,
|
||||
_common_key_generation_helper,
|
||||
_enforce_upperbound_key_params,
|
||||
_execute_virtual_key_regeneration,
|
||||
_get_and_validate_existing_key,
|
||||
_list_key_helper,
|
||||
_persist_deleted_verification_tokens,
|
||||
|
|
@ -11932,6 +11935,10 @@ async def test_execute_virtual_key_regeneration_rejects_over_limit_duration(monk
|
|||
"litellm.proxy.management_endpoints.key_management_endpoints._insert_deprecated_key",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch( # test-quality-ok: archival path is outside upperbound rejection
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._persist_deleted_verification_tokens",
|
||||
new_callable=AsyncMock,
|
||||
) as persist_deleted_verification_tokens,
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
|
||||
new_callable=AsyncMock,
|
||||
|
|
@ -11952,6 +11959,7 @@ async def test_execute_virtual_key_regeneration_rejects_over_limit_duration(monk
|
|||
assert exc_info.value.status_code == 400
|
||||
assert "duration" in str(exc_info.value.detail)
|
||||
# Rejected regenerate must not reach the DB update.
|
||||
persist_deleted_verification_tokens.assert_not_awaited()
|
||||
assert mock_prisma_client.db.litellm_verificationtoken.update.await_count == 0
|
||||
|
||||
|
||||
|
|
@ -12011,6 +12019,164 @@ async def test_execute_virtual_key_regeneration_allows_within_limit_duration(mon
|
|||
assert mock_prisma_client.db.litellm_verificationtoken.update.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_virtual_key_regeneration_rejects_when_custom_key_update_hook_denies():
|
||||
existing_key = _make_regenerate_existing_key()
|
||||
data = RegenerateKeyRequest(duration="3000d")
|
||||
mock_prisma_client = _make_regenerate_mock_prisma()
|
||||
received_data: list[UpdateKeyRequest] = []
|
||||
|
||||
async def hook(data: UpdateKeyRequest) -> dict[str, object]:
|
||||
received_data.append(data)
|
||||
if data.duration and duration_in_seconds(data.duration) > duration_in_seconds("7d"):
|
||||
return {"decision": False, "message": "duration must be <= 7d"}
|
||||
return {"decision": True}
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: deterministic token setup for policy rejection
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.get_new_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value="sk-newtoken1234ab12",
|
||||
),
|
||||
patch( # test-quality-ok: grace-period path is outside policy rejection
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._insert_deprecated_key",
|
||||
new_callable=AsyncMock,
|
||||
) as insert_deprecated_key,
|
||||
patch( # test-quality-ok: archival path is outside policy rejection
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._persist_deleted_verification_tokens",
|
||||
new_callable=AsyncMock,
|
||||
) as persist_deleted_verification_tokens,
|
||||
patch( # test-quality-ok: cache eviction is outside policy rejection
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch( # test-quality-ok: rotation callback is outside policy rejection
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.KeyManagementEventHooks.async_key_rotated_hook",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.user_custom_key_update", hook), # test-quality-ok: inject policy hook
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _execute_virtual_key_regeneration(
|
||||
prisma_client=mock_prisma_client,
|
||||
key_in_db=existing_key,
|
||||
hashed_api_key="abc123",
|
||||
key="abc123",
|
||||
data=data,
|
||||
user_api_key_dict=_make_regenerate_user_api_key_dict(),
|
||||
litellm_changed_by=None,
|
||||
user_api_key_cache=MagicMock(),
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.detail == "duration must be <= 7d"
|
||||
insert_deprecated_key.assert_not_awaited()
|
||||
persist_deleted_verification_tokens.assert_not_awaited()
|
||||
assert mock_prisma_client.db.litellm_verificationtoken.update.await_count == 0
|
||||
assert len(received_data) == 1
|
||||
assert received_data[0].key == "abc123"
|
||||
assert received_data[0].duration == "3000d"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_virtual_key_regeneration_allows_when_custom_key_update_hook_approves():
|
||||
existing_key = _make_regenerate_existing_key()
|
||||
data = RegenerateKeyRequest(duration="5d")
|
||||
mock_prisma_client = _make_regenerate_mock_prisma()
|
||||
received_data: list[UpdateKeyRequest] = []
|
||||
|
||||
async def hook(data: UpdateKeyRequest) -> dict[str, object]:
|
||||
received_data.append(data)
|
||||
if data.duration and duration_in_seconds(data.duration) > duration_in_seconds("7d"):
|
||||
return {"decision": False, "message": "duration must be <= 7d"}
|
||||
return {"decision": True}
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: deterministic token setup for policy approval
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.get_new_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value="sk-newtoken1234ab12",
|
||||
),
|
||||
patch( # test-quality-ok: grace-period path is outside policy approval
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._insert_deprecated_key",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch( # test-quality-ok: verify archival follows policy approval
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._persist_deleted_verification_tokens",
|
||||
new_callable=AsyncMock,
|
||||
) as persist_deleted_verification_tokens,
|
||||
patch( # test-quality-ok: cache eviction is outside policy approval
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch( # test-quality-ok: rotation callback is outside policy approval
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.KeyManagementEventHooks.async_key_rotated_hook",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.user_custom_key_update", hook), # test-quality-ok: inject policy hook
|
||||
):
|
||||
await _execute_virtual_key_regeneration(
|
||||
prisma_client=mock_prisma_client,
|
||||
key_in_db=existing_key,
|
||||
hashed_api_key="abc123",
|
||||
key="abc123",
|
||||
data=data,
|
||||
user_api_key_dict=_make_regenerate_user_api_key_dict(),
|
||||
litellm_changed_by=None,
|
||||
user_api_key_cache=MagicMock(),
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
assert mock_prisma_client.db.litellm_verificationtoken.update.await_count == 1
|
||||
persist_deleted_verification_tokens.assert_awaited_once()
|
||||
assert persist_deleted_verification_tokens.call_args.kwargs["keys"] == [existing_key]
|
||||
assert len(received_data) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("data", [None, RegenerateKeyRequest()])
|
||||
async def test_execute_virtual_key_regeneration_skips_custom_key_update_hook_without_changes(data):
|
||||
mock_prisma_client = _make_regenerate_mock_prisma()
|
||||
|
||||
async def hook(data: UpdateKeyRequest) -> dict[str, object]:
|
||||
raise AssertionError(f"custom key update hook called with {data}")
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: deterministic token setup for unchanged request
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.get_new_token",
|
||||
new_callable=AsyncMock,
|
||||
return_value="sk-newtoken1234ab12",
|
||||
),
|
||||
patch( # test-quality-ok: grace-period path is outside unchanged request
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._insert_deprecated_key",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch( # test-quality-ok: cache eviction is outside unchanged request
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._delete_cache_key_object",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch( # test-quality-ok: rotation callback is outside unchanged request
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints.KeyManagementEventHooks.async_key_rotated_hook",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.user_custom_key_update", hook), # test-quality-ok: inject policy hook
|
||||
):
|
||||
await _execute_virtual_key_regeneration(
|
||||
prisma_client=mock_prisma_client,
|
||||
key_in_db=_make_regenerate_existing_key(),
|
||||
hashed_api_key="abc123",
|
||||
key="abc123",
|
||||
data=data,
|
||||
user_api_key_dict=_make_regenerate_user_api_key_dict(),
|
||||
litellm_changed_by=None,
|
||||
user_api_key_cache=MagicMock(),
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
|
||||
assert mock_prisma_client.db.litellm_verificationtoken.update.await_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regenerate_evicts_jwt_key_mapping_cache_so_next_jwt_call_gets_new_token():
|
||||
"""
|
||||
|
|
@ -13772,10 +13938,6 @@ async def test_regenerate_applies_normalized_mcp_object_permission():
|
|||
"litellm.proxy.management_endpoints.key_management_endpoints.validate_key_vector_stores_against_team",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._persist_deleted_verification_tokens",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.key_management_endpoints._execute_virtual_key_regeneration",
|
||||
execute_mock,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue