mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
feat(proxy): allow virtual keys to view own spend logs (#49090)
This commit is contained in:
parent
a392a39627
commit
f804cda1c6
4 changed files with 372 additions and 27 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue