litellm/tests/integration/spend/test_spend_log_read_scope.py
yujonglee 677205b3f5
fix(proxy): share ownership permissions for spend logs and traces (#44239)
* refactor(proxy): extract shared spend log read policy

* test(proxy): use named bindings for spend scope regression

* test(proxy): reuse existing spend log query harness

* test(proxy): cover spend log permission lookup adoption

* chore(proxy): relocate existing spend query baseline

* refactor(proxy): make scope query returns explicit

* refactor(proxy): inject deferred log permission lookup

* test(proxy): cover teamless management compatibility lookup

* refactor(proxy): compose user and team log grants

* refactor(proxy): share generic authorization composition

* refactor(proxy): compose trace read permissions

* refactor(proxy): centralize spend and trace authorization

* refactor(proxy): strengthen spend and trace scope types

* refactor(proxy): flatten log read scope into owned logs

Replace the AnyOf grant tree with a flat OwnedLogs(user_id, team_ids) scope,
and OwnedTraces(logs, api_key_hash) for traces, since every consumer flattened
the tree back into that shape.

A caller with no user id now gets an empty scope instead of matching ownerless
rows through Prisma's IS NULL. The dead request_id guard in ui_view_spend_logs
is removed, and the management facets inject the log team lookup and reuse
read_scope_sql instead of the list shim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(proxy): run spend scope tests through one SQLite emulator

Replace the string-matching payload emulator and the hand-rolled Prisma where
interpreter with one SQLite helper that runs the real scope SQL. Session scope
tests now go through the endpoint, including the no-user caller that must not
match ownerless rows. Drop duplicated lookup-failure and trace mapping cases.

load_permitted_log_team_ids returns no teams without a database instead of
relying on the resolver's broad except.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(proxy): unify log and trace ownership permissions

* test(tracing): align fixtures with ownership read scopes

* refactor(tracing): align query scopes with row ownership

* refactor(spend): make ownership SQL predicates explicit

* test(spend): validate ownership SQL against PostgreSQL

* docs(traces): drop key-row visibility from query help guide

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(spend): reach the empty-memberships branch in team lookup test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(ui): regenerate dashboard API types

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-03 01:38:54 +00:00

226 lines
9.8 KiB
Python

import os
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Final
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
import psycopg
import pytest
import pytest_asyncio
from integration._support.client import Gateway
from prisma import Prisma
from psycopg import sql
from psycopg.types.json import Jsonb
from pydantic import TypeAdapter
from litellm.proxy.auth.authorization import AllRows, OwnedRows, ReadScope
from litellm.proxy.spend_tracking.spend_management_endpoints import _spend_log_payload_query, read_scope_sql
@dataclass(frozen=True, slots=True)
class SpendRow:
request_id: str
user: str | None
team_id: str | None
call_id: str | None = None
@dataclass(frozen=True, slots=True)
class RequestId:
request_id: str
REQUEST_IDS: Final = TypeAdapter(tuple[RequestId, ...])
ROWS: Final = (
SpendRow("own", "caller", None, "foreign"),
SpendRow("team-1", "other", "first"),
SpendRow("team-2", "third", "second"),
SpendRow("foreign", "other", "outside"),
SpendRow("ownerless", None, None),
SpendRow("team-ownerless", None, "first"),
)
def _seed_rows(
connection: psycopg.Connection,
schema: str,
rows: tuple[SpendRow, ...],
session_id: str,
started: datetime,
) -> None:
utc_timestamp: Final = started.astimezone(timezone.utc).replace(tzinfo=None)
with connection.cursor() as cursor:
cursor.executemany(
sql.SQL(
'INSERT INTO {} (request_id, "user", team_id, litellm_call_id, session_id, '
'"startTime", "endTime", messages, response, call_type) '
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'acompletion')"
).format(sql.Identifier(schema, "LiteLLM_SpendLogs")),
tuple(
(
row.request_id,
row.user,
row.team_id,
row.call_id,
session_id,
utc_timestamp,
utc_timestamp,
Jsonb([{"role": "user", "content": row.request_id + " payload"}]),
Jsonb({"id": row.request_id}),
)
for row in rows
),
)
@pytest_asyncio.fixture(loop_scope="function")
async def spend_database() -> AsyncIterator[Prisma]:
schema: Final = f"integration_spend_scope_{uuid.uuid4().hex}"
url: Final = os.environ["DATABASE_URL"]
parsed: Final = urlsplit(url)
scoped_url: Final = urlunsplit(
parsed._replace(query=urlencode({**dict(parse_qsl(parsed.query)), "schema": schema}))
)
with psycopg.connect(url, autocommit=True) as setup:
setup.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema)))
try:
setup.execute(
sql.SQL('CREATE TABLE {} (LIKE public."LiteLLM_SpendLogs" INCLUDING ALL)').format(
sql.Identifier(schema, "LiteLLM_SpendLogs")
)
)
_seed_rows(setup, schema, ROWS, "scope-session", datetime(2026, 1, 1, tzinfo=timezone.utc))
database: Final = Prisma(datasource={"url": scoped_url})
await database.connect()
try:
yield database
finally:
await database.disconnect()
finally:
setup.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema)))
@pytest.mark.asyncio
@pytest.mark.parametrize("preceding_filters", [False, True])
@pytest.mark.parametrize(
("scope", "user_filter", "expected"),
[
(AllRows(), None, ("foreign", "own", "ownerless", "team-1", "team-2", "team-ownerless")),
(OwnedRows("caller"), None, ("own",)),
(OwnedRows(None), None, ()),
(OwnedRows(None, ("first", "second")), None, ("team-1", "team-2", "team-ownerless")),
(OwnedRows(None, ("first", "second")), "other", ("team-1",)),
(OwnedRows("caller", ("first", "second")), None, ("own", "team-1", "team-2", "team-ownerless")),
(OwnedRows("caller", ("first", "second")), "other", ("team-1",)),
(OwnedRows("caller", ("first' OR TRUE --",)), None, ("own",)),
(OwnedRows("caller' OR TRUE --", ("first",)), None, ("team-1", "team-ownerless")),
],
)
async def test_ownership_sql_selects_allowed_rows_and_intersects_filters(
spend_database: Prisma,
scope: ReadScope,
user_filter: str | None,
expected: tuple[str, ...],
preceding_filters: bool,
) -> None:
window_params: Final = ("scope-session", "2026-01-01", "2026-01-02") if preceding_filters else ()
window_sql: Final = (
'session_id = $1 AND "startTime" >= $2::timestamp AND "startTime" < $3::timestamp AND '
if preceding_filters
else ""
)
clause, scope_params = read_scope_sql(scope, len(window_params) + 1)
filter_sql: Final = f' AND "user" = ${len(window_params) + len(scope_params) + 1}' if user_filter else ""
params: Final = window_params + scope_params + ((user_filter,) if user_filter else ())
result: Final = await spend_database.query_raw(
f'SELECT request_id FROM "LiteLLM_SpendLogs" WHERE {window_sql}{clause or "TRUE"}{filter_sql} '
"ORDER BY request_id",
*params,
)
assert tuple(row.request_id for row in REQUEST_IDS.validate_python(result)) == expected
@pytest.mark.asyncio
@pytest.mark.parametrize(
("scope", "expected"),
[(AllRows(), ("foreign",)), (OwnedRows("caller"), ("own",)), (OwnedRows(None), ())],
)
async def test_payload_sql_filters_foreign_collisions_and_prefers_exact_ids_for_admins(
spend_database: Prisma, scope: ReadScope, expected: tuple[str, ...]
) -> None:
query, params = _spend_log_payload_query("foreign", scope)
result: Final = await spend_database.query_raw(query, *params)
assert tuple(row.request_id for row in REQUEST_IDS.validate_python(result)) == expected
def _delete_session(session_id: str) -> None:
with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
connection.execute('DELETE FROM "LiteLLM_SpendLogs" WHERE session_id = %s', (session_id,))
@pytest.mark.parametrize(
("member_role", "permissions", "team_access"),
[
("admin", [], True),
("user", ["/spend/logs"], True),
("user", ["/key/info"], False),
("user", [], False),
],
)
def test_spend_log_routes_preserve_user_and_permitted_team_access(
gateway: Gateway, member_role: str, permissions: list[str], team_access: bool
) -> None:
session_id: Final = f"scope-{uuid.uuid4().hex}"
started: Final = datetime.now(timezone.utc) - timedelta(hours=1)
with gateway.scenario() as scenario:
caller: Final = scenario.user(user_role="internal_user")
other: Final = scenario.user(user_role="internal_user")
team: Final = scenario.team(
members_with_roles=[{"user_id": caller, "role": member_role}],
team_member_permissions=list(permissions),
)
outside_team: Final = scenario.team(
members_with_roles=[{"user_id": other, "role": "admin"}],
team_member_permissions=["/spend/logs"],
)
key: Final = scenario.key(user_id=caller)
other_key: Final = scenario.key(user_id=other)
rows: Final = (
SpendRow(session_id + "-own", caller, None, session_id + "-foreign"),
SpendRow(session_id + "-team", other, team),
SpendRow(session_id + "-foreign", other, outside_team),
SpendRow(session_id + "-ownerless", None, None),
SpendRow(session_id + "-outside", other, outside_team),
)
scenario.cleanups.callback(_delete_session, session_id)
with psycopg.connect(os.environ["DATABASE_URL"]) as connection:
_seed_rows(connection, "public", rows, session_id, started)
expected: Final = (rows[0].request_id, rows[1].request_id) if team_access else (rows[0].request_id,)
session: Final = gateway.request("GET", "/spend/logs/session/ui", key=key, params={"session_id": session_id})
assert session.status_code == 200, session.text
assert session.json()["total"] == len(expected), session.text
assert sorted(row["request_id"] for row in session.json()["data"]) == list(expected), session.text
filters: Final = {
"session_id": session_id,
"start_date": (started - timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S"),
"end_date": (started + timedelta(hours=1)).strftime("%Y-%m-%d %H:%M:%S"),
}
listed: Final = gateway.request("GET", "/spend/logs/ui", key=key, params=filters)
assert listed.status_code == 200, listed.text
assert sorted(row["request_id"] for row in listed.json()["data"]) == list(expected), listed.text
narrowed: Final = gateway.request("GET", "/spend/logs/ui", key=key, params={**filters, "user_id": other})
assert narrowed.status_code == 200, narrowed.text
assert [row["request_id"] for row in narrowed.json()["data"]] == (
[rows[1].request_id] if team_access else []
), narrowed.text
refused: Final = gateway.request("GET", f"/spend/logs/ui/{rows[4].request_id}", key=key)
assert refused.status_code == 403, refused.text
for caller_key, expected_id in ((key, rows[0].request_id), (other_key, rows[2].request_id)):
payload: Final = gateway.request("GET", f"/spend/logs/ui/{rows[2].request_id}", key=caller_key)
assert payload.status_code == 200, payload.text
assert payload.json()["messages"] == [{"role": "user", "content": expected_id + " payload"}], payload.text
admin: Final = gateway.request("GET", f"/spend/logs/ui/{rows[2].request_id}")
assert admin.status_code == 200, admin.text
assert admin.json()["messages"] == [{"role": "user", "content": rows[2].request_id + " payload"}], admin.text