mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
fix(proxy): let authorized internal users open vector store details (#40150)
Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
6be7a1cbe0
commit
b618c7ad86
4 changed files with 128 additions and 3 deletions
|
|
@ -848,6 +848,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/model/{model_id}/update",
|
||||
"/prompt/list",
|
||||
"/prompt/info",
|
||||
"/vector_store/info",
|
||||
# Project read routes - endpoint scopes results to caller's teams (non-admin)
|
||||
"/project/list",
|
||||
"/project/info",
|
||||
|
|
|
|||
|
|
@ -161,6 +161,8 @@ async def can_user_access_vector_store(
|
|||
this vector store id.
|
||||
5. The caller's team_id matches the vector store's team_id.
|
||||
|
||||
A dashboard session credential is evaluated against the same effective
|
||||
contexts as listing (its own grants plus each real team of the user).
|
||||
Otherwise access is denied.
|
||||
"""
|
||||
if _is_proxy_admin(user_api_key_dict):
|
||||
|
|
@ -169,7 +171,8 @@ async def can_user_access_vector_store(
|
|||
if vector_store.get("team_id") is None:
|
||||
return True
|
||||
|
||||
return await _is_vector_store_granted(vector_store, user_api_key_dict)
|
||||
auth_contexts: Final = await _vector_store_auth_contexts(user_api_key_dict)
|
||||
return await _is_vector_store_granted_to_any(vector_store, auth_contexts)
|
||||
|
||||
|
||||
async def _is_vector_store_granted(
|
||||
|
|
@ -219,7 +222,7 @@ async def _team_auth_context(team_id: str, user_api_key_dict: UserAPIKeyAuth) ->
|
|||
)
|
||||
|
||||
|
||||
async def _vector_store_listing_auth_contexts(
|
||||
async def _vector_store_auth_contexts(
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> tuple[UserAPIKeyAuth, ...]:
|
||||
if not is_ui_session_credential(user_api_key_dict):
|
||||
|
|
@ -250,7 +253,7 @@ async def filter_listable_vector_stores(
|
|||
if _is_proxy_admin(user_api_key_dict):
|
||||
return tuple(vector_stores)
|
||||
|
||||
auth_contexts: Final = await _vector_store_listing_auth_contexts(user_api_key_dict)
|
||||
auth_contexts: Final = await _vector_store_auth_contexts(user_api_key_dict)
|
||||
return tuple([vs for vs in vector_stores if await _is_vector_store_granted_to_any(vs, auth_contexts)])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3217,6 +3217,65 @@ def test_internal_user_blocked_from_search_tool_writes(route):
|
|||
assert "Your role=internal_user" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_role",
|
||||
[
|
||||
LitellmUserRoles.INTERNAL_USER.value,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
||||
],
|
||||
)
|
||||
def test_non_admin_can_open_vector_store_details(user_role):
|
||||
"""Regression for LIT-7132: the dashboard lists a vector store via /vector_store/list
|
||||
(an LLM API route) but opened it via /vector_store/info, which no non-admin allowlist
|
||||
granted, so the route gate 401'd before the handler's per-store access check ran."""
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="test_user",
|
||||
user_email="user@example.com",
|
||||
user_role=user_role,
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
|
||||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
granted = RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=user_role,
|
||||
route="/vector_store/info",
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
assert granted is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
["/vector_store/new", "/vector_store/update", "/vector_store/delete"],
|
||||
)
|
||||
def test_internal_user_blocked_from_vector_store_writes(route):
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="test_user",
|
||||
user_email="user@example.com",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
)
|
||||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
with pytest.raises(Exception, match="Only proxy admin can be used to generate, delete, update"):
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
route=route,
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_admin_viewer_can_read_another_users_info():
|
||||
"""Admin Viewer has read parity with Proxy Admin, so the /user/info
|
||||
key-ownership gate must not apply to it — the Users page reads every row."""
|
||||
|
|
|
|||
|
|
@ -244,3 +244,65 @@ async def test_list_vector_stores_dashboard_session_resolves_real_teams(
|
|||
),
|
||||
):
|
||||
assert await _listed_ids(alice) == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("user_team_ids", "expected_status"),
|
||||
[
|
||||
(["team_a"], 200),
|
||||
(["team_b"], 403),
|
||||
([], 403),
|
||||
],
|
||||
)
|
||||
async def test_get_vector_store_info_dashboard_session_resolves_real_teams(
|
||||
user_team_ids: list[str], expected_status: int
|
||||
):
|
||||
"""Regression for LIT-7132: /vector_store/info must grant a dashboard session the same team-owned stores
|
||||
/vector_store/list shows it, instead of judging the session's reserved litellm-dashboard team id."""
|
||||
from litellm.models.team import LiteLLM_TeamTableCachedObj
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import (
|
||||
get_vector_store_info,
|
||||
)
|
||||
from litellm.types.vector_stores import VectorStoreInfoRequest
|
||||
|
||||
alice = UserAPIKeyAuth(
|
||||
team_id="litellm-dashboard",
|
||||
user_id="alice",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
)
|
||||
|
||||
async def fake_get_team_object(team_id: str, **_kwargs: object) -> LiteLLM_TeamTableCachedObj:
|
||||
return LiteLLM_TeamTableCachedObj(team_id=team_id)
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_prisma.db.litellm_managedvectorstorestable.find_unique = AsyncMock(
|
||||
return_value=MagicMock(model_dump=lambda: dict(_TEAM_A_OWNED))
|
||||
)
|
||||
|
||||
async def outcome() -> int:
|
||||
try:
|
||||
response = await get_vector_store_info(
|
||||
data=VectorStoreInfoRequest(vector_store_id="vs_team_a"), user_api_key_dict=alice
|
||||
)
|
||||
except HTTPException as exc:
|
||||
return exc.status_code
|
||||
assert response["vector_store"]["vector_store_id"] == "vs_team_a"
|
||||
return 200
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: the endpoint reads the store row through the module-level prisma client, no injection seam
|
||||
"litellm.proxy.proxy_server.prisma_client", mock_prisma
|
||||
),
|
||||
patch( # test-quality-ok: the endpoint consults the module-level registry before the DB, no injection seam
|
||||
"litellm.vector_store_registry", None
|
||||
),
|
||||
patch( # test-quality-ok: team rows come from the module-level prisma client, no injection seam
|
||||
"litellm.proxy.auth.auth_checks.get_team_object", new=fake_get_team_object
|
||||
),
|
||||
patch( # test-quality-ok: the user row comes from the module-level prisma client, no injection seam
|
||||
"litellm.proxy.vector_store_endpoints.utils.resolve_ui_session_team_ids",
|
||||
new=AsyncMock(return_value=user_team_ids),
|
||||
),
|
||||
):
|
||||
assert await outcome() == expected_status
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue