litellm/tests/integration/management/test_key_delete_revocation.py
Etienne Chabert 372c096388
fix(proxy): evict a deleted key from every worker's auth cache (#47609)
* fix(proxy): evict a deleted key from every worker's auth cache

/key/delete dropped the key object only from the in-memory cache of the worker
that served the call. Auth serves key objects cache-first, so every other
worker, and the Redis copy when enable_redis_auth_cache is on, kept
authenticating the deleted key until user_api_key_cache_ttl expired. /key/update
already evicts and broadcasts through evict_and_broadcast, delete now does the
same for the tokens it deletes

* fix(proxy): evict only the deleted rows and broadcast them in one batch

Scope the cross-worker eviction on /key/delete to the rows the caller was allowed to delete and send it through delete_cache_key_objects, so a caller-supplied name can no longer drop another team's cached entries on every worker

* refactor(proxy): drop the comment above the delete eviction call

* fix(proxy): run the usage cache eviction beside the auth eviction so a hung Redis costs one wait

* test(proxy): wait on the broadcast instead of a sleep and type the peer eviction tests

* test(proxy): wait for the peer to apply the broadcast instead of polling its cache

* test(proxy): cover hashed keys and aliases when deleting a key warmed on a peer

* fix(key_management): evict every requested key on delete, including keys whose row is already gone

A batch delete that names a live key and a key whose row was removed outside the API evicted only the live key, so the gone key kept authenticating from every worker's cache. Every requested key hash is evicted now, while raw non-key cache keys are still left alone

* test(key_management): split the gone-row eviction assertion into one comprehension per worker

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-30 10:20:00 -07:00

234 lines
9.5 KiB
Python

import json
import uuid
from hashlib import sha256
from typing import Final
import httpx
import pytest
from pydantic import BaseModel, JsonValue, TypeAdapter
from tests.integration._support.client import Gateway, delete_key_if_present, eventually
from tests.integration._support.database import read_rows
from tests.integration._support.wire import Reply, Request, wire_server
_CHAT_BODY: Final = TypeAdapter(dict[str, JsonValue])
_EXPECTED_CHAT: Final = ("POST", "/v1/chat/completions")
class _Usage(BaseModel):
prompt_tokens: int
completion_tokens: int
total_tokens: int
class _AssistantMessage(BaseModel):
content: str
class _Choice(BaseModel):
message: _AssistantMessage
class _ChatResponse(BaseModel):
id: str
choices: tuple[_Choice, ...]
usage: _Usage
class _ModelEntry(BaseModel):
id: str
class _ModelList(BaseModel):
data: tuple[_ModelEntry, ...]
class _GenerateKeyResponse(BaseModel):
key: str
class _DeleteResponse(BaseModel):
deleted_keys: tuple[str, ...]
class _KeyInfo(BaseModel):
status: str
class _KeyInfoResponse(BaseModel):
info: _KeyInfo
class _Error(BaseModel):
type: str
class _ErrorResponse(BaseModel):
error: _Error
class _DeletedKeyRow(BaseModel):
token: str
def _chat_body(model: str, prompt: str) -> dict[str, JsonValue]:
return {
"model": model,
"messages": [{"role": "user", "content": prompt}],
}
def _wire_reply(identity: str) -> Reply:
return Reply(
body=json.dumps(
{
"id": identity,
"object": "chat.completion",
"created": 1,
"model": "integration-keys-auth-delete",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "wire response"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 20, "completion_tokens": 20, "total_tokens": 40},
}
).encode()
)
def _chat(gateway: Gateway, model: str, key: str, prompt: str) -> httpx.Response:
return gateway.request("POST", "/v1/chat/completions", _chat_body(model, prompt), key=key)
def _assert_success(response: httpx.Response) -> _ChatResponse:
assert response.status_code == 200, response.text
payload: Final = _ChatResponse.model_validate_json(response.content)
assert payload.choices[0].message.content == "wire response", response.text
assert payload.usage == _Usage(prompt_tokens=20, completion_tokens=20, total_tokens=40), response.text
return payload
def _model_is_available(gateway: Gateway, model: str) -> bool:
response: Final = gateway.request("GET", "/v1/models")
if response.status_code != 200:
return False
models: Final = _ModelList.model_validate_json(response.content)
return model in tuple(entry.id for entry in models.data)
@pytest.mark.parametrize("spelling", ("keys", "hashed_keys", "key_aliases"))
def test_deleting_a_key_revokes_it(gateway: Gateway, spelling: str) -> None:
backend: Final = "integration-keys-auth-delete"
prompts: Final = (
"delete warm " + uuid.uuid4().hex,
"delete reject " + uuid.uuid4().hex,
)
def respond(request: Request) -> Reply:
assert request.method == "POST"
assert request.target == "/v1/chat/completions"
assert request.headers["authorization"] == "Bearer synthetic-openai-key"
body: Final = _CHAT_BODY.validate_json(request.body)
assert body in tuple(_chat_body(backend, prompt) for prompt in prompts), body
return _wire_reply("chatcmpl-" + uuid.uuid4().hex)
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"openai/{backend}", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key"
)
eventually(lambda: _model_is_available(gateway, model), bool, seconds=30)
alias: Final = "integration-delete-" + uuid.uuid4().hex
created_response: Final = gateway.request("POST", "/key/generate", {"key_alias": alias, "models": [model]})
assert created_response.status_code == 200, created_response.text
key: Final = _GenerateKeyResponse.model_validate_json(created_response.content).key
scenario.cleanups.callback(delete_key_if_present, gateway, key)
key_hash: Final = sha256(key.encode()).hexdigest()
_assert_success(_chat(gateway, model, key, prompts[0]))
assert tuple((request.method, request.target) for request in wire.drain()) == (_EXPECTED_CHAT,)
identifier: Final = {"keys": key, "hashed_keys": key_hash, "key_aliases": alias}[spelling]
delete_body: Final = {"key_aliases" if spelling == "key_aliases" else "keys": [identifier]}
deleted: Final = gateway.request("POST", "/key/delete", delete_body)
assert deleted.status_code == 200, deleted.text
assert _DeleteResponse.model_validate_json(deleted.content).deleted_keys == (identifier,), deleted.text
primary_refusal: Final = _chat(gateway, model, key, prompts[1])
assert primary_refusal.status_code == 401, primary_refusal.text
assert _ErrorResponse.model_validate_json(primary_refusal.content).error.type == "token_not_found_in_db", (
primary_refusal.text
)
assert wire.drain() == (), "deleted-key requests reached the upstream"
info: Final = gateway.request("GET", "/key/info", params={"key": key_hash})
assert info.status_code == 200, info.text
assert _KeyInfoResponse.model_validate_json(info.content).info.status == "deleted", info.text
live_rows: Final = read_rows('SELECT token FROM "LiteLLM_VerificationToken" WHERE token=%s', (key_hash,))
deleted_rows: Final = TypeAdapter(tuple[_DeletedKeyRow, ...]).validate_python(
read_rows('SELECT token FROM "LiteLLM_DeletedVerificationToken" WHERE token=%s', (key_hash,))
)
assert live_rows == []
assert deleted_rows == (_DeletedKeyRow(token=key_hash),)
@pytest.mark.parametrize("spelling", ("keys", "hashed_keys", "key_aliases"))
def test_deleting_a_key_revokes_it_on_a_warmed_peer(gateway: Gateway, peer: Gateway, spelling: str) -> None:
backend: Final = "integration-keys-auth-delete"
prompts: Final = (
"delete warm primary " + uuid.uuid4().hex,
"delete warm peer " + uuid.uuid4().hex,
"delete reject peer " + uuid.uuid4().hex,
"delete reject peer fresh " + uuid.uuid4().hex,
)
def respond(request: Request) -> Reply:
assert request.method == "POST"
assert request.target == "/v1/chat/completions"
assert request.headers["authorization"] == "Bearer synthetic-openai-key"
body: Final = _CHAT_BODY.validate_json(request.body)
assert body in tuple(_chat_body(backend, prompt) for prompt in prompts), body
return _wire_reply("chatcmpl-" + uuid.uuid4().hex)
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"openai/{backend}", api_base=f"{wire.url}/v1", api_key="synthetic-openai-key"
)
eventually(lambda: _model_is_available(peer, model), bool, seconds=30)
alias: Final = "integration-delete-" + uuid.uuid4().hex
created_response: Final = gateway.request("POST", "/key/generate", {"key_alias": alias, "models": [model]})
assert created_response.status_code == 200, created_response.text
key: Final = _GenerateKeyResponse.model_validate_json(created_response.content).key
scenario.cleanups.callback(delete_key_if_present, gateway, key)
warm_responses: Final = tuple(
_assert_success(_chat(worker, model, key, prompt))
for worker, prompt in zip((gateway, peer), prompts[:2], strict=True)
)
assert len(warm_responses) == 2
assert tuple((request.method, request.target) for request in wire.drain()) == (_EXPECTED_CHAT,) * 2
identifier: Final = {"keys": key, "hashed_keys": sha256(key.encode()).hexdigest(), "key_aliases": alias}[
spelling
]
delete_body: Final = {"key_aliases" if spelling == "key_aliases" else "keys": [identifier]}
deleted: Final = gateway.request("POST", "/key/delete", delete_body)
assert deleted.status_code == 200, deleted.text
assert _DeleteResponse.model_validate_json(deleted.content).deleted_keys == (identifier,), deleted.text
peer_refusal: Final = eventually(
lambda: _chat(peer, model, key, prompts[2]),
lambda response: response.status_code == 401,
seconds=3,
)
assert _ErrorResponse.model_validate_json(peer_refusal.content).error.type == "token_not_found_in_db", (
peer_refusal.text
)
wire.drain()
fresh_peer_refusal: Final = _chat(peer, model, key, prompts[3])
assert fresh_peer_refusal.status_code == 401, fresh_peer_refusal.text
assert _ErrorResponse.model_validate_json(fresh_peer_refusal.content).error.type == "token_not_found_in_db", (
fresh_peer_refusal.text
)
fresh_gateway_refusal: Final = _chat(gateway, model, key, prompts[3])
assert fresh_gateway_refusal.status_code == 401, fresh_gateway_refusal.text
assert _ErrorResponse.model_validate_json(fresh_gateway_refusal.content).error.type == (
"token_not_found_in_db"
), fresh_gateway_refusal.text
assert wire.drain() == (), "deleted-key requests reached the upstream"