mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
7e45bede3b
commit
66c36ab1e6
2 changed files with 177 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue