mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
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:
parent
934ecdca78
commit
3372db4f8f
3 changed files with 405 additions and 11 deletions
|
|
@ -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)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Add table
Reference in a new issue