fix(proxy): scope team and agent activity endpoints per-entity (VERIA-43)

Two related cross-tenant leaks in the daily-activity endpoints:

1. `/team/daily/activity` set a single `has_full_team_view` flag that
   went True if the caller was admin/perm-holder of ANY one of the
   requested teams. An admin of team A could pass `team_ids=A,B` and
   read team B's per-API-key breakdown even when they were only a
   plain member of team B. Require admin/permission on EVERY requested
   team for the unfiltered view; otherwise force fallback to the
   caller's own API keys for the whole request. Callers wanting wider
   coverage can split into separate requests.

2. `/agent/daily/activity` initialized an empty `where_condition` and
   returned every agent's spend/token rows on the proxy when
   `agent_ids` was omitted — the dashboard's "Top Agents Driving
   Spend" panel triggered this for any authenticated user. For
   non-admin callers, scope the query to agents they're permitted to
   invoke (`AgentRequestHandler.get_allowed_agents`) or, when their
   key/team has no explicit agent allowlist, to agents they created
   (`created_by`). Explicit `agent_ids` is intersected with the same
   permitted set rather than trusted. When the resolved set is empty,
   return an empty paginated page without issuing the query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
user 2026-05-01 21:54:32 +00:00
parent 934ecdca78
commit 3372db4f8f
No known key found for this signature in database
3 changed files with 405 additions and 11 deletions

View file

@ -31,6 +31,7 @@ from litellm.types.agents import (
from litellm.litellm_core_utils.litellm_logging import _get_masked_values
from litellm.types.llms.custom_http import httpxSpecialProvider
from litellm.types.proxy.management_endpoints.common_daily_activity import (
DailySpendMetadata,
SpendAnalyticsPaginatedResponse,
)
@ -973,7 +974,57 @@ async def get_agent_daily_activity(
exclude_agent_ids.split(",") if exclude_agent_ids else None
)
where_condition = {}
# Without scoping, an empty `agent_ids` query returned every agent's
# spend/token rows on the proxy. Restrict non-admin callers to the
# agents they're permitted to invoke (or that they created), and
# intersect their explicit `agent_ids` filter with the same allowlist.
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
AgentRequestHandler,
)
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
where_condition: Dict[str, Any] = {}
if not _user_has_admin_view(user_api_key_dict):
permitted_agent_ids = await AgentRequestHandler.get_allowed_agents(
user_api_key_auth=user_api_key_dict
)
# `get_allowed_agents` returns an empty list when the caller's key
# and team carry no agent restrictions. For activity scoping that's
# not "see everything" — fall back to the agents the caller
# created so they cannot enumerate other tenants' agents.
if not permitted_agent_ids:
owned_records = await prisma_client.db.litellm_agentstable.find_many(
where={"created_by": user_api_key_dict.user_id}
)
permitted_agent_ids = [a.agent_id for a in owned_records]
if agent_ids_list:
agent_ids_list = [
aid for aid in agent_ids_list if aid in permitted_agent_ids
]
else:
agent_ids_list = list(permitted_agent_ids)
# No accessible agents → return an empty page without querying.
if not agent_ids_list:
return SpendAnalyticsPaginatedResponse(
results=[],
metadata=DailySpendMetadata(
total_spend=0.0,
total_prompt_tokens=0,
total_completion_tokens=0,
total_tokens=0,
total_api_requests=0,
total_successful_requests=0,
total_failed_requests=0,
total_cache_read_input_tokens=0,
total_cache_creation_input_tokens=0,
page=page,
total_pages=0,
has_more=False,
),
)
if agent_ids_list:
where_condition["agent_id"] = {"in": list(agent_ids_list)}

View file

@ -5026,24 +5026,30 @@ async def get_team_daily_activity(
}
# Check if user is team admin or has /team/daily/activity permission
# If not, filter by user's API keys
# If not, filter by user's API keys.
#
# Earlier this loop used `any-team admin -> set has_full_team_view=True
# for the entire request`, so an admin of one team that requested
# data for several teams would see API-key-level breakdowns for all
# of them. Require full view on EVERY requested team — if the caller
# only has admin/permission for a strict subset, fall back to
# filtering the entire response by their own API keys (they can re-
# request the admin-only teams separately to get the wider view).
user_api_keys: Optional[List[str]] = None
if not _user_has_admin_view(user_api_key_dict) and team_ids_list and team_aliases:
# Check if user is team admin or has usage view permission for any team
has_full_team_view = False
has_full_team_view = True
for team_alias in team_aliases:
team_obj = LiteLLM_TeamTable(**team_alias.model_dump())
if _is_user_team_admin(
is_admin = _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=team_obj
):
has_full_team_view = True
break
if _team_member_has_permission(
)
has_perm = _team_member_has_permission(
user_api_key_dict=user_api_key_dict,
team_obj=team_obj,
permission="/team/daily/activity",
):
has_full_team_view = True
)
if not (is_admin or has_perm):
has_full_team_view = False
break
# If user does not have full team view, filter by their API keys

View file

@ -0,0 +1,337 @@
"""
VERIA-43 regression tests:
- /team/daily/activity must require admin/permission on EVERY requested
team for the unfiltered "full team view" path. The earlier code set a
global flag if the caller was admin of any one of the requested teams.
- /agent/daily/activity must scope non-admin callers to the agents they
may actually see, instead of returning the entire proxy's agent rows.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
# ---------------------------------------------------------------------------
# /team/daily/activity — per-team admin/permission requirement
# ---------------------------------------------------------------------------
def _make_team(team_id: str, admin_user_ids: list):
"""Build a Prisma-compatible team row. `admin_user_ids` are inserted as
`members_with_roles[*].role == "admin"` because that's what
`_is_user_team_admin` checks."""
members_with_roles = [{"user_id": uid, "role": "admin"} for uid in admin_user_ids]
row = MagicMock()
row.team_id = team_id
row.team_alias = team_id
row.admins = admin_user_ids
row.members_with_roles = members_with_roles
row.model_dump = MagicMock(
return_value={
"team_id": team_id,
"team_alias": team_id,
"admins": admin_user_ids,
"members_with_roles": members_with_roles,
"members": [],
}
)
return row
@pytest.mark.asyncio
async def test_team_activity_requires_admin_on_every_requested_team():
"""If the caller is admin of one team but only a member of another in
the same request, the response MUST be filtered down to their own
keys the previous code returned a full breakdown."""
from litellm.proxy.management_endpoints import team_endpoints
user = UserAPIKeyAuth(
user_id="alice",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
# Mock prisma client
prisma = MagicMock()
prisma.db.litellm_teamtable.find_many = AsyncMock(
return_value=[
_make_team("team-A", admin_user_ids=["alice"]),
_make_team("team-B", admin_user_ids=["bob"]),
]
)
user_keys = MagicMock(token="alice-key-1")
prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[user_keys])
# Mock get_user_object so the non-admin branch passes
user_info = MagicMock()
user_info.teams = ["team-A", "team-B"]
captured = {}
async def _fake_get_daily_activity(**kwargs):
captured.update(kwargs)
return MagicMock()
with (
patch.object(team_endpoints, "prisma_client", prisma, create=True),
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new=AsyncMock(return_value=user_info),
),
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
new=AsyncMock(side_effect=_fake_get_daily_activity),
),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
):
await team_endpoints.get_team_daily_activity(
team_ids="team-A,team-B",
start_date="2026-01-01",
end_date="2026-01-02",
user_api_key_dict=user,
)
# Caller is only admin of team-A; team-B forces fallback to user-key
# filtering for the entire request.
assert captured["api_key"] == ["alice-key-1"]
@pytest.mark.asyncio
async def test_team_activity_full_view_when_admin_of_all_requested_teams():
"""When the caller is admin of *every* team requested, no api_key
filter is forced they're allowed the unfiltered breakdown."""
from litellm.proxy.management_endpoints import team_endpoints
user = UserAPIKeyAuth(
user_id="alice",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
prisma = MagicMock()
prisma.db.litellm_teamtable.find_many = AsyncMock(
return_value=[
_make_team("team-A", admin_user_ids=["alice"]),
_make_team("team-B", admin_user_ids=["alice"]),
]
)
user_info = MagicMock()
user_info.teams = ["team-A", "team-B"]
captured = {}
async def _fake_get_daily_activity(**kwargs):
captured.update(kwargs)
return MagicMock()
with (
patch.object(team_endpoints, "prisma_client", prisma, create=True),
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new=AsyncMock(return_value=user_info),
),
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_daily_activity",
new=AsyncMock(side_effect=_fake_get_daily_activity),
),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
):
await team_endpoints.get_team_daily_activity(
team_ids="team-A,team-B",
start_date="2026-01-01",
end_date="2026-01-02",
user_api_key_dict=user,
)
assert captured["api_key"] is None
# ---------------------------------------------------------------------------
# /agent/daily/activity — non-admin tenant scoping
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_agent_activity_admin_unscoped():
"""Proxy admin: agent_ids omitted → no scoping (existing behavior)."""
from litellm.proxy.agent_endpoints import endpoints
admin = UserAPIKeyAuth(user_id="root", user_role=LitellmUserRoles.PROXY_ADMIN.value)
prisma = MagicMock()
prisma.db.litellm_agentstable.find_many = AsyncMock(return_value=[])
captured = {}
async def _fake_get_daily_activity(**kwargs):
captured.update(kwargs)
return MagicMock()
with (
patch.object(endpoints, "prisma_client", prisma, create=True),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch(
"litellm.proxy.agent_endpoints.endpoints.check_feature_access_for_user",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.agent_endpoints.endpoints.get_daily_activity",
new=AsyncMock(side_effect=_fake_get_daily_activity),
),
):
await endpoints.get_agent_daily_activity(
agent_ids=None,
start_date="2026-01-01",
end_date="2026-01-02",
user_api_key_dict=admin,
)
assert captured["entity_id"] is None # no agent_id scoping for admin
@pytest.mark.asyncio
async def test_agent_activity_non_admin_no_perms_falls_back_to_owned():
"""Non-admin without explicit agent permissions: scope to agents they
created. An empty `agent_ids` query must NOT return everyone's agents."""
from litellm.proxy.agent_endpoints import endpoints
user = UserAPIKeyAuth(
user_id="alice",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
owned = [MagicMock(agent_id="agent-alice-1"), MagicMock(agent_id="agent-alice-2")]
prisma = MagicMock()
# First call: lookup of owned agents (created_by=alice).
# Second call: agent_metadata fetch for the resolved set.
prisma.db.litellm_agentstable.find_many = AsyncMock(side_effect=[owned, owned])
captured = {}
async def _fake_get_daily_activity(**kwargs):
captured.update(kwargs)
return MagicMock()
with (
patch.object(endpoints, "prisma_client", prisma, create=True),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch(
"litellm.proxy.agent_endpoints.endpoints.check_feature_access_for_user",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.get_allowed_agents",
new=AsyncMock(return_value=[]), # no explicit agent permissions
),
patch(
"litellm.proxy.agent_endpoints.endpoints.get_daily_activity",
new=AsyncMock(side_effect=_fake_get_daily_activity),
),
):
await endpoints.get_agent_daily_activity(
agent_ids=None,
start_date="2026-01-01",
end_date="2026-01-02",
user_api_key_dict=user,
)
# entity_id must be the user's owned agents, not None (which would
# match every row).
assert sorted(captured["entity_id"]) == ["agent-alice-1", "agent-alice-2"]
@pytest.mark.asyncio
async def test_agent_activity_non_admin_intersects_explicit_agent_ids():
"""When the caller passes `agent_ids`, the result is intersected with
their permitted set rather than trusting the request."""
from litellm.proxy.agent_endpoints import endpoints
user = UserAPIKeyAuth(
user_id="alice",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
prisma = MagicMock()
prisma.db.litellm_agentstable.find_many = AsyncMock(return_value=[])
captured = {}
async def _fake_get_daily_activity(**kwargs):
captured.update(kwargs)
return MagicMock()
with (
patch.object(endpoints, "prisma_client", prisma, create=True),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch(
"litellm.proxy.agent_endpoints.endpoints.check_feature_access_for_user",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.get_allowed_agents",
new=AsyncMock(return_value=["agent-permitted"]),
),
patch(
"litellm.proxy.agent_endpoints.endpoints.get_daily_activity",
new=AsyncMock(side_effect=_fake_get_daily_activity),
),
):
await endpoints.get_agent_daily_activity(
agent_ids="agent-permitted,agent-someone-elses",
start_date="2026-01-01",
end_date="2026-01-02",
user_api_key_dict=user,
)
# Only the permitted agent survives the intersection.
assert captured["entity_id"] == ["agent-permitted"]
@pytest.mark.asyncio
async def test_agent_activity_non_admin_no_access_returns_empty_page():
"""Non-admin with no permitted agents and no owned agents must get an
empty paginated response without an unscoped DB query."""
from litellm.proxy.agent_endpoints import endpoints
user = UserAPIKeyAuth(
user_id="alice",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
prisma = MagicMock()
prisma.db.litellm_agentstable.find_many = AsyncMock(return_value=[])
fake_get_daily = AsyncMock()
with (
patch.object(endpoints, "prisma_client", prisma, create=True),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch(
"litellm.proxy.agent_endpoints.endpoints.check_feature_access_for_user",
new=AsyncMock(return_value=None),
),
patch(
"litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.get_allowed_agents",
new=AsyncMock(return_value=[]),
),
patch(
"litellm.proxy.agent_endpoints.endpoints.get_daily_activity",
new=fake_get_daily,
),
):
result = await endpoints.get_agent_daily_activity(
agent_ids=None,
start_date="2026-01-01",
end_date="2026-01-02",
user_api_key_dict=user,
)
assert result.results == []
fake_get_daily.assert_not_awaited()