diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index b603bf22596..9fddae3c7ee 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -10905,7 +10905,10 @@ async def _get_caller_byok_team_scope( """ if user_api_key_dict is None or prisma_client is None: return None - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + if user_api_key_dict.user_role in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ): return None user_id = user_api_key_dict.user_id if user_id is None: @@ -11365,28 +11368,81 @@ async def _gather_team_accessible_model_ids( return team_accessible_model_ids +async def _authorize_team_id_query( + team_id: str, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, +) -> None: + """ + `teamId` arrives untrusted via the /v2/model/info query string and the + filter below includes BYOK rows solely on `model_info.team_id == team_id`. + Without this guard, any authenticated user who knows (or guesses) another + team's id could enumerate that team's BYOK model metadata. Allow only + proxy admins or members of the requested team. + """ + if user_api_key_dict.user_role in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ): + return + + user_id = user_api_key_dict.user_id + if user_id is None: + raise HTTPException( + status_code=403, + detail={"error": "Not authorized to view this team's models"}, + ) + try: + user_row = await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_id} + ) + except Exception: + verbose_proxy_logger.exception( + "Failed to look up caller teams while authorizing teamId filter" + ) + raise HTTPException( + status_code=403, + detail={"error": "Not authorized to view this team's models"}, + ) + + if user_row is None or team_id not in (user_row.teams or []): + raise HTTPException( + status_code=403, + detail={"error": "Not authorized to view this team's models"}, + ) + + async def _filter_models_by_team_id( all_models: List[Dict[str, Any]], team_id: str, prisma_client: PrismaClient, llm_router: Router, + user_api_key_dict: Optional[UserAPIKeyAuth] = None, ) -> List[Dict[str, Any]]: """ Filter models by team ID. Returns models where: - - direct_access is True, OR - - team_id is in access_via_team_ids - - Also searches config and database for models accessible to the team. + - team_id matches the model's BYOK team_id, OR + - team_id is in access_via_team_ids, OR + - model_id is reachable via team.models / access groups Args: all_models: List of models to filter team_id: Team ID to filter by prisma_client: Prisma client for database queries llm_router: Router instance for config queries + user_api_key_dict: Caller auth context. When provided, the caller must + be a proxy admin or a member of `team_id`; otherwise raises 403. Returns: Filtered list of models """ + if user_api_key_dict is not None: + await _authorize_team_id_query( + team_id=team_id, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ) + team_object = await _load_team_object_for_model_filter(team_id, prisma_client) if team_object is None: return [] @@ -11597,6 +11653,7 @@ async def model_info_v2( team_id=teamId.strip(), prisma_client=prisma_client, llm_router=llm_router, + user_api_key_dict=user_api_key_dict, ) # Update search_total_count after teamId filter is applied search_total_count = len(all_models) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 980c3fca6d6..491bd172554 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -1497,6 +1497,121 @@ async def test_filter_models_by_team_id_excludes_viewer_direct_access(): ), "viewer's direct_access must not widen the team's visible set" +@pytest.mark.asyncio +async def test_filter_models_by_team_id_rejects_non_member(): + """ + Regression test: /v2/model/info?teamId=X includes BYOK rows solely on + `model_info.team_id == X`. Without an auth check, any authenticated user + could enumerate another team's BYOK metadata by guessing its id. Callers + that are neither proxy admins nor members of `team_id` must get 403. + """ + from fastapi import HTTPException + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.proxy_server import _filter_models_by_team_id + + byok = { + "model_name": "model_name_team-111_uuid", + "litellm_params": {"model": "claude"}, + "model_info": {"id": "byok-team-111", "team_id": "team-111"}, + } + + prisma = MagicMock() + # Caller is in team-222 only + user_row = MagicMock() + user_row.teams = ["team-222"] + prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row) + + caller = UserAPIKeyAuth( + user_id="alice", + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-test", + ) + + with pytest.raises(HTTPException) as excinfo: + await _filter_models_by_team_id( + all_models=[byok], + team_id="team-111", + prisma_client=prisma, + llm_router=MagicMock(), + user_api_key_dict=caller, + ) + assert excinfo.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_filter_models_by_team_id_allows_team_member(): + """ + A caller who IS a member of `team_id` must be allowed to filter, and + should see that team's BYOK rows. + """ + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.proxy_server import _filter_models_by_team_id + + byok = { + "model_name": "model_name_team-111_uuid", + "litellm_params": {"model": "claude"}, + "model_info": {"id": "byok-team-111", "team_id": "team-111"}, + } + + prisma = MagicMock() + user_row = MagicMock() + user_row.teams = ["team-111", "team-999"] + prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row) + team_db = MagicMock() + team_db.model_dump.return_value = { + "team_id": "team-111", + "team_alias": "Team 111", + "models": [], + "access_group_ids": None, + } + prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_db) + prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[]) + + router = MagicMock() + router.get_model_access_groups = MagicMock(return_value={}) + router.get_model_list = MagicMock(return_value=[byok]) + + caller = UserAPIKeyAuth( + user_id="bob", + user_role=LitellmUserRoles.INTERNAL_USER, + api_key="sk-test", + ) + + result = await _filter_models_by_team_id( + all_models=[byok], + team_id="team-111", + prisma_client=prisma, + llm_router=router, + user_api_key_dict=caller, + ) + assert [m["model_info"]["id"] for m in result] == ["byok-team-111"] + + +@pytest.mark.asyncio +async def test_caller_byok_team_scope_treats_view_only_admin_as_unscoped(): + """ + Regression test: `PROXY_ADMIN_VIEW_ONLY` is an admin role + ("can login, view all own keys, view all spend"). Search results for + this role must show BYOK rows across all teams, not be silently scoped + to the user-id's `teams` field — that path narrows results to whatever + teams the admin happens to be a member of, regressing pre-PR behavior. + """ + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.proxy_server import _get_caller_byok_team_scope + + caller = UserAPIKeyAuth( + user_id="view-admin", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + api_key="sk-test", + ) + scope = await _get_caller_byok_team_scope( + user_api_key_dict=caller, + prisma_client=MagicMock(), + ) + assert scope is None, "PROXY_ADMIN_VIEW_ONLY must be unscoped, like PROXY_ADMIN" + + @pytest.mark.asyncio async def test_add_access_group_models_to_team_models(): """