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:
devin-ai-integration[bot] 2026-09-07 13:10:09 -07:00 committed by GitHub
parent 6be7a1cbe0
commit b618c7ad86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 128 additions and 3 deletions

View file

@ -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",

View file

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

View file

@ -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."""

View file

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