From 0b76f51dc93ad85fc70d97a8ee8cd12e198cd498 Mon Sep 17 00:00:00 2001 From: oss-agent-shin <279349115+oss-agent-shin@users.noreply.github.com> Date: Wed, 6 May 2026 18:43:28 +0000 Subject: [PATCH] Scope internal tag usage to own keys Co-authored-by: ishaan-berri --- litellm/proxy/_types.py | 11 +- .../tag_management_endpoints.py | 130 ++++++++++++++--- .../proxy/auth/test_route_checks.py | 35 +++++ .../test_tag_management_endpoints.py | 134 ++++++++++++++++++ .../components/UsagePageView.test.tsx | 30 +++- .../UsagePage/components/UsagePageView.tsx | 10 +- .../UsageViewSelect/UsageViewSelect.test.tsx | 12 ++ .../UsageViewSelect/UsageViewSelect.tsx | 5 + ui/litellm-dashboard/src/utils/roles.ts | 2 +- 9 files changed, 347 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c6653a722d6..d75dfdb4bf9 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -666,6 +666,10 @@ class LiteLLMRoutes(enum.Enum): "/global/activity", "/global/activity/model", "/global/activity/cache_hits", + # Tag usage endpoints scope internal users to tags produced by + # their own keys in tag_management_endpoints.py. + "/tag/daily/activity", + "/tag/list", "/v1/models/{model_id}", "/models/{model_id}", "/guardrails/list", @@ -677,7 +681,12 @@ class LiteLLMRoutes(enum.Enum): + key_management_routes ) - internal_user_view_only_routes = spend_tracking_routes + internal_user_view_only_routes = spend_tracking_routes + [ + # Tag usage endpoints scope internal viewers to tags produced by + # their own keys in tag_management_endpoints.py. + "/tag/daily/activity", + "/tag/list", + ] self_managed_routes = [ "/team/member_add", diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index 0e60820aab1..643c5f37105 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -12,12 +12,16 @@ All /tag management endpoints import asyncio import json -from typing import TYPE_CHECKING, Dict, List, Optional +from typing import TYPE_CHECKING, Dict, List, Optional, Union from fastapi import APIRouter, Depends, HTTPException from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy._types import ( + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, @@ -39,6 +43,76 @@ if TYPE_CHECKING: router = APIRouter() +def _is_internal_user_role(user_api_key_dict: UserAPIKeyAuth) -> bool: + return user_api_key_dict.user_role in ( + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER.value, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + ) + + +async def _get_internal_user_api_keys( + prisma_client, + user_api_key_dict: UserAPIKeyAuth, +) -> List[str]: + if not _is_internal_user_role(user_api_key_dict): + return [] + + user_id = user_api_key_dict.user_id + if user_id is None: + return [] + + key_records = await prisma_client.db.litellm_verificationtoken.find_many( + where={"user_id": user_id}, + select={"token": True}, + ) + user_api_keys = { + key_record.token + for key_record in key_records + if getattr(key_record, "token", None) + } + if user_api_key_dict.api_key: + user_api_keys.add(user_api_key_dict.api_key) + + return sorted(user_api_keys) + + +async def _get_tag_list_scope( + prisma_client, + user_api_key_dict: UserAPIKeyAuth, +) -> Optional[Dict[str, dict]]: + if user_api_key_has_admin_view(user_api_key_dict) or not _is_internal_user_role( + user_api_key_dict + ): + return None + + scoped_api_keys = await _get_internal_user_api_keys( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + ) + return {"api_key": {"in": scoped_api_keys}} + + +async def _get_tag_daily_activity_api_key_filter( + prisma_client, + user_api_key_dict: UserAPIKeyAuth, + requested_api_key: Optional[str], +) -> Optional[Union[str, List[str]]]: + if user_api_key_has_admin_view(user_api_key_dict) or not _is_internal_user_role( + user_api_key_dict + ): + return requested_api_key + + scoped_api_keys = await _get_internal_user_api_keys( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + ) + if requested_api_key is not None: + return requested_api_key if requested_api_key in scoped_api_keys else [] + return scoped_api_keys + + async def _get_model_names(prisma_client, model_ids: list) -> Dict[str, str]: """Helper function to get model names from model IDs""" try: @@ -412,9 +486,39 @@ async def list_tags( raise HTTPException(status_code=500, detail="Database not connected") try: + tag_scope = await _get_tag_list_scope( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + ) + + ## QUERY DYNAMIC TAGS ## + # Use group_by instead of find_many(distinct=["tag"]). + # Prisma's distinct fetches all columns for all rows and deduplicates + # in application code, which is extremely slow on large tables. + # See: https://www.prisma.io/docs/orm/prisma-client/queries/aggregation-grouping-summarizing#distinct-under-the-hood + dynamic_tag_where = {"tag": {"not": None}} + if tag_scope: + dynamic_tag_where = {**dynamic_tag_where, **tag_scope} + + dynamic_tag_rows = await prisma_client.db.litellm_dailytagspend.group_by( + by=["tag"], + where=dynamic_tag_where, + min={"created_at": True}, + max={"updated_at": True}, + ) + + used_tag_names = [row["tag"] for row in dynamic_tag_rows if row["tag"]] + if tag_scope is not None and not used_tag_names: + return [] + + stored_tag_where = ( + {"tag_name": {"in": used_tag_names}} if tag_scope is not None else None + ) + ## QUERY STORED TAGS ## tag_records = await prisma_client.db.litellm_tagtable.find_many( - include={"litellm_budget_table": True} + where=stored_tag_where, + include={"litellm_budget_table": True}, ) stored_tag_names = set() @@ -448,18 +552,6 @@ async def list_tags( list_of_tags.append(tag_dict) - ## QUERY DYNAMIC TAGS ## - # Use group_by instead of find_many(distinct=["tag"]). - # Prisma's distinct fetches all columns for all rows and deduplicates - # in application code, which is extremely slow on large tables. - # See: https://www.prisma.io/docs/orm/prisma-client/queries/aggregation-grouping-summarizing#distinct-under-the-hood - dynamic_tag_rows = await prisma_client.db.litellm_dailytagspend.group_by( - by=["tag"], - where={"tag": {"not": None}}, - min={"created_at": True}, - max={"updated_at": True}, - ) - dynamic_tag_config = [ { "name": row["tag"], @@ -527,6 +619,7 @@ async def get_tag_daily_activity( api_key: Optional[str] = None, page: int = 1, page_size: int = 10, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ Get daily activity for specific tags or all tags. @@ -547,6 +640,11 @@ async def get_tag_daily_activity( # Convert comma-separated tags string to list if provided tag_list = tags.split(",") if tags else None + scoped_api_key_filter = await _get_tag_daily_activity_api_key_filter( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + requested_api_key=api_key, + ) return await get_daily_activity( prisma_client=prisma_client, @@ -557,7 +655,7 @@ async def get_tag_daily_activity( start_date=start_date, end_date=end_date, model=model, - api_key=api_key, + api_key=scoped_api_key_filter, page=page, page_size=page_size, # metadata_metrics_func=None because litellm_dailytagspend rows are diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index cf6feabf85f..6d96797d46e 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1856,6 +1856,41 @@ def test_non_admin_non_team_admin_cannot_access_config_update_but_can_attempt_re assert "Only proxy admin can be used to generate" in str(exc_info.value) +@pytest.mark.parametrize( + "user_role", + [ + LitellmUserRoles.INTERNAL_USER.value, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + ], +) +@pytest.mark.parametrize("route", ["/tag/list", "/tag/daily/activity"]) +def test_internal_users_can_access_scoped_tag_usage_routes(user_role, route): + """ + Internal users can read tag usage endpoints because the endpoint handlers + scope results to the caller's own keys. + """ + user_obj = LiteLLM_UserTable( + user_id="test_user", + user_email="test@example.com", + user_role=user_role, + ) + valid_token = UserAPIKeyAuth( + user_id="test_user", + user_role=user_role, + ) + request = MagicMock(spec=Request) + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=user_role, + route=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + + @pytest.mark.parametrize( "user_role", [ diff --git a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py index 39ec6f075d7..710a9643a85 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py @@ -380,6 +380,140 @@ async def test_list_tags_no_dynamic_tags(): app.dependency_overrides.clear() +@pytest.mark.asyncio +async def test_internal_user_list_tags_only_returns_tags_used_by_their_keys(): + """ + Internal users can view tag usage, but the tag list must be scoped to tags + produced by API keys owned by the caller. + """ + from datetime import datetime + from unittest.mock import AsyncMock, Mock + + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + mock_user_auth = UserAPIKeyAuth( + api_key="current-owned-key", + user_id="internal-user-123", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth + + try: + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + mock_db = Mock() + mock_prisma.db = mock_db + + owned_key_record = Mock() + owned_key_record.token = "owned-key" + mock_db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[owned_key_record] + ) + + mock_db.litellm_dailytagspend.group_by = AsyncMock( + return_value=[ + { + "tag": "stored-owned-tag", + "_min": {"created_at": "2025-02-01T00:00:00Z"}, + "_max": {"updated_at": "2025-03-01T00:00:00Z"}, + }, + { + "tag": "dynamic-owned-tag", + "_min": {"created_at": "2025-02-02T00:00:00Z"}, + "_max": {"updated_at": "2025-03-02T00:00:00Z"}, + }, + ] + ) + + stored_tag = Mock() + stored_tag.tag_name = "stored-owned-tag" + stored_tag.description = "A stored tag used by the caller" + stored_tag.models = ["model-1"] + stored_tag.model_info = {} + stored_tag.spend = 0.0 + stored_tag.budget_id = None + stored_tag.created_at = datetime(2025, 1, 1) + stored_tag.updated_at = datetime(2025, 1, 1) + stored_tag.created_by = "admin-user" + stored_tag.litellm_budget_table = None + mock_db.litellm_tagtable.find_many = AsyncMock(return_value=[stored_tag]) + + response = client.get( + "/tag/list", + headers={"Authorization": "Bearer test-key"}, + ) + + assert response.status_code == 200 + assert [tag["name"] for tag in response.json()] == [ + "stored-owned-tag", + "dynamic-owned-tag", + ] + mock_db.litellm_verificationtoken.find_many.assert_awaited_once_with( + where={"user_id": "internal-user-123"}, + select={"token": True}, + ) + mock_db.litellm_dailytagspend.group_by.assert_awaited_once_with( + by=["tag"], + where={ + "tag": {"not": None}, + "api_key": {"in": ["current-owned-key", "owned-key"]}, + }, + min={"created_at": True}, + max={"updated_at": True}, + ) + mock_db.litellm_tagtable.find_many.assert_awaited_once_with( + where={"tag_name": {"in": ["stored-owned-tag", "dynamic-owned-tag"]}}, + include={"litellm_budget_table": True}, + ) + + finally: + app.dependency_overrides.clear() + + +@pytest.mark.asyncio +async def test_internal_user_tag_daily_activity_is_scoped_to_their_keys(): + """ + Internal users must not receive proxy-wide tag spend rows when viewing tag + usage daily activity. + """ + from unittest.mock import AsyncMock, Mock + + from litellm.proxy.management_endpoints.tag_management_endpoints import ( + get_tag_daily_activity, + ) + + mock_user_auth = UserAPIKeyAuth( + user_id="internal-user-123", + user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch( + "litellm.proxy.management_endpoints.tag_management_endpoints.get_daily_activity", + new_callable=AsyncMock, + ) as mock_get_daily_activity, + ): + mock_db = Mock() + mock_prisma.db = mock_db + + owned_key_record = Mock() + owned_key_record.token = "owned-key" + mock_db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[owned_key_record] + ) + mock_get_daily_activity.return_value = "daily-activity-response" + + result = await get_tag_daily_activity( + start_date="2025-01-01", + end_date="2025-01-31", + user_api_key_dict=mock_user_auth, + ) + + assert result == "daily-activity-response" + mock_get_daily_activity.assert_awaited_once() + assert mock_get_daily_activity.await_args.kwargs["api_key"] == ["owned-key"] + + @pytest.mark.asyncio async def test_get_deployments_by_model_id(): """ diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx index bbcddd572cd..b95f63368c7 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.test.tsx @@ -57,7 +57,10 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({ vi.mock("./UsageViewSelect/UsageViewSelect", async () => { const React = await import("react"); - const UsageViewSelect = ({ value, onChange }: any) => { + const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => { + const tagOption = canViewTagUsage + ? React.createElement("option", { value: "tag" }, "Tag Usage") + : null; return React.createElement( "select", { @@ -70,7 +73,7 @@ vi.mock("./UsageViewSelect/UsageViewSelect", async () => { React.createElement("option", { value: "team" }, "Team Usage"), React.createElement("option", { value: "organization" }, "Organization Usage"), React.createElement("option", { value: "customer" }, "Customer Usage"), - React.createElement("option", { value: "tag" }, "Tag Usage"), + tagOption, React.createElement("option", { value: "agent" }, "Agent Usage"), React.createElement("option", { value: "user-agent-activity" }, "User Agent Activity"), ); @@ -639,6 +642,29 @@ describe("UsagePage", () => { }); }); + it("should show tag usage selector option for internal users", async () => { + mockUseAuthorized.mockReturnValue({ + isLoading: false, + isAuthorized: true, + token: "mock-token", + accessToken: "test-token", + userId: "user-123", + userEmail: "test@example.com", + userRole: "internal_user", + premiumUser: true, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }); + + renderWithProviders(); + + await waitFor(() => { + expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + + expect(screen.getByRole("option", { name: "Tag Usage" })).toBeInTheDocument(); + }); + it("should show organization usage banner and view for admins", async () => { renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx index 809f1d4e17b..60a24c1e7df 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsagePageView.tsx @@ -31,7 +31,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { formatNumberWithCommas } from "@/utils/dataUtils"; -import { all_admin_roles } from "../../../utils/roles"; +import { all_admin_roles, internalUserRoles } from "../../../utils/roles"; import { ActivityMetrics, processActivityData } from "../../activity_metrics"; import CloudZeroExportModal from "../../cloudzero_export_modal"; import EntityUsageExportModal from "../../EntityUsageExport"; @@ -84,6 +84,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { console.log(`currentUser: ${JSON.stringify(currentUser)}`); console.log(`currentUser max budget: ${currentUser?.max_budget}`); const isAdmin = all_admin_roles.includes(userRole || ""); + const canViewTagUsage = isAdmin || internalUserRoles.includes(userRole || ""); // Debounced search for user selector const [userSearchInput, setUserSearchInput] = useState(""); @@ -437,7 +438,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
- setUsageView(value)} isAdmin={isAdmin} /> + setUsageView(value)} + isAdmin={isAdmin} + canViewTagUsage={canViewTagUsage} + />
{paginatedResult.isFetchingMore && ( diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.test.tsx index 7bb80b424b5..b33129fcdb4 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.test.tsx @@ -110,4 +110,16 @@ describe("UsageViewSelect", () => { expect(mockOnChange).toHaveBeenCalledWith("team"); }); + + it("should show Tag Usage for non-admin users with tag usage permission", () => { + render(); + + expect(screen.getByRole("option", { name: "Tag Usage" })).toBeInTheDocument(); + }); + + it("should hide Tag Usage for non-admin users without tag usage permission", () => { + render(); + + expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.tsx index 11fdb8a7cf5..184fc000274 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/UsageViewSelect/UsageViewSelect.tsx @@ -16,6 +16,7 @@ export interface UsageViewSelectProps { value: UsageOption; onChange: (value: UsageOption) => void; isAdmin: boolean; + canViewTagUsage?: boolean; title?: string; description?: string; "data-id"?: string; @@ -106,12 +107,16 @@ export const UsageViewSelect: React.FC = ({ value, onChange, isAdmin, + canViewTagUsage = false, title = "Usage View", description = "Select the usage data you want to view", "data-id": dataId, }) => { const getFilteredOptions = () => { return OPTIONS.filter((option) => { + if (option.value === "tag" && canViewTagUsage) { + return true; + } if (option.adminOnly && !isAdmin) { return false; } diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 11e31fd369c..bb23985d7c2 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -5,7 +5,7 @@ export const old_admin_roles = ["Admin", "Admin Viewer"]; export const v2_admin_role_names = ["proxy_admin", "proxy_admin_viewer", "org_admin"]; export const all_admin_roles = [...old_admin_roles, ...v2_admin_role_names]; -export const internalUserRoles = ["Internal User", "Internal Viewer"]; +export const internalUserRoles = ["Internal User", "Internal Viewer", "internal_user", "internal_user_viewer"]; export const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Internal Viewer"]; export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"]; // Admin-tier read parity: Admin Viewer sees Models + Endpoints, Agents, and