fix(identity): invalidate identity cache on key deletion

delete_verification_tokens (the /key/delete path) cleared the legacy key
cache but never the identity cache, so a revoked key kept authenticating
from its identity:v1:<hash> entry until the TTL expired. Wire
invalidate_identity_for_token into the deletion loop. The other deletion
sites already route through _delete_cache_key_object, which invalidates;
this was the one path that bypassed it (Veria review finding).
This commit is contained in:
Yassin Kortam 2026-06-08 18:18:37 -07:00
parent c99588c7f4
commit 80fcb674cf
2 changed files with 55 additions and 0 deletions

View file

@ -3936,11 +3936,19 @@ async def delete_verification_tokens(
verbose_proxy_logger.debug(traceback.format_exc())
raise e
from litellm.identity.invalidation import invalidate_identity_for_token
for key in tokens:
user_api_key_cache.delete_cache(key)
# remove hash token from cache
hashed_token = hash_token(cast(str, key))
user_api_key_cache.delete_cache(hashed_token)
# `tokens` are already hashed (see `_hash_token_if_needed` above), so
# `key` is the same hash the identity cache is keyed under. Without this
# the `identity:v1:<hash>` entry outlives the deleted key until its TTL.
await invalidate_identity_for_token(
token_hash=key, dual_cache=user_api_key_cache
)
return {
"deleted_keys": deleted_tokens,

View file

@ -11668,3 +11668,50 @@ async def test_ghsa_q775_default_team_id_does_not_grant_session_token_exemption(
msg = str(getattr(err, "detail", "")) + str(getattr(err, "message", ""))
assert str(code) == "400"
assert "cannot exceed" in msg.lower()
@pytest.mark.asyncio
async def test_delete_verification_tokens_clears_identity_cache():
"""A revoked key must not survive in the identity cache.
delete_verification_tokens clears the legacy key cache; it must also
invalidate the ``identity:v1:<hash>`` entry, otherwise a deleted key
keeps authenticating until the identity-cache TTL expires.
"""
from litellm.identity.cache import IdentityCache
from litellm.proxy._types import LitellmUserRoles, hash_token
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.management_endpoints import key_management_endpoints as kme
raw_key = "sk-delete-identity"
hashed = hash_token(raw_key)
backend = UserApiKeyCache()
identity_cache = IdentityCache(dual_cache=backend)
await identity_cache.set(hashed, UserAPIKeyAuth(token=hashed, user_id="u1"))
assert await identity_cache.get(hashed) is not None
admin = UserAPIKeyAuth(
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin", api_key="sk-admin"
)
prisma = MagicMock()
prisma.delete_data = AsyncMock(return_value=[hashed])
key_row = MagicMock()
key_row.token = hashed
repo = MagicMock()
repo.table.find_many = AsyncMock(return_value=[key_row])
with (
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch.object(kme, "VerificationTokenRepository", MagicMock(return_value=repo)),
patch.object(kme, "_persist_deleted_verification_tokens", new=AsyncMock()),
):
await kme.delete_verification_tokens(
tokens=[raw_key],
user_api_key_cache=backend,
user_api_key_dict=admin,
)
assert await identity_cache.get(hashed) is None