fix(proxy): search by team_public_model_name and scope teamId queries

- /v2/model/info search now matches both `model_name` and
  `model_info.team_public_model_name`, so team BYOK rows (which persist
  an internal `model_name_{team_id}_{uuid}`) are findable by the public
  name shown in the UI. DB query OR-includes a JSON-path match on
  `team_public_model_name` for rows that exist only in the DB.
- `_filter_models_by_team_id` no longer short-circuits on the viewer's
  `direct_access` flag — that describes the admin viewer's own
  permissions and would leak every public model into a team-scoped view.
  Models are kept only when they belong to the team (own BYOK, in
  access_via_team_ids, or reachable via team.models / access groups).
- Added `_authorize_team_id_query`: the untrusted `teamId` query
  parameter now requires the caller to be a proxy admin or a member of
  the requested team, otherwise returns 403. Without this, any
  authenticated user could enumerate another team's BYOK metadata by
  guessing the team id.
- `_get_caller_byok_team_scope` now treats `PROXY_ADMIN_VIEW_ONLY` the
  same as `PROXY_ADMIN` (both are admin roles); previously VIEW_ONLY
  admins fell through to a user-id team lookup and saw only their own
  teams' BYOK rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
shivam 2026-05-16 14:24:09 -07:00
parent 7e45bede3b
commit 66c36ab1e6
No known key found for this signature in database
2 changed files with 177 additions and 5 deletions

View file

@ -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)

View file

@ -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():
"""