diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index cf99c5cd9fa..83d05e70f3a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -247,6 +247,9 @@ class KeyManagementRoutes(str, enum.Enum): # team usage routes TEAM_DAILY_ACTIVITY = "/team/daily/activity" + # team spend-log viewing + SPEND_LOGS = "/spend/logs" + class LiteLLMRoutes(enum.Enum): openai_route_names = [ @@ -520,6 +523,7 @@ class LiteLLMRoutes(enum.Enum): KeyManagementRoutes.KEY_UNBLOCK.value, KeyManagementRoutes.KEY_BULK_UPDATE.value, KeyManagementRoutes.TEAM_DAILY_ACTIVITY.value, + KeyManagementRoutes.SPEND_LOGS.value, KeyManagementRoutes.KEY_RESET_SPEND.value, KeyManagementRoutes.KEY_ALIASES.value, ] diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 3c1b7cfd10c..27c8bcc5a0c 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -13,10 +13,10 @@ from litellm.proxy._types import * from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.management_endpoints.common_utils import ( - _is_user_team_admin, - _user_has_admin_view, -) + +# NOTE: Avoid module-level import from common_utils: proxy_server imports this +# module while common_utils may pull proxy_server during init, which can leave +# those names undefined. Import the helpers locally where they are used. from litellm.proxy.spend_tracking.spend_tracking_utils import ( get_spend_by_team_and_customer, ) @@ -1870,6 +1870,7 @@ def parse_date(date_str: str) -> datetime: if max_spend is not None: where_conditions["spend"]["lte"] = max_spend is_admin_view = _is_admin_view_safe(user_api_key_dict=user_api_key_dict) + permitted_team_ids: Optional[List[str]] = None if not is_admin_view: if team_id is not None: can_view_team = await _can_team_member_view_log( @@ -1887,9 +1888,26 @@ def parse_date(date_str: str) -> datetime: }, ) where_conditions["team_id"] = team_id + where_conditions.pop("user", None) else: if _can_user_view_spend_log(user_api_key_dict=user_api_key_dict): - where_conditions["user"] = user_api_key_dict.user_id + try: + permitted_team_ids = ( + await _get_permitted_team_ids_for_spend_logs( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + ) + ) + except Exception: + permitted_team_ids = [] + if permitted_team_ids: + where_conditions.pop("user", None) + where_conditions["OR"] = [ + {"user": user_api_key_dict.user_id}, + {"team_id": {"in": permitted_team_ids}}, + ] + else: + where_conditions["user"] = user_api_key_dict.user_id where_conditions.pop("team_id", None) # Calculate skip value for pagination skip = (page - 1) * page_size @@ -1934,6 +1952,14 @@ def parse_date(date_str: str) -> datetime: sql_params.append(val) p += 1 + # Multi-team OR filter: (user = $X OR team_id = ANY($Y)) + if permitted_team_ids is not None and len(permitted_team_ids) > 0: + or_clause = f'("user" = ${p} OR team_id = ANY(${p + 1}::text[]))' + sql_params.append(user_api_key_dict.user_id) + sql_params.append(permitted_team_ids) + p += 2 + sql_conditions.append(or_clause) + # Status filter if status_filter is not None: if status_filter == "success": @@ -2033,6 +2059,7 @@ async def ui_view_request_response_for_request_id( default=None, description="Time till which to view key spend", ), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ View request / response for a specific request_id @@ -2040,6 +2067,25 @@ async def ui_view_request_response_for_request_id( - goes through all callbacks, checks if any of them have a @property -> has_request_response_payload - if so, it will return the request and response payload """ + from litellm.proxy.proxy_server import prisma_client + + if not _is_admin_view_safe(user_api_key_dict=user_api_key_dict): + if prisma_client is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": ( + "Cannot authorize spend log access without a database " + "connection. Connect a database or use a proxy admin key." + ) + }, + ) + await _assert_user_can_view_request_id( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + request_id=request_id, + ) + custom_loggers = ( litellm.logging_callback_manager.get_active_additional_logging_utils_from_custom_logger() ) @@ -2068,8 +2114,6 @@ async def ui_view_request_response_for_request_id( # response, and proxy_server_request for performance. When no custom # logger (S3, GCS, etc.) is configured, we still need to serve these # fields from the DB for the detail/drawer view. - from litellm.proxy.proxy_server import prisma_client - if prisma_client is not None: sql_query = """ SELECT messages, response, proxy_server_request @@ -3404,10 +3448,16 @@ def _build_status_filter_condition(status_filter: Optional[str]) -> Dict[str, An def _is_admin_view_safe(user_api_key_dict: UserAPIKeyAuth) -> bool: """ Safely determine if the current user has admin view permissions. - Wraps the underlying check and defaults to False on any exception. + Defaults to False on any exception. """ try: - return _user_has_admin_view(user_api_key_dict=user_api_key_dict) + user_role = getattr(user_api_key_dict, "user_role", None) + if user_role is None: + return False + return user_role in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ) except Exception: return False @@ -3419,16 +3469,29 @@ async def _can_team_member_view_log( ) -> bool: """ Check if the requesting user can view spend logs for the given team. - Returns True only if the team exists and the user is a team admin. + Returns True if the team exists and the user is either a team admin or + a team member with the ``/spend/logs`` permission. """ + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, + _team_member_has_permission, + ) + if team_id is None: return False - team_obj = await prisma_client.db.litellm_teamtable.find_unique( + team_row = await prisma_client.db.litellm_teamtable.find_unique( where={"team_id": team_id} ) - if team_obj is None: + if team_row is None: return False - return _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj) + team_obj = LiteLLM_TeamTable(**team_row.model_dump()) + if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj): + return True + return _team_member_has_permission( + user_api_key_dict=user_api_key_dict, + team_obj=team_obj, + permission=KeyManagementRoutes.SPEND_LOGS.value, + ) def _can_user_view_spend_log(user_api_key_dict: UserAPIKeyAuth) -> bool: @@ -3445,3 +3508,87 @@ def _can_user_view_spend_log(user_api_key_dict: UserAPIKeyAuth) -> bool: ) and user_id is not None ) + + +async def _assert_user_can_view_request_id( + prisma_client, + user_api_key_dict: UserAPIKeyAuth, + request_id: str, +) -> None: + """ + Verify the requesting non-admin user is allowed to view this spend-log row. + Allowed when the log belongs to the user directly, or to one of their + permitted teams (admin or ``/spend/logs`` permission). + Raises HTTP 403 if not. + """ + row = await prisma_client.db.litellm_spendlogs.find_unique( + where={"request_id": request_id}, + include=None, + ) + if row is None: + return + + if row.user is not None and row.user == user_api_key_dict.user_id: + return + + if row.team_id: + can_view = await _can_team_member_view_log( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + team_id=row.team_id, + ) + if can_view: + return + + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "Not authorized to view spend log for request_id={}".format( + request_id + ) + }, + ) + + +async def _get_permitted_team_ids_for_spend_logs( + prisma_client, + user_api_key_dict: UserAPIKeyAuth, +) -> List[str]: + """ + Return team IDs where the user is either a team admin or has the + ``/spend/logs`` permission, allowing them to view team-wide spend logs. + """ + # Imported here to avoid circular import: proxy_server imports this module. + from litellm.proxy.auth.auth_checks import get_user_object + from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, + _team_member_has_permission, + ) + from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache + + user_obj = await get_user_object( + user_id=user_api_key_dict.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + if user_obj is None or not user_obj.teams: + return [] + + team_rows = await prisma_client.db.litellm_teamtable.find_many( + where={"team_id": {"in": user_obj.teams}} + ) + + permitted: List[str] = [] + for team_row in team_rows: + team_obj = LiteLLM_TeamTable(**team_row.model_dump()) + if _is_user_team_admin(user_api_key_dict=user_api_key_dict, team_obj=team_obj): + permitted.append(team_obj.team_id) + elif _team_member_has_permission( + user_api_key_dict=user_api_key_dict, + team_obj=team_obj, + permission=KeyManagementRoutes.SPEND_LOGS.value, + ): + permitted.append(team_obj.team_id) + return permitted diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 05d9b7489f4..24e165a5954 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -6,6 +6,7 @@ from datetime import timezone import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient sys.path.insert( @@ -103,45 +104,38 @@ def __init__(self): UserAPIKeyAuth, ) from litellm.proxy.hooks.proxy_track_cost_callback import _ProxyDBLogger -from litellm.proxy.proxy_server import app, prisma_client +from litellm.proxy.management_endpoints import common_utils +from litellm.proxy.proxy_server import app from litellm.proxy.spend_tracking import spend_management_endpoints from litellm.router import Router from litellm.types.utils import BudgetConfig @pytest.mark.asyncio -async def test_is_admin_view_safe_true(monkeypatch): - # Force underlying check to return True - monkeypatch.setattr( - spend_management_endpoints, - "_user_has_admin_view", - lambda user_api_key_dict: True, - ) +async def test_is_admin_view_safe_true(): auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user") assert spend_management_endpoints._is_admin_view_safe(auth) is True + auth_view = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, user_id="admin_view" + ) + assert spend_management_endpoints._is_admin_view_safe(auth_view) is True @pytest.mark.asyncio -async def test_is_admin_view_safe_false(monkeypatch): - # Force underlying check to return False - monkeypatch.setattr( - spend_management_endpoints, - "_user_has_admin_view", - lambda user_api_key_dict: False, - ) +async def test_is_admin_view_safe_false(): auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1") assert spend_management_endpoints._is_admin_view_safe(auth) is False @pytest.mark.asyncio -async def test_is_admin_view_safe_exception(monkeypatch): +async def test_is_admin_view_safe_exception(): # Ensure exceptions are swallowed and return False - def raise_err(*args, **kwargs): - raise RuntimeError("boom") + class ExplodingAuth: + @property + def user_role(self): + raise RuntimeError("boom") - monkeypatch.setattr(spend_management_endpoints, "_user_has_admin_view", raise_err) - auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1") - assert spend_management_endpoints._is_admin_view_safe(auth) is False + assert spend_management_endpoints._is_admin_view_safe(ExplodingAuth()) is False # type: ignore[arg-type] @pytest.mark.asyncio @@ -185,7 +179,7 @@ def __init__(self): prisma = MockPrisma() # Even if admin check would return True, no team means False monkeypatch.setattr( - spend_management_endpoints, + common_utils, "_is_user_team_admin", lambda user_api_key_dict, team_obj: True, ) @@ -198,9 +192,18 @@ def __init__(self): @pytest.mark.asyncio async def test_can_team_member_view_log_not_admin(monkeypatch): - # Existing team but caller is not a team admin -> False + # Existing team but caller is not a team admin and no /spend/logs permission -> False class MockTeam: - pass + team_id = "team_x" + members_with_roles = [Member(user_id="user_1", role="user")] + team_member_permissions = None + + def model_dump(self): + return { + "team_id": self.team_id, + "members_with_roles": [{"user_id": "user_1", "role": "user"}], + "team_member_permissions": self.team_member_permissions, + } class MockPrisma: class DB: @@ -216,7 +219,7 @@ def __init__(self): prisma = MockPrisma() monkeypatch.setattr( - spend_management_endpoints, + common_utils, "_is_user_team_admin", lambda user_api_key_dict, team_obj: False, ) @@ -231,7 +234,16 @@ def __init__(self): async def test_can_team_member_view_log_admin(monkeypatch): # Existing team and caller is team admin -> True class MockTeam: - pass + team_id = "team_x" + members_with_roles = [Member(user_id="user_1", role="admin")] + team_member_permissions = None + + def model_dump(self): + return { + "team_id": self.team_id, + "members_with_roles": [{"user_id": "user_1", "role": "admin"}], + "team_member_permissions": self.team_member_permissions, + } class MockPrisma: class DB: @@ -246,11 +258,6 @@ def __init__(self): self.db = self.DB() prisma = MockPrisma() - monkeypatch.setattr( - spend_management_endpoints, - "_is_user_team_admin", - lambda user_api_key_dict, team_obj: True, - ) auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user_1") allowed = await spend_management_endpoints._can_team_member_view_log( prisma, auth, "team_x" @@ -280,6 +287,59 @@ def test_can_user_view_spend_log_false_for_other_roles(): assert spend_management_endpoints._can_user_view_spend_log(auth) is False +@pytest.mark.asyncio +async def test_assert_user_can_view_request_id_rejects_both_users_none(): + """ + API keys with user_id=None must not be treated as owning a log whose user + field is None (avoid None == None bypass). + """ + + class MockRow: + user = None + team_id = None + + class MockSpendLogs: + async def find_unique(self, where, include=None): + return MockRow() + + class MockDB: + def __init__(self): + self.litellm_spendlogs = MockSpendLogs() + + class MockPrisma: + def __init__(self): + self.db = MockDB() + + auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id=None) + with pytest.raises(HTTPException) as exc_info: + await spend_management_endpoints._assert_user_can_view_request_id( + MockPrisma(), auth, "req-none-user" + ) + assert exc_info.value.status_code == 403 + + +def test_ui_view_request_response_forbids_non_admin_without_db(client, monkeypatch): + """ + Without prisma, non-admins cannot be authorized to read request/response + payloads (including from custom loggers); do not skip RBAC silently. + """ + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="user_1", + ) + try: + response = client.get( + "/spend/logs/ui/req-no-db", + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 403 + body = response.json() + assert "database" in str(body).lower() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + ignored_keys = [ "request_id", "session_id", @@ -866,7 +926,16 @@ def filter_by_team(where): return mock_spend_logs class TeamTable: + team_id = "team_admin_team" members_with_roles = [Member(user_id="admin_user", role="admin")] + team_member_permissions = None + + def model_dump(self): + return { + "team_id": self.team_id, + "members_with_roles": [{"user_id": "admin_user", "role": "admin"}], + "team_member_permissions": self.team_member_permissions, + } async def team_lookup(where): return TeamTable() if where == {"team_id": "team_admin_team"} else None @@ -1644,9 +1713,6 @@ async def test_spend_logs_payload_success_log_with_router(self, monkeypatch): } ) - print(f"payload: {payload}") - print(f"expected_payload: {expected_payload}") - differences = _compare_nested_dicts( payload, expected_payload, ignore_keys=ignored_keys ) @@ -2066,7 +2132,7 @@ async def test_provider_budget_over(disable_budget_sync): ) with pytest.raises(Exception) as e: - response = await router.acompletion( + await router.acompletion( model="azure-gpt-4o", messages=[{"role": "user", "content": "Hello, world!"}], ) @@ -2473,3 +2539,225 @@ async def test_build_ui_spend_logs_response_dict_rows_session_counts(): where={"session_id": {"in": [session_id]}}, count={"session_id": True}, ) + + +# --------------------------------------------------------------------------- +# Tests for /spend/logs team-member permission +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_can_team_member_view_log_with_spend_logs_permission(monkeypatch): + """ + Non-admin team member WITH /spend/logs permission should be allowed. + """ + + class MockTeam: + team_id = "team_abc" + members_with_roles = [Member(user_id="member_1", role="user")] + team_member_permissions = ["/spend/logs"] + + def model_dump(self): + return { + "team_id": self.team_id, + "members_with_roles": [{"user_id": "member_1", "role": "user"}], + "team_member_permissions": self.team_member_permissions, + } + + class MockPrisma: + class DB: + class TeamTable: + async def find_unique(self, where: dict): + return MockTeam() + + def __init__(self): + self.litellm_teamtable = self.TeamTable() + + def __init__(self): + self.db = self.DB() + + prisma = MockPrisma() + auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="member_1") + allowed = await spend_management_endpoints._can_team_member_view_log( + prisma, auth, "team_abc" + ) + assert allowed is True + + +@pytest.mark.asyncio +async def test_can_team_member_view_log_without_spend_logs_permission(monkeypatch): + """ + Non-admin team member WITHOUT /spend/logs permission should be denied. + """ + + class MockTeam: + team_id = "team_abc" + members_with_roles = [Member(user_id="member_1", role="user")] + team_member_permissions = ["/key/info"] + + def model_dump(self): + return { + "team_id": self.team_id, + "members_with_roles": [{"user_id": "member_1", "role": "user"}], + "team_member_permissions": self.team_member_permissions, + } + + class MockPrisma: + class DB: + class TeamTable: + async def find_unique(self, where: dict): + return MockTeam() + + def __init__(self): + self.litellm_teamtable = self.TeamTable() + + def __init__(self): + self.db = self.DB() + + prisma = MockPrisma() + auth = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="member_1") + allowed = await spend_management_endpoints._can_team_member_view_log( + prisma, auth, "team_abc" + ) + assert allowed is False + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_team_member_with_spend_logs_permission( + client, monkeypatch +): + """ + A non-admin team member with /spend/logs permission should see team-wide + spend logs when filtering by that team_id. + """ + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-key-1", + "user": "member_1", + "team_id": "team_perm", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-key-2", + "user": "member_2", + "team_id": "team_perm", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, + ] + + def filter_by_team(where): + if "team_id" in where and where["team_id"] == "team_perm": + return mock_spend_logs + return [] + + class TeamTable: + team_id = "team_perm" + members_with_roles = [Member(user_id="member_1", role="user")] + team_member_permissions = ["/spend/logs"] + + def model_dump(self): + return { + "team_id": self.team_id, + "members_with_roles": [{"user_id": "member_1", "role": "user"}], + "team_member_permissions": self.team_member_permissions, + } + + async def team_lookup(where): + return TeamTable() if where == {"team_id": "team_perm"} else None + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_team, team_lookup), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="member_1" + ) + + try: + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={ + "team_id": "team_perm", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + assert len(data["data"]) == 2 + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_team_member_no_permission_blocked( + client, monkeypatch +): + """ + A non-admin team member WITHOUT /spend/logs permission should be + rejected when filtering by team_id. + """ + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-key-1", + "user": "member_1", + "team_id": "team_noperm", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, + ] + + def filter_fn(where): + return mock_spend_logs + + class TeamTable: + team_id = "team_noperm" + members_with_roles = [Member(user_id="member_1", role="user")] + team_member_permissions = ["/key/info"] + + def model_dump(self): + return { + "team_id": self.team_id, + "members_with_roles": [{"user_id": "member_1", "role": "user"}], + "team_member_permissions": self.team_member_permissions, + } + + async def team_lookup(where): + return TeamTable() if where == {"team_id": "team_noperm"} else None + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, user_id="member_1" + ) + + try: + start_date, end_date = _default_date_range() + response = client.get( + "/spend/logs/ui", + params={ + "team_id": "team_noperm", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 403 + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) diff --git a/ui/litellm-dashboard/src/components/team/permission_definitions.test.tsx b/ui/litellm-dashboard/src/components/team/permission_definitions.test.tsx index a85ed8353d2..dc82008eeb8 100644 --- a/ui/litellm-dashboard/src/components/team/permission_definitions.test.tsx +++ b/ui/litellm-dashboard/src/components/team/permission_definitions.test.tsx @@ -74,5 +74,24 @@ describe("permission_definitions", () => { expect(PERMISSION_DESCRIPTIONS["/team/daily/activity"]).toBeDefined(); expect(PERMISSION_DESCRIPTIONS["/team/daily/activity"]).toContain("team usage"); }); + + it("should include spend logs permission", () => { + expect(PERMISSION_DESCRIPTIONS["/spend/logs"]).toBeDefined(); + expect(PERMISSION_DESCRIPTIONS["/spend/logs"]).toContain("spend logs"); + }); + }); + + describe("spend/logs permission", () => { + it("should return GET method for /spend/logs", () => { + expect(getMethodForEndpoint("/spend/logs")).toBe("GET"); + }); + + it("should return correct info for /spend/logs permission", () => { + const result = getPermissionInfo("/spend/logs"); + expect(result.method).toBe("GET"); + expect(result.endpoint).toBe("/spend/logs"); + expect(result.description).toBe(PERMISSION_DESCRIPTIONS["/spend/logs"]); + expect(result.route).toBe("/spend/logs"); + }); }); }); diff --git a/ui/litellm-dashboard/src/components/team/permission_definitions.tsx b/ui/litellm-dashboard/src/components/team/permission_definitions.tsx index 1c1e795d219..4a48baec32d 100644 --- a/ui/litellm-dashboard/src/components/team/permission_definitions.tsx +++ b/ui/litellm-dashboard/src/components/team/permission_definitions.tsx @@ -22,13 +22,15 @@ export const PERMISSION_DESCRIPTIONS: Record = { "/key/unblock": "Member can unblock a virtual key belonging to this team", "/team/daily/activity": "Member can view all team usage data (not just their own)", + "/spend/logs": + "Member can view spend logs for the entire team (not just their own)", }; /** * Determines the HTTP method for a given permission endpoint */ export const getMethodForEndpoint = (endpoint: string): string => { - if (endpoint.includes("/info") || endpoint.includes("/list") || endpoint.includes("/activity")) { + if (endpoint.includes("/info") || endpoint.includes("/list") || endpoint.includes("/activity") || endpoint === "/spend/logs") { return "GET"; } return "POST";