mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-17 23:51:30 +00:00
fix(proxy): resolve rotation and permission fields before the key policy and pin the effective-row contract
This commit is contained in:
parent
577a4e94aa
commit
fc62df33c2
3 changed files with 100 additions and 19 deletions
|
|
@ -359,7 +359,9 @@ def _effective_key_after_update(
|
|||
overlay: Final = MappingProxyType(
|
||||
{column: _decode_json_string_column(column, value) for column, value in non_default_values.items()}
|
||||
)
|
||||
return _verification_token_from_row(MappingProxyType({**existing_key_row.model_dump(), **overlay}))
|
||||
return _verification_token_from_row(
|
||||
MappingProxyType({**existing_key_row.model_dump(), **overlay, "object_permission": None})
|
||||
)
|
||||
|
||||
|
||||
def _update_policy_request(
|
||||
|
|
@ -381,7 +383,7 @@ def _update_policy_request(
|
|||
def _generate_budget_windows(
|
||||
budget_limits: Sequence[BudgetLimitEntry] | None,
|
||||
) -> tuple[Mapping[str, object], ...] | None:
|
||||
if budget_limits is None:
|
||||
if not budget_limits:
|
||||
return None
|
||||
return tuple(
|
||||
MappingProxyType(
|
||||
|
|
@ -402,15 +404,20 @@ def _effective_key_for_generate(data: GenerateKeyRequest, now: datetime) -> Lite
|
|||
column_fields: Final = MappingProxyType(
|
||||
{field: value for field, value in requested.items() if field not in _KEY_METADATA_REQUEST_FIELDS}
|
||||
)
|
||||
request_metadata: Final = data.metadata or MappingProxyType({})
|
||||
folded_metadata: Final = {**request_metadata, **metadata_fields} # mutable-ok: encrypt_callback_vars needs a dict
|
||||
columns: Final = handle_key_type(data, {**column_fields}) # mutable-ok: handle_key_type mutates data_json in place
|
||||
metadata: Final = data.metadata or MappingProxyType({})
|
||||
folded_metadata: Final = {**metadata, **metadata_fields} # mutable-ok: encrypt_callback_vars needs a dict
|
||||
columns: Final = handle_key_type(data, {**column_fields}) # mutable-ok: handle_key_type mutates in place
|
||||
expires: Final = (
|
||||
now + timedelta(seconds=duration_in_seconds(duration=data.duration)) if data.duration is not None else None
|
||||
)
|
||||
budget_reset_at: Final = (
|
||||
get_budget_reset_time(budget_duration=data.budget_duration) if data.budget_duration is not None else None
|
||||
)
|
||||
key_rotation_at: Final = (
|
||||
now + timedelta(seconds=duration_in_seconds(duration=data.rotation_interval))
|
||||
if data.auto_rotate and data.rotation_interval
|
||||
else None
|
||||
)
|
||||
return _verification_token_from_row(
|
||||
MappingProxyType(
|
||||
{
|
||||
|
|
@ -418,6 +425,7 @@ def _effective_key_for_generate(data: GenerateKeyRequest, now: datetime) -> Lite
|
|||
"metadata": encrypt_callback_vars(folded_metadata),
|
||||
"expires": expires,
|
||||
"budget_reset_at": budget_reset_at,
|
||||
"key_rotation_at": key_rotation_at,
|
||||
"budget_limits": _generate_budget_windows(data.budget_limits),
|
||||
"object_permission": None,
|
||||
}
|
||||
|
|
@ -3249,16 +3257,6 @@ async def update_key_fn(
|
|||
_enforce_upperbound_key_params(data, fill_defaults=False)
|
||||
non_default_values: Final = await prepare_key_update_data(data=data, existing_key_row=existing_key_row)
|
||||
|
||||
await _enforce_custom_key_policy(
|
||||
hook=_custom_key_policy_hook(proxy_server),
|
||||
build_policy_request=lambda: _update_policy_request(
|
||||
operation="update",
|
||||
existing_key_row=existing_key_row,
|
||||
non_default_values=non_default_values,
|
||||
request=data,
|
||||
),
|
||||
)
|
||||
|
||||
# Only validate key_alias format if it's actually being changed
|
||||
new_key_alias: Final = non_default_values.get("key_alias", None)
|
||||
if new_key_alias != existing_key_row.key_alias:
|
||||
|
|
@ -3278,6 +3276,16 @@ async def update_key_fn(
|
|||
existing_key_alias=existing_key_row.key_alias,
|
||||
)
|
||||
|
||||
await _enforce_custom_key_policy(
|
||||
hook=_custom_key_policy_hook(proxy_server),
|
||||
build_policy_request=lambda: _update_policy_request(
|
||||
operation="update",
|
||||
existing_key_row=existing_key_row,
|
||||
non_default_values=non_default_values,
|
||||
request=data,
|
||||
),
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception("Not connected to DB!")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
from datetime import datetime
|
||||
from typing import Any, Final, Literal
|
||||
from typing import Any, Final, Literal, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
|
@ -128,11 +128,18 @@ class BulkUpdateTeamKeysRequest(BaseModel):
|
|||
return self
|
||||
|
||||
|
||||
CustomKeyPolicyOperation = Literal["generate", "update", "regenerate"]
|
||||
CustomKeyPolicyOperation: TypeAlias = Literal["generate", "update", "regenerate"]
|
||||
|
||||
|
||||
class CustomKeyPolicyRequest(LiteLLMPydanticObjectBase):
|
||||
"""What `general_settings.custom_key_policy` receives: the operation, the key row as it will be written, and the raw request."""
|
||||
"""What `general_settings.custom_key_policy` receives.
|
||||
|
||||
`effective_key` is the verification token row as it will be written: the existing row overlaid with the
|
||||
requested changes, with `duration` resolved to `expires` and `budget_duration` to `budget_reset_at`. Values the
|
||||
proxy fills in after the policy stay at their defaults: `token`, `key_name`, `created_by`, `updated_by` and the
|
||||
soft-budget `budget_id` on generate, the rotated token on regenerate, and the `object_permission` relation on
|
||||
every operation (`object_permission_id` is set; read `request.object_permission` for the requested change).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=(), frozen=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from litellm.proxy._types import (
|
|||
ResetSpendRequest,
|
||||
UpdateKeyRequest,
|
||||
)
|
||||
from litellm.models.object_permission import LiteLLM_ObjectPermissionTable
|
||||
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
|
||||
|
|
@ -12362,7 +12363,9 @@ async def test_update_key_fn_runs_custom_key_policy_on_the_effective_row(monkeyp
|
|||
mock_prisma_client = _setup_update_key_fn_policy_mocks(monkeypatch, _policy_existing_team_key())
|
||||
received: list[CustomKeyPolicyRequest] = []
|
||||
policy = _seven_day_policy(received)
|
||||
data = UpdateKeyRequest(key=_POLICY_HASHED_TOKEN, duration="5d", max_budget=50.0)
|
||||
data = UpdateKeyRequest(
|
||||
key=_POLICY_HASHED_TOKEN, duration="5d", max_budget=50.0, auto_rotate=True, rotation_interval="30d"
|
||||
)
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: cache eviction is outside the policy path
|
||||
|
|
@ -12381,6 +12384,9 @@ async def test_update_key_fn_runs_custom_key_policy_on_the_effective_row(monkeyp
|
|||
mock_prisma_client.update_data.assert_awaited_once()
|
||||
assert len(received) == 1
|
||||
_assert_update_policy_request(received[0], data)
|
||||
key_rotation_at = received[0].effective_key.key_rotation_at
|
||||
assert key_rotation_at is not None
|
||||
assert abs(key_rotation_at - (datetime.now(timezone.utc) + timedelta(days=30))) < timedelta(seconds=60)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -12695,6 +12701,23 @@ async def test_effective_key_after_update_clears_expiry_for_a_minus_one_duration
|
|||
assert effective_key.expires is None
|
||||
|
||||
|
||||
def test_effective_key_after_update_swaps_the_object_permission_id_and_drops_the_stale_relation():
|
||||
existing_key = LiteLLM_VerificationToken(
|
||||
token="tok",
|
||||
object_permission_id="op-old",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-old", mcp_servers=["old"]),
|
||||
)
|
||||
|
||||
effective_key = _effective_key_after_update(
|
||||
existing_key_row=existing_key, non_default_values={"object_permission_id": "op-new"}
|
||||
)
|
||||
|
||||
assert effective_key.object_permission_id == "op-new"
|
||||
assert effective_key.object_permission is None
|
||||
assert existing_key.object_permission is not None
|
||||
assert existing_key.object_permission.mcp_servers == ["old"]
|
||||
|
||||
|
||||
def test_effective_key_for_generate_reflects_the_processed_request_without_mutating_it():
|
||||
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
data = GenerateKeyRequest(
|
||||
|
|
@ -12705,12 +12728,21 @@ def test_effective_key_for_generate_reflects_the_processed_request_without_mutat
|
|||
tags=["t1"],
|
||||
budget_duration="1d",
|
||||
max_budget=3.0,
|
||||
budget_limits=[{"budget_duration": "1d", "max_budget": 5.0}],
|
||||
auto_rotate=True,
|
||||
rotation_interval="30d",
|
||||
object_permission={"mcp_servers": ["srv"]},
|
||||
key_type=LiteLLMKeyType.LLM_API,
|
||||
)
|
||||
|
||||
effective_key = _effective_key_for_generate(data=data, now=now)
|
||||
|
||||
assert effective_key.expires == now + timedelta(days=5)
|
||||
assert effective_key.key_rotation_at == now + timedelta(days=30)
|
||||
assert effective_key.budget_limits is not None
|
||||
assert effective_key.budget_limits[0]["max_budget"] == 5.0
|
||||
assert effective_key.budget_limits[0]["reset_at"] is not None
|
||||
assert effective_key.object_permission is None
|
||||
assert effective_key.org_id == "org-1"
|
||||
assert effective_key.metadata == {"a": 1, "guardrails": ["g1"], "tags": ["t1"]}
|
||||
assert effective_key.max_budget == 3.0
|
||||
|
|
@ -12722,6 +12754,18 @@ def test_effective_key_for_generate_reflects_the_processed_request_without_mutat
|
|||
assert data.guardrails == ["g1"]
|
||||
assert data.tags == ["t1"]
|
||||
assert data.duration == "5d"
|
||||
assert data.budget_limits is not None
|
||||
assert data.budget_limits[0].reset_at is None
|
||||
assert data.object_permission is not None
|
||||
assert data.object_permission.mcp_servers == ["srv"]
|
||||
|
||||
|
||||
def test_effective_key_for_generate_stores_no_budget_windows_for_an_empty_list():
|
||||
effective_key = _effective_key_for_generate(
|
||||
data=GenerateKeyRequest(budget_limits=[]), now=datetime(2026, 1, 1, tzinfo=timezone.utc)
|
||||
)
|
||||
|
||||
assert effective_key.budget_limits is None
|
||||
|
||||
|
||||
def test_effective_key_for_generate_without_duration_never_expires():
|
||||
|
|
@ -12731,6 +12775,7 @@ def test_effective_key_for_generate_without_duration_never_expires():
|
|||
|
||||
assert effective_key.expires is None
|
||||
assert effective_key.budget_reset_at is None
|
||||
assert effective_key.key_rotation_at is None
|
||||
assert effective_key.key_type == "default"
|
||||
|
||||
|
||||
|
|
@ -12764,6 +12809,27 @@ async def test_enforce_custom_key_policy_uses_the_default_denial_message():
|
|||
assert exc_info.value.detail == "Authentication Failed - Custom Auth Rule"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enforce_custom_key_policy_allows_when_the_decision_is_missing():
|
||||
received: list[CustomKeyPolicyRequest] = []
|
||||
|
||||
async def no_decision(policy_request: CustomKeyPolicyRequest) -> dict[str, object]:
|
||||
received.append(policy_request)
|
||||
return {}
|
||||
|
||||
await _enforce_custom_key_policy(hook=no_decision, build_policy_request=_policy_request_for_generate)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].operation == "generate"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enforce_custom_key_policy_never_builds_the_request_without_a_hook():
|
||||
await _enforce_custom_key_policy(
|
||||
hook=None, build_policy_request=lambda: pytest.fail("policy request built without a hook")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regenerate_evicts_jwt_key_mapping_cache_so_next_jwt_call_gets_new_token():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue