From 3372db4f8ff23e5f60a11dd625fd3a47e82a684e Mon Sep 17 00:00:00 2001 From: user <70670632+stuxf@users.noreply.github.com> Date: Fri, 1 May 2026 21:54:32 +0000 Subject: [PATCH] fix(proxy): scope team and agent activity endpoints per-entity (VERIA-43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- litellm/proxy/agent_endpoints/endpoints.py | 53 ++- .../management_endpoints/team_endpoints.py | 26 +- .../test_activity_tenant_scoping.py | 337 ++++++++++++++++++ 3 files changed, 405 insertions(+), 11 deletions(-) create mode 100644 tests/test_litellm/proxy/management_endpoints/test_activity_tenant_scoping.py diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index c351d3cfecb..978e1a73be1 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -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)} diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 61247ce5deb..bed99e7fd51 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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 diff --git a/tests/test_litellm/proxy/management_endpoints/test_activity_tenant_scoping.py b/tests/test_litellm/proxy/management_endpoints/test_activity_tenant_scoping.py new file mode 100644 index 00000000000..ebc73f87562 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_activity_tenant_scoping.py @@ -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()