feat(proxy): allow virtual keys to view own spend logs (#49090)

This commit is contained in:
Harshvardhan 2026-10-31 14:23:58 +05:30
parent a392a39627
commit f804cda1c6
4 changed files with 372 additions and 27 deletions

View file

@ -369,7 +369,7 @@ class RouteChecks:
route in LiteLLMRoutes.global_spend_tracking_routes.value
and getattr(valid_token, "permissions", None) is not None
and "get_spend_routes" in getattr(valid_token, "permissions", [])
):
) or route in ("/spend/logs", "/spend/logs/v2"):
pass
elif route == "/lens" or route.startswith("/lens/"):
pass # Lens authorizes the delegated role, including its POST-based read endpoints.

View file

@ -2737,11 +2737,34 @@ async def ui_view_spend_logs(
if status_condition:
where_conditions.update(status_condition)
if api_key is not None:
where_conditions["api_key"] = api_key
if user_id is not None:
where_conditions["user"] = user_id
caller_key_hash: Final = _extract_caller_key_hash(
user_api_key_dict=user_api_key_dict, prisma_client=prisma_client
)
if not is_admin_view and is_v2 and caller_key_hash is not None:
if api_key is not None:
requested_key_hash: Final = (
_hash_token_safe(prisma_client, api_key) if api_key.startswith("sk-") else api_key
)
if requested_key_hash != caller_key_hash:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": f"Not authorized to view spend logs for api_key={api_key}"},
)
where_conditions["api_key"] = caller_key_hash
if user_id is not None:
if user_api_key_dict.user_id is None or user_id != user_api_key_dict.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": f"Not authorized to view spend logs for user_id={user_id}"},
)
where_conditions["user"] = user_id
else:
if api_key is not None:
where_conditions["api_key"] = (
_hash_token_safe(prisma_client, api_key) if api_key.startswith("sk-") and is_v2 else api_key
)
if user_id is not None:
where_conditions["user"] = user_id
if request_id is not None:
where_conditions["request_id"] = request_id
@ -2807,6 +2830,7 @@ async def ui_view_spend_logs(
)
user_scope_applies: Final = (
not is_admin_view
and (not is_v2 or caller_key_hash is None)
and team_id is None
and (is_request_id_lookup or _can_user_view_spend_log(user_api_key_dict=user_api_key_dict))
)
@ -3565,11 +3589,16 @@ async def view_spend_logs(
"""
from litellm.proxy.proxy_server import prisma_client
if (
user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER
or user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
):
user_id = user_api_key_dict.user_id
is_admin: Final = is_admin_view_safe(user_api_key_dict=user_api_key_dict)
caller_key_hash: Final = _extract_caller_key_hash(user_api_key_dict=user_api_key_dict, prisma_client=prisma_client)
effective_api_key, effective_user_id = _resolve_effective_filters(
api_key=api_key,
user_id=user_id,
user_api_key_dict=user_api_key_dict,
is_admin=is_admin,
caller_key_hash=caller_key_hash,
prisma_client=prisma_client,
)
try:
verbose_proxy_logger.debug("inside view_spend_logs")
@ -3599,16 +3628,16 @@ async def view_spend_logs(
}
summary_api_key: Final[str | None] = (
prisma_client.hash_token(token=api_key)
if api_key is not None and api_key.startswith("sk-")
else api_key
_hash_token_safe(prisma_client, effective_api_key)
if effective_api_key is not None and effective_api_key.startswith("sk-")
else effective_api_key
)
if api_key is not None and isinstance(api_key, str):
if effective_api_key is not None:
filter_query["api_key"] = summary_api_key
if request_id is not None and isinstance(request_id, str):
filter_query["OR"] = _request_id_or_call_id_clause(request_id)
if user_id is not None and isinstance(user_id, str):
filter_query["user"] = user_id
if effective_user_id is not None:
filter_query["user"] = effective_user_id
# Check if user wants unsummarized data
if not summarize:
@ -3628,7 +3657,7 @@ async def view_spend_logs(
end_date_iso=end_date_iso,
api_key=summary_api_key,
request_id=request_id,
user_id=user_id,
user_id=effective_user_id,
)
sql_query, params = summary_sql_and_params
rows: Final[Sequence[_SpendDailySummaryRow]] = await _query_raw(prisma_client, sql_query, *params)
@ -3655,16 +3684,17 @@ async def view_spend_logs(
else:
scoped_filter: Final[dict[str, object]] = {}
if api_key is not None and isinstance(api_key, str):
if api_key.startswith("sk-"):
hashed_token = prisma_client.hash_token(token=api_key)
else:
hashed_token = api_key
if effective_api_key is not None:
hashed_token: Final = (
_hash_token_safe(prisma_client, effective_api_key)
if effective_api_key.startswith("sk-")
else effective_api_key
)
scoped_filter["api_key"] = hashed_token
if request_id is not None and isinstance(request_id, str):
scoped_filter["OR"] = _request_id_or_call_id_clause(request_id)
if user_id is not None and isinstance(user_id, str):
scoped_filter["user"] = user_id
if effective_user_id is not None:
scoped_filter["user"] = effective_user_id
data = await _find_spend_logs(
prisma_client,
@ -4867,6 +4897,56 @@ def is_admin_view_safe(user_api_key_dict: UserAPIKeyAuth) -> bool:
_is_admin_view_safe: Final = is_admin_view_safe
def _hash_token_safe(prisma_client: PrismaClient | None, token: str) -> str:
if prisma_client is not None and hasattr(prisma_client, "hash_token"):
return prisma_client.hash_token(token=token)
return hash_token(token)
def _extract_caller_key_hash(
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient | None = None,
) -> str | None:
raw_or_hash: Final = user_api_key_dict.token or user_api_key_dict.api_key
if raw_or_hash is None:
return None
if raw_or_hash.startswith("sk-"):
return _hash_token_safe(prisma_client, raw_or_hash)
return raw_or_hash
def _resolve_effective_filters(
api_key: str | None,
user_id: str | None,
user_api_key_dict: UserAPIKeyAuth,
is_admin: bool,
caller_key_hash: str | None,
prisma_client: PrismaClient | None,
) -> tuple[str | None, str | None]:
if not is_admin and caller_key_hash is not None:
if api_key is not None:
requested_key_hash: Final = (
_hash_token_safe(prisma_client, api_key) if api_key.startswith("sk-") else api_key
)
if requested_key_hash != caller_key_hash:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Key not allowed to view spend logs for another key. api_key={api_key}",
)
if user_id is not None and (user_api_key_dict.user_id is None or user_id != user_api_key_dict.user_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Key not allowed to view spend logs for another user. user_id={user_id}",
)
return (caller_key_hash, user_api_key_dict.user_id if user_id is None else user_id)
if (
user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER
or user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY
):
return (api_key, user_api_key_dict.user_id)
return (api_key, user_id)
async def _can_team_member_view_log(
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,

View file

@ -215,7 +215,9 @@ def test_user_banner_read_open_to_non_admin_roles(role):
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
],
)
def test_latest_release_info_read_open_to_non_admin_roles(role): # test-quality-ok: allowed path returns None, not raising is the observable
def test_latest_release_info_read_open_to_non_admin_roles(
role,
): # test-quality-ok: allowed path returns None, not raising is the observable
user_obj = LiteLLM_UserTable(
user_id="test_user",
user_email="test@example.com",
@ -4448,7 +4450,7 @@ def test_team_service_account_key_allowed_key_management_routes(route):
assert result is None
@pytest.mark.parametrize("route", ["/team/new", "/spend/logs", "/key/delete", "/key/regenerate"])
@pytest.mark.parametrize("route", ["/team/new", "/key/delete", "/key/regenerate"])
def test_team_service_account_key_rejected_outside_generate_and_update(route):
"""The service account carve-out covers only /key/generate and /key/update; other
key-management routes lack team scoping for a userless caller and stay denied."""
@ -5055,3 +5057,22 @@ def test_every_dashboard_role_can_send_telemetry_ui_events(
)
except HTTPException as exc:
pytest.fail(f"{user_role.value} was denied {method} {route}: {exc.detail}")
@pytest.mark.parametrize("route", ["/spend/logs", "/spend/logs/v2"])
def test_spend_logs_routes_allowed_for_plain_virtual_key(route: str) -> None:
request: Final = MagicMock(spec=Request)
request.method = "GET"
request.query_params = {}
valid_token: Final = UserAPIKeyAuth(token="hashed_token_value")
assert (
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=None,
_user_role=None,
route=route,
request=request,
valid_token=valid_token,
request_data={},
)
is None
)

View file

@ -5834,6 +5834,250 @@ async def test_view_spend_logs_date_range_hashes_sk_api_key(client, monkeypatch)
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_view_spend_logs_plain_virtual_key_defaults_to_own_key_hash(client, monkeypatch):
mock_client: Final = _CapturePrismaClient()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
token="key-hash-123",
user_role=None,
user_id=None,
)
try:
response: Final = client.get(
"/spend/logs",
params={"start_date": "2024-01-01", "end_date": "2024-12-31", "summarize": "false"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
where: Final = mock_client.db.captured_where
assert where is not None
assert where["api_key"] == "key-hash-123"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_v2_plain_virtual_key_with_matching_sk_raw_api_key(client, monkeypatch):
token_hash: Final = "key-hash-123"
mock_spend_logs: Final = [
{
"id": "log1",
"request_id": "req1",
"api_key": token_hash,
"user": None,
"team_id": None,
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
},
]
def filter_by_key(where):
if "api_key" in where and where["api_key"] == token_hash:
return [mock_spend_logs[0]]
return []
mock_prisma: Final = make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_key)
mock_prisma.hash_token = lambda token: token_hash if token == "sk-raw-token" else "other"
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
token=token_hash,
user_role=None,
user_id=None,
)
try:
start_date, end_date = _default_date_range()
response: Final = client.get(
"/spend/logs/v2",
params={"api_key": "sk-raw-token", "start_date": start_date, "end_date": end_date},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data: Final = response.json()
assert data["total"] == 1
assert len(data["data"]) == 1
assert data["data"][0]["api_key"] == token_hash
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_view_spend_logs_plain_virtual_key_with_matching_api_key(client, monkeypatch):
mock_client: Final = _CapturePrismaClient()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
token="key-hash-123",
user_role=None,
user_id=None,
)
try:
response: Final = client.get(
"/spend/logs",
params={"api_key": "key-hash-123"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
where: Final = mock_client.db.captured_where
assert where is not None
assert where["api_key"] == "key-hash-123"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_view_spend_logs_plain_virtual_key_rejects_foreign_api_key(client, monkeypatch):
mock_client: Final = _CapturePrismaClient()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
token="key-hash-123",
user_role=None,
user_id=None,
)
try:
response: Final = client.get(
"/spend/logs",
params={"api_key": "different-key-hash"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 403
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_view_spend_logs_plain_virtual_key_rejects_foreign_user_id(client, monkeypatch):
mock_client: Final = _CapturePrismaClient()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
token="key-hash-123",
user_role=None,
user_id=None,
)
try:
response: Final = client.get(
"/spend/logs",
params={"user_id": "other-user"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 403
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_v2_plain_virtual_key_defaults_to_own_key_hash(client, monkeypatch):
mock_spend_logs: Final = [
{
"id": "log1",
"request_id": "req1",
"api_key": "key-hash-123",
"user": None,
"team_id": None,
"spend": 0.05,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
},
{
"id": "log2",
"request_id": "req2",
"api_key": "other-key-hash",
"user": None,
"team_id": None,
"spend": 0.10,
"startTime": datetime.datetime.now(timezone.utc).isoformat(),
"model": "gpt-4",
},
]
def filter_by_key(where):
if "api_key" in where and where["api_key"] == "key-hash-123":
return [mock_spend_logs[0]]
return mock_spend_logs
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_key),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
token="key-hash-123",
user_role=None,
user_id=None,
)
try:
start_date, end_date = _default_date_range()
response: Final = client.get(
"/spend/logs/v2",
params={"start_date": start_date, "end_date": end_date},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data: Final = response.json()
assert data["total"] == 1
assert len(data["data"]) == 1
assert data["data"][0]["api_key"] == "key-hash-123"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_v2_plain_virtual_key_rejects_foreign_api_key(client, monkeypatch):
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma([], lambda _where: []),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
token="key-hash-123",
user_role=None,
user_id=None,
)
try:
start_date, end_date = _default_date_range()
response: Final = client.get(
"/spend/logs/v2",
params={
"api_key": "other-key-hash",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 403
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
@pytest.mark.asyncio
async def test_ui_view_spend_logs_v2_plain_virtual_key_rejects_foreign_user_id(client, monkeypatch):
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma([], lambda _where: []),
)
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
token="key-hash-123",
user_role=None,
user_id=None,
)
try:
start_date, end_date = _default_date_range()
response: Final = client.get(
"/spend/logs/v2",
params={
"user_id": "other-user",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 403
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)
class _SpendScopeMockPrismaClient:
def __init__(self, get_data_returns=None, find_many_returns=None):
self._get_data_returns = get_data_returns if get_data_returns is not None else []