feat(access-groups): grant vector stores through access groups (#50290)

This commit is contained in:
shrey 2026-11-12 14:59:17 -08:00 • committed by GitHub
parent 41fa625322
commit 6685525fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1044 additions and 18 deletions

View file

@ -20,6 +20,7 @@ class LiteLLM_AccessGroupTable(LiteLLMPydanticObjectBase):
mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None
access_agent_ids: list[str] = []
access_skill_names: tuple[str, ...] = ()
access_vector_store_ids: tuple[str, ...] = ()
assigned_team_ids: list[str] = []
assigned_key_ids: list[str] = []
created_at: datetime | None = None

View file

@ -137,6 +137,10 @@ class LiteLLM_TeamTableCachedObj(LiteLLM_TeamTable):
last_refreshed_at: float | None = None
class LiteLLM_TeamRebuiltFromToken(LiteLLM_TeamTableCachedObj): # frozen-ok: same mutability as its cached base
"""Auth's stand-in, built from the key's fields when the team row was unreadable. It has no access groups."""
class LiteLLM_DeletedTeamTable(LiteLLM_TeamTable):
"""Audit record for deleted teams; mirrors the team plus deletion metadata."""

View file

@ -0,0 +1,4 @@
SET LOCAL lock_timeout = '10s';
-- AlterTable
ALTER TABLE "LiteLLM_AccessGroupTable" ADD COLUMN IF NOT EXISTS "access_vector_store_ids" TEXT[] DEFAULT ARRAY[]::TEXT[];

View file

@ -1630,6 +1630,7 @@ model LiteLLM_AccessGroupTable {
mcp_tool_permissions Json?
access_agent_ids String[] @default([])
access_skill_names String[] @default([])
access_vector_store_ids String[] @default([])
assigned_team_ids String[] @default([])
assigned_key_ids String[] @default([])

View file

@ -939,6 +939,20 @@
],
"title": "Access Skill Names"
},
"access_vector_store_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Access Vector Store Ids"
},
"assigned_key_ids": {
"anyOf": [
{
@ -1128,6 +1142,14 @@
"title": "Access Skill Names",
"type": "array"
},
"access_vector_store_ids": {
"default": [],
"items": {
"type": "string"
},
"title": "Access Vector Store Ids",
"type": "array"
},
"assigned_key_ids": {
"items": {
"type": "string"
@ -1304,6 +1326,20 @@
],
"title": "Access Skill Names"
},
"access_vector_store_ids": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"title": "Access Vector Store Ids"
},
"assigned_key_ids": {
"anyOf": [
{

View file

@ -54,6 +54,7 @@ from litellm.llms.base_llm.managed_resources.utils import (
is_base64_encoded_unified_id,
)
from litellm.models.project import LiteLLM_ProjectTable
from litellm.models.team import LiteLLM_TeamRebuiltFromToken
from litellm.proxy._types import (
MODEL_LIST_ROUTES,
RBAC_ROLES,
@ -5807,6 +5808,17 @@ async def can_team_access_model(
raise
def _group_grants_key(
group: LiteLLM_AccessGroupTable, valid_token: UserAPIKeyAuth, team_object: LiteLLM_TeamTable | None
) -> bool:
key_team_id: Final = valid_token.team_id or (team_object.team_id if team_object is not None else None)
return (
bool(key_team_id and key_team_id in (group.assigned_team_ids or []))
or bool(valid_token.token and valid_token.token in (group.assigned_key_ids or []))
or group.access_group_id in valid_token.access_group_rule_grants
)
async def get_authorized_resources_from_key_access_groups(
valid_token: UserAPIKeyAuth | None,
team_object: LiteLLM_TeamTable | None,
@ -5835,9 +5847,6 @@ async def get_authorized_resources_from_key_access_groups(
if _prisma_client is None or _user_api_key_cache is None:
return []
key_team_id: Final = valid_token.team_id or (team_object.team_id if team_object is not None else None)
key_token: Final = valid_token.token
authorized_resources: Final[list[str]] = []
for ag_id in key_access_group_ids:
try:
@ -5856,9 +5865,7 @@ async def get_authorized_resources_from_key_access_groups(
if check_db_only or strict_errors:
raise
continue
team_authorized = bool(key_team_id and key_team_id in (ag.assigned_team_ids or []))
key_authorized = bool(key_token and key_token in (ag.assigned_key_ids or []))
if team_authorized or key_authorized or ag_id in valid_token.access_group_rule_grants:
if _group_grants_key(ag, valid_token, team_object):
authorized_resources.extend(getattr(ag, resource_field, []) or [])
return list(set(authorized_resources))
@ -8095,10 +8102,104 @@ def _legacy_requested_vector_store_ids(
)
def _restricts_vector_stores(grants: Sequence[tuple[GrantLayer, LiteLLM_ObjectPermissionTable | None]]) -> bool:
def _restricts_vector_stores(grants: Sequence[tuple[GrantLayer, _VectorStorePermissionsRow | None]]) -> bool:
return any(grant is not None and bool(grant.vector_stores) for _, grant in grants)
async def access_group_vector_stores(
valid_token: UserAPIKeyAuth | None,
team_object: LiteLLM_TeamTable | None,
unreadable: Literal["skip", "raise"] = "skip",
) -> Mapping[GrantLayer, tuple[str, ...]]:
"""The stores the key's and its team's access groups list, per layer.
A deleted group lists nothing. So does a group that cannot be read, unless `unreadable` is "raise"."""
key_groups: Final = await get_access_group_tables(
(valid_token.access_group_ids or ()) if valid_token is not None else (), unreadable=unreadable
)
team_groups: Final = await get_access_group_tables(
(team_object.access_group_ids or ()) if team_object is not None else (), unreadable=unreadable
)
listed_key_groups: Final = (
tuple(group for group in key_groups if _group_grants_key(group, valid_token, team_object))
if valid_token is not None
else ()
)
return MappingProxyType(
{
"key": tuple(chain.from_iterable(group.access_vector_store_ids for group in listed_key_groups)),
"team": tuple(chain.from_iterable(group.access_vector_store_ids for group in team_groups)),
}
)
async def _team_read_again(
valid_token: UserAPIKeyAuth | None, team_object: LiteLLM_TeamTable | None
) -> LiteLLM_TeamTable | None:
"""Auth's stand-in for a team it could not read has none of the team's groups, so the team is read again."""
team_id: Final = valid_token.team_id if valid_token is not None else None
if (
not isinstance(team_object, LiteLLM_TeamRebuiltFromToken)
or valid_token is None
or team_id is None
or team_id == UI_TEAM_ID
):
return team_object
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
return await get_team_object(
team_id=team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=valid_token.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
@dataclass(frozen=True, slots=True)
class _OwnAndGroupVectorStores:
vector_stores: Sequence[str]
def _with_group_stores(
grant: LiteLLM_ObjectPermissionTable | None, group_stores: Sequence[str]
) -> _VectorStorePermissionsRow | None:
if not group_stores:
return grant
own_stores: Final = (grant.vector_stores if grant is not None else None) or ()
return _OwnAndGroupVectorStores([*dict.fromkeys((*own_stores, *group_stores))])
async def _vector_store_grants(
valid_token: UserAPIKeyAuth | None,
team_object: LiteLLM_TeamTable | None,
user_object: LiteLLM_UserTable | None,
prisma_client: PrismaClient,
user_api_key_cache: UserApiKeyCache,
strict_layers: frozenset[GrantLayer] = frozenset(),
) -> tuple[tuple[GrantLayer, _VectorStorePermissionsRow | None], ...]:
"""Each identity's own grant. The stores its access groups list count as if they were on its own list.
A strict team layer can only lose grants without its groups, so it keeps auth's stand-in team."""
try:
team: Final = team_object if "team" in strict_layers else await _team_read_again(valid_token, team_object)
group_stores: Final = await access_group_vector_stores(valid_token, team, unreadable="raise")
except Exception as e:
# Skipping an unreadable team or group could empty a list and lift the restriction.
raise ProxyException(
message="Could not read the team or its access groups to check vector store access",
type=ProxyErrorTypes.internal_server_error,
param="vector_store",
code=status.HTTP_503_SERVICE_UNAVAILABLE,
) from e
return tuple(
[
(layer, _with_group_stores(grant, group_stores.get(layer, ())))
for layer, grant in await _identity_grants(
valid_token, team_object, user_object, prisma_client, user_api_key_cache
)
]
)
def _request_drop_policies(
request_body: Mapping[str, object], llm_router: Router | None, valid_token: UserAPIKeyAuth | None
) -> tuple[DropPolicy, ...]:
@ -8161,7 +8262,7 @@ async def vector_store_access_check(
Requested stores come from `vector_store_ids`, `tools[].vector_store_ids`, the tools of
`additional_tools` input items and the RAG `retrieval_config.vector_store_id`. Grants come from each identity's
`object_permission.vector_stores`.
`object_permission.vector_stores`, plus the stores its access groups list, which count as if they were on it.
Both modes read the same request fields, with or without a vector store registry, and answer 400
when the top-level `vector_store_ids` is not a list of strings, which legacy mode does only when the
@ -8209,7 +8310,7 @@ async def vector_store_access_check(
)
if isinstance(requested_ids, _UnreadableVectorStoreIds):
if _restricts_vector_stores(
await _identity_grants(valid_token, team_object, None, prisma_client, user_api_key_cache)
await _vector_store_grants(valid_token, team_object, None, prisma_client, user_api_key_cache)
):
raise _malformed_vector_store_ids()
return True
@ -8223,12 +8324,13 @@ async def vector_store_access_check(
# Check if the object (key, team, org) has access to the vector store
#########################################################
strict_layers: Final = _strict_grant_layers(deny_by_default, valid_token, team_object)
grants: Final = await _identity_grants(
grants: Final = await _vector_store_grants(
valid_token,
team_object,
user_object if "user" in strict_layers else None,
prisma_client,
user_api_key_cache,
strict_layers,
)
for layer, grant in grants:
if layer in strict_layers:

View file

@ -43,6 +43,7 @@ from litellm.litellm_core_utils.dd_tracing import tracer
from litellm.litellm_core_utils.dot_notation_indexing import get_nested_value
from litellm.litellm_core_utils.routing_common_utils import resolve_model_group_alias
from litellm.models.end_user import LiteLLM_EndUserTable
from litellm.models.team import LiteLLM_TeamRebuiltFromToken
from litellm.proxy._types import *
from litellm.proxy.agent_endpoints.auth.agent_caller import agent_caller_from_headers
from litellm.proxy.auth.access_group_rules import access_group_ids_for_identity
@ -3614,13 +3615,13 @@ async def _safe_fetch(label: str, awaitable):
return None
def _team_obj_from_token(valid_token: UserAPIKeyAuth) -> LiteLLM_TeamTableCachedObj:
def _team_obj_from_token(valid_token: UserAPIKeyAuth) -> LiteLLM_TeamRebuiltFromToken:
"""Reconstruct a cached team object from the fields already on the
UserAPIKeyAuth. Only called when valid_token.team_id is known to be
non-None (the caller gates on it)."""
assert valid_token.team_id is not None
token_team_models: Final = _token_team_models(valid_token)
return LiteLLM_TeamTableCachedObj(
return LiteLLM_TeamRebuiltFromToken(
team_id=valid_token.team_id,
max_budget=valid_token.team_max_budget,
soft_budget=valid_token.team_soft_budget,

View file

@ -578,6 +578,7 @@ async def create_access_group(
"mcp_tool_permissions": _tool_permissions_json(data.mcp_tool_permissions),
"access_agent_ids": data.access_agent_ids or [],
"access_skill_names": list(data.access_skill_names or ()),
"access_vector_store_ids": list(data.access_vector_store_ids or ()),
"assigned_team_ids": data.assigned_team_ids or [],
"assigned_key_ids": data.assigned_key_ids or [],
"created_by": user_api_key_dict.user_id,
@ -676,6 +677,7 @@ async def update_access_group(
"access_mcp_server_ids",
"access_agent_ids",
"access_skill_names",
"access_vector_store_ids",
):
value = list(iter_items(value or ()))
update_data[field] = (

View file

@ -1630,6 +1630,7 @@ model LiteLLM_AccessGroupTable {
mcp_tool_permissions Json?
access_agent_ids String[] @default([])
access_skill_names String[] @default([])
access_vector_store_ids String[] @default([])
assigned_team_ids String[] @default([])
assigned_key_ids String[] @default([])

View file

@ -1,6 +1,7 @@
import json
import re
from collections.abc import Iterable, Mapping
from itertools import chain
from types import MappingProxyType
from typing import Final, Literal, Protocol
@ -13,6 +14,7 @@ from litellm._logging import verbose_proxy_logger
from litellm.litellm_core_utils.core_helpers import as_object_list, as_str_mapping
from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
LiteLLM_TeamTable,
LitellmUserRoles,
UserAPIKeyAuth,
)
@ -181,6 +183,8 @@ async def can_user_access_vector_store(
4. The caller's team-level object_permission.vector_stores explicitly lists
this vector store id.
5. The caller's team_id matches the vector store's team_id.
6. An access group of the caller's key or team lists this vector store id,
which counts exactly like rules 3 and 4.
A dashboard session credential is evaluated against the same effective
contexts as listing (its own grants plus each real team of the user).
@ -193,7 +197,9 @@ async def can_user_access_vector_store(
return True
auth_contexts: Final = await _vector_store_auth_contexts(user_api_key_dict)
return await _is_vector_store_granted_to_any(vector_store, auth_contexts)
return await _is_vector_store_granted_to_any(vector_store, auth_contexts) or (
vector_store.get("vector_store_id") in await _access_group_vector_store_ids(auth_contexts)
)
async def _is_vector_store_granted(
@ -266,16 +272,54 @@ async def _is_vector_store_granted_to_any(
return False
async def _team_or_none(auth_context: UserAPIKeyAuth) -> LiteLLM_TeamTable | None:
"""A team that cannot be read has no access groups here, as it has no own list."""
from litellm.proxy.auth.auth_checks import get_team_object
from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache
if auth_context.team_id is None:
return None
try:
return await get_team_object(
team_id=auth_context.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
parent_otel_span=auth_context.parent_otel_span,
proxy_logging_obj=proxy_logging_obj,
)
except Exception as e: # noqa: BLE001 # no groups, per the docstring
verbose_proxy_logger.debug("Could not read team id=%s for its access groups: %s", auth_context.team_id, e)
return None
async def _access_group_vector_store_ids(auth_contexts: tuple[UserAPIKeyAuth, ...]) -> frozenset[str]:
"""Groups only add access here, so a group that cannot be read lists nothing."""
from litellm.proxy.auth.auth_checks import access_group_vector_stores
listed: Final = [
await access_group_vector_stores(auth_context, await _team_or_none(auth_context))
for auth_context in auth_contexts
]
return frozenset(chain.from_iterable(chain.from_iterable(stores.values()) for stores in listed))
async def filter_listable_vector_stores(
vector_stores: Iterable[LiteLLM_ManagedVectorStore],
user_api_key_dict: UserAPIKeyAuth,
) -> tuple[LiteLLM_ManagedVectorStore, ...]:
"""Non-admins only see stores their key, one of their teams' object_permission, or team ownership grants."""
"""Non-admins only see stores their key, a team's object_permission, team ownership or access groups grant."""
if _is_proxy_admin(user_api_key_dict):
return tuple(vector_stores)
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)])
group_store_ids: Final = await _access_group_vector_store_ids(auth_contexts)
return tuple(
[
vs
for vs in vector_stores
if vs.get("vector_store_id") in group_store_ids or await _is_vector_store_granted_to_any(vs, auth_contexts)
]
)
async def get_litellm_managed_vector_store(

View file

@ -1630,6 +1630,7 @@ model LiteLLM_AccessGroupTable {
mcp_tool_permissions Json?
access_agent_ids String[] @default([])
access_skill_names String[] @default([])
access_vector_store_ids String[] @default([])
assigned_team_ids String[] @default([])
assigned_key_ids String[] @default([])

View file

@ -12,6 +12,7 @@ class AccessGroupCreateRequest(LiteLLMBaseModel):
mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None
access_agent_ids: list[str] | None = None
access_skill_names: tuple[str, ...] | None = None
access_vector_store_ids: tuple[str, ...] | None = None
assigned_team_ids: list[str] | None = None
assigned_key_ids: list[str] | None = None
@ -24,6 +25,7 @@ class AccessGroupUpdateRequest(LiteLLMBaseModel):
mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None
access_agent_ids: list[str] | None = None
access_skill_names: tuple[str, ...] | None = None
access_vector_store_ids: tuple[str, ...] | None = None
assigned_team_ids: list[str] | None = None
assigned_key_ids: list[str] | None = None
@ -44,6 +46,7 @@ class AccessGroupResponse(LiteLLMBaseModel):
mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None
access_agent_ids: list[str]
access_skill_names: tuple[str, ...] = ()
access_vector_store_ids: tuple[str, ...] = ()
assigned_team_ids: list[str]
assigned_key_ids: list[str]
access_mcp_servers: tuple[AccessGroupResource, ...]

View file

@ -26299,6 +26299,8 @@ export interface components {
access_model_names?: string[] | null;
/** Access Skill Names */
access_skill_names?: string[] | null;
/** Access Vector Store Ids */
access_vector_store_ids?: string[] | null;
/** Assigned Key Ids */
assigned_key_ids?: string[] | null;
/** Assigned Team Ids */
@ -26353,6 +26355,11 @@ export interface components {
* @default []
*/
access_skill_names: string[];
/**
* Access Vector Store Ids
* @default []
*/
access_vector_store_ids: string[];
/** Assigned Key Ids */
assigned_key_ids: string[];
/** Assigned Keys */
@ -26405,6 +26412,8 @@ export interface components {
access_model_names?: string[] | null;
/** Access Skill Names */
access_skill_names?: string[] | null;
/** Access Vector Store Ids */
access_vector_store_ids?: string[] | null;
/** Assigned Key Ids */
assigned_key_ids?: string[] | null;
/** Assigned Team Ids */

View file

@ -0,0 +1,359 @@
"""A team or key in an access group that grants stores can use those stores and no others."""
import base64
from collections.abc import Sequence
from typing import Final, Literal
from unittest.mock import AsyncMock
import pytest
import litellm
from litellm import Router
from litellm.proxy._types import (
LiteLLM_TeamTableCachedObj,
ProxyException,
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import vector_store_access_check
from litellm.proxy.auth.user_api_key_auth import _team_obj_from_token
from litellm.types.vector_stores import LiteLLM_ManagedVectorStore
from litellm.vector_stores.vector_store_registry import VectorStoreRegistry
from tests.unit.proxy.vector_store_access_group_state import (
TEAM_ID,
TOKEN,
ProxyState,
make_key,
make_team,
own_permission,
)
@pytest.fixture
def proxy(monkeypatch: pytest.MonkeyPatch) -> ProxyState:
return ProxyState(monkeypatch)
def _virtual_key(groups: Sequence[str]) -> UserAPIKeyAuth:
key: Final = UserAPIKeyAuth(token=TOKEN, api_key=TOKEN, team_id=TEAM_ID, access_group_ids=list(groups))
key.via_virtual_key = True
return key
async def _request_is_allowed(
key: UserAPIKeyAuth, team: LiteLLM_TeamTableCachedObj | None, store: str, *, deny_by_default: bool = False
) -> bool:
try:
await vector_store_access_check(
request_body={"vector_store_ids": [store]},
team_object=team,
valid_token=key,
deny_by_default=deny_by_default,
)
except ProxyException as refused:
if refused.code != "401":
raise
return False
return True
@pytest.mark.asyncio
@pytest.mark.parametrize(
("key_stores", "key_groups", "team_stores", "team_groups", "store", "allowed"),
[
pytest.param(None, ("g1",), None, (), "store-a", True, id="key-group-grants"),
pytest.param(None, ("g1",), None, (), "store-b", False, id="key-group-restricts"),
pytest.param((), ("g1",), None, (), "store-b", False, id="key-group-restricts-with-empty-own-list"),
pytest.param(None, (), None, ("g1",), "store-a", True, id="team-group-grants"),
pytest.param(None, (), None, ("g1",), "store-b", False, id="team-group-restricts"),
pytest.param(None, (), (), ("g1",), "store-b", False, id="team-group-restricts-with-empty-own-list"),
pytest.param(("store-x",), ("g1",), None, (), "store-a", True, id="key-group-widens-own-list"),
pytest.param(("store-x",), ("g1",), None, (), "store-x", True, id="key-own-list-still-counts"),
pytest.param(("store-x",), ("g1",), None, (), "store-b", False, id="key-neither-list-denies"),
pytest.param(None, (), ("store-x",), ("g1",), "store-a", True, id="team-group-widens-own-list"),
pytest.param(None, (), ("store-x",), ("g1",), "store-x", True, id="team-own-list-still-counts"),
pytest.param(("store-x",), (), None, ("g1",), "store-a", False, id="key-own-list-narrows-team-group"),
pytest.param(None, ("g1",), ("store-x",), (), "store-a", False, id="team-own-list-narrows-key-group"),
pytest.param(None, ("g1", "g2"), None, (), "store-c", True, id="stores-from-a-second-group"),
pytest.param(None, ("models-only",), None, (), "store-b", True, id="models-only-group-changes-nothing"),
pytest.param(None, (), None, ("models-only",), "store-b", True, id="team-models-only-group-changes-nothing"),
pytest.param(("store-x",), ("models-only",), None, (), "store-b", False, id="models-only-keeps-own-list"),
pytest.param(None, (), None, (), "store-b", True, id="no-grants-anywhere-changes-nothing"),
],
)
async def test_request_check_combines_own_list_and_group_stores(
proxy: ProxyState,
key_stores: Sequence[str] | None,
key_groups: Sequence[str],
team_stores: Sequence[str] | None,
team_groups: Sequence[str],
store: str,
allowed: bool,
) -> None:
await proxy.add_group("g1", ("store-a",))
await proxy.add_group("g2", ("store-c",))
await proxy.add_group("models-only", models=("gpt-4",))
assert (
await _request_is_allowed(make_key(key_stores, key_groups, TEAM_ID), make_team(team_stores, team_groups), store)
is allowed
)
@pytest.mark.asyncio
async def test_key_group_counts_when_it_lists_only_the_keys_team(proxy: ProxyState) -> None:
await proxy.add_group("g1", ("store-a",), key_ids=())
assert await _request_is_allowed(make_key(groups=("g1",), team_id=TEAM_ID), None, "store-a") is True
assert await _request_is_allowed(make_key(groups=("g1",), team_id=TEAM_ID), None, "store-b") is False
@pytest.mark.asyncio
async def test_group_granted_by_an_identity_rule_counts_for_the_key(proxy: ProxyState) -> None:
await proxy.add_group("g1", ("store-a",), team_ids=(), key_ids=())
key: Final = make_key(("store-x",))
key.apply_access_group_rule_grants(("g1",))
assert await _request_is_allowed(key, None, "store-a") is True
assert await _request_is_allowed(key, None, "store-b") is False
@pytest.mark.asyncio
async def test_key_cannot_claim_a_group_that_does_not_list_it(proxy: ProxyState) -> None:
await proxy.add_group("g1", ("store-a",), team_ids=(), key_ids=())
assert await _request_is_allowed(make_key(("store-x",), ("g1",)), None, "store-a") is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
("store", "allowed"),
[pytest.param("store-a", True, id="granted"), pytest.param("store-b", False, id="not-granted")],
)
async def test_deny_by_default_counts_group_grants(proxy: ProxyState, store: str, allowed: bool) -> None:
await proxy.add_group("g1", ("store-a",))
key: Final = _virtual_key(("g1",))
assert await _request_is_allowed(key, make_team(groups=("g1",)), store, deny_by_default=True) is allowed
@pytest.mark.asyncio
async def test_deny_by_default_requires_the_key_too_not_only_its_team(proxy: ProxyState) -> None:
await proxy.add_group("g1", ("store-a",))
key: Final = _virtual_key(())
assert await _request_is_allowed(key, make_team(groups=("g1",)), "store-a", deny_by_default=True) is False
_MANAGED_A: Final = base64.urlsafe_b64encode(
b"litellm_proxy:vector_store;unified_id,abc;target_model_names,gpt-4;resource_id,kb-shared"
).decode()
_PLAIN_B: Final = LiteLLM_ManagedVectorStore(vector_store_id="kb-shared", custom_llm_provider="bedrock")
async def _all_allowed(key: UserAPIKeyAuth, stores: Sequence[str]) -> bool:
try:
await vector_store_access_check(
request_body={"vector_store_ids": list(stores)}, team_object=None, valid_token=key
)
except ProxyException as refused:
if refused.code != "401":
raise
return False
return True
@pytest.mark.asyncio
async def test_an_own_list_still_matches_by_provider_id(proxy: ProxyState, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "vector_store_registry", VectorStoreRegistry(vector_stores=[_PLAIN_B]))
assert await _all_allowed(make_key((_MANAGED_A,)), ("kb-shared",)) is True
async def _answer(
layer: Literal["key", "team"],
own: Sequence[str] | None,
groups: Sequence[str],
requested: Sequence[str],
deny_by_default: bool,
) -> tuple[object, ...] | None:
"""The refusal a virtual key gets when `layer` holds the `own` list and the `groups`, or None when allowed.
Under deny-by-default the other layer lists every requested store, so `layer` is the one that decides."""
other: Final = tuple(requested) if deny_by_default else None
key_stores: Final = own if layer == "key" else other
key: Final = _virtual_key(groups if layer == "key" else ()).model_copy(
update={"object_permission_id": "own", "object_permission": own_permission(key_stores)}
if key_stores is not None
else {}
)
team: Final = make_team(own if layer == "team" else other, groups if layer == "team" else ())
try:
await vector_store_access_check(
request_body={"vector_store_ids": list(requested)},
team_object=team,
valid_token=key,
deny_by_default=deny_by_default,
)
except ProxyException as refused:
return (refused.code, refused.type, refused.message)
return None
@pytest.mark.asyncio
@pytest.mark.parametrize("deny_by_default", [False, True], ids=["legacy", "deny-by-default"])
@pytest.mark.parametrize("layer", ["key", "team"])
@pytest.mark.parametrize("own", [None, ("store-x",)], ids=["no-own-list", "own-list"])
@pytest.mark.parametrize(
"group", [("store-a",), ("kb-shared",), (_MANAGED_A,)], ids=["plain", "provider-id", "managed"]
)
@pytest.mark.parametrize(
"requested",
[("store-a",), ("store-x",), ("store-b",), ("kb-shared",), (_MANAGED_A,), ("store-a", "store-b")],
ids=["store-a", "store-x", "store-b", "provider-id", "managed", "two-stores"],
)
async def test_a_group_store_is_checked_exactly_like_the_same_store_on_the_own_list(
proxy: ProxyState,
monkeypatch: pytest.MonkeyPatch,
deny_by_default: bool,
layer: Literal["key", "team"],
own: Sequence[str] | None,
group: Sequence[str],
requested: Sequence[str],
) -> None:
monkeypatch.setattr(litellm, "vector_store_registry", VectorStoreRegistry(vector_stores=[_PLAIN_B]))
await proxy.add_group("g1", group)
through_the_group: Final = await _answer(layer, own, ("g1",), requested, deny_by_default)
on_the_own_list: Final = await _answer(layer, (*(own or ()), *group), (), requested, deny_by_default)
assert through_the_group == on_the_own_list
@pytest.mark.asyncio
async def test_a_refusal_lists_the_allowed_stores_as_main_does(proxy: ProxyState) -> None:
with pytest.raises(ProxyException) as denied:
await vector_store_access_check(
request_body={"vector_store_ids": ["store-b"]}, team_object=None, valid_token=make_key(("store-x",))
)
assert "Only allowed to access ['store-x']" in denied.value.message
@pytest.mark.asyncio
async def test_keyless_team_member_gets_the_teams_group_stores(proxy: ProxyState) -> None:
await proxy.add_group("g1", ("store-a",))
member: Final = UserAPIKeyAuth(team_id=TEAM_ID)
assert await _request_is_allowed(member, make_team(groups=("g1",)), "store-a") is True
assert await _request_is_allowed(member, make_team(groups=("g1",)), "store-b") is False
@pytest.mark.asyncio
async def test_deny_by_default_still_denies_a_models_only_group(proxy: ProxyState) -> None:
await proxy.add_group("models-only", models=("gpt-4",))
key: Final = _virtual_key(("models-only",))
assert await _request_is_allowed(key, make_team(groups=("models-only",)), "store-a", deny_by_default=True) is False
_UNREADABLE: Final = "Could not read the team or its access groups to check vector store access"
@pytest.mark.asyncio
@pytest.mark.parametrize("own", [None, ("store-x",)], ids=["no-own-list", "own-list"])
@pytest.mark.parametrize("layer", ["key", "team"])
async def test_a_group_that_cannot_be_read_blocks_the_request_instead_of_lifting_the_restriction(
monkeypatch: pytest.MonkeyPatch, layer: Literal["key", "team"], own: Sequence[str] | None
) -> None:
ProxyState(monkeypatch, group_reads=AsyncMock(side_effect=RuntimeError("database unavailable")))
refusal: Final = await _answer(layer, own, ("g1",), ("store-b",), deny_by_default=False)
assert refusal is not None
assert refusal[0] == "503"
assert refusal[2] == _UNREADABLE
@pytest.mark.asyncio
async def test_auths_stand_in_for_a_team_it_could_not_read_is_read_again_so_the_teams_groups_still_apply(
proxy: ProxyState,
) -> None:
await proxy.add_group("g1", ("store-a",))
await proxy.add_team(make_team(groups=("g1",)))
key: Final = make_key(team_id=TEAM_ID)
assert await _request_is_allowed(key, _team_obj_from_token(key), "store-a") is True
assert await _request_is_allowed(key, _team_obj_from_token(key), "store-b") is False
@pytest.mark.asyncio
async def test_a_stand_in_team_that_still_cannot_be_read_blocks_the_request(monkeypatch: pytest.MonkeyPatch) -> None:
ProxyState(monkeypatch, team_reads=AsyncMock(side_effect=RuntimeError("database unavailable")))
key: Final = make_key(team_id=TEAM_ID)
with pytest.raises(ProxyException) as blocked:
await vector_store_access_check(
request_body={"vector_store_ids": ["store-b"]}, team_object=_team_obj_from_token(key), valid_token=key
)
assert (blocked.value.code, blocked.value.message) == ("503", _UNREADABLE)
@pytest.mark.asyncio
async def test_a_strict_team_layer_keeps_the_stand_in_team_and_is_refused_as_on_main(
monkeypatch: pytest.MonkeyPatch,
) -> None:
ProxyState(monkeypatch, team_reads=AsyncMock(side_effect=RuntimeError("database unavailable")))
key: Final = _virtual_key(())
assert await _request_is_allowed(key, _team_obj_from_token(key), "store-b", deny_by_default=True) is False
@pytest.mark.asyncio
async def test_deleted_group_grants_nothing_and_does_not_fail_the_request(proxy: ProxyState) -> None:
assert await _request_is_allowed(make_key(team_id=TEAM_ID), make_team(groups=("deleted",)), "store-b") is True
assert (
await _request_is_allowed(make_key(("store-x",), team_id=TEAM_ID), make_team(groups=("deleted",)), "store-b")
is False
)
@pytest.mark.asyncio
async def test_group_cached_before_the_upgrade_keeps_the_callers_own_list(proxy: ProxyState) -> None:
await proxy.cache.async_set_cache(
key="access_group_id:old",
value={"access_group_id": "old", "access_group_name": "old", "assigned_team_ids": [TEAM_ID]},
)
assert (
await _request_is_allowed(make_key(("store-x",), team_id=TEAM_ID), make_team(groups=("old",)), "store-b")
is False
)
assert (
await _request_is_allowed(make_key(("store-x",), team_id=TEAM_ID), make_team(groups=("old",)), "store-x")
is True
)
@pytest.mark.asyncio
async def test_malformed_store_ids_are_rejected_when_only_a_group_restricts(proxy: ProxyState) -> None:
await proxy.add_group("g1", ("store-a",))
with pytest.raises(ProxyException, match="vector_store_ids must be a list of strings"):
await vector_store_access_check(
request_body={"model": "gpt-4o-mini", "vector_store_ids": 123},
team_object=make_team(groups=("g1",)),
valid_token=make_key(team_id=TEAM_ID),
llm_router=Router(model_list=[{"model_name": "gpt-4o-mini", "litellm_params": {"model": "gpt-4o-mini"}}]),
)
@pytest.mark.asyncio
async def test_request_naming_no_store_never_reads_groups(monkeypatch: pytest.MonkeyPatch) -> None:
proxy: Final = ProxyState(monkeypatch, group_reads=AsyncMock(side_effect=RuntimeError("database unavailable")))
result: Final = await vector_store_access_check(
request_body={"model": "gpt-4"}, team_object=make_team(groups=("g1",)), valid_token=make_key(team_id=TEAM_ID)
)
assert result is True
assert proxy.group_reads.await_count == 0

View file

@ -3,8 +3,10 @@ Tests for access group management endpoints.
"""
import types
from collections.abc import Iterator, Mapping, Sequence
from contextlib import asynccontextmanager
from datetime import datetime
from typing import Final, TypeAlias
from unittest.mock import AsyncMock, MagicMock
import pytest
@ -22,6 +24,9 @@ from litellm.proxy.proxy_server import app
from litellm.types.agents import AgentResponse
_ClientAndMocks: TypeAlias = tuple[TestClient, MagicMock, MagicMock, MagicMock, MagicMock]
def _make_access_group_record(
access_group_id: str = "ag-123",
access_group_name: str = "test-group",
@ -30,12 +35,13 @@ def _make_access_group_record(
access_mcp_server_ids: list | None = None,
access_agent_ids: list | None = None,
access_skill_names: list | None = None,
access_vector_store_ids: Sequence[str] = (),
assigned_team_ids: list | None = None,
assigned_key_ids: list | None = None,
created_by: str | None = "admin-user",
updated_by: str | None = "admin-user",
created_at: datetime | None = None,
):
) -> MagicMock:
created_at_val = created_at or datetime.now()
updated_at_val = datetime.now()
data = {
@ -46,6 +52,7 @@ def _make_access_group_record(
"access_mcp_server_ids": access_mcp_server_ids or [],
"access_agent_ids": access_agent_ids or [],
"access_skill_names": access_skill_names or [],
"access_vector_store_ids": list(access_vector_store_ids),
"assigned_team_ids": assigned_team_ids or [],
"assigned_key_ids": assigned_key_ids or [],
"created_at": created_at_val,
@ -78,12 +85,12 @@ def _make_key_record(token: str, key_alias: str | None = None):
@pytest.fixture
def client_and_mocks(monkeypatch):
def client_and_mocks(monkeypatch: pytest.MonkeyPatch) -> Iterator[_ClientAndMocks]:
"""Setup mock prisma and admin auth for access group endpoints."""
mock_access_group_table = MagicMock()
mock_prisma = MagicMock()
def _create_side_effect(*, data):
def _create_side_effect(*, data: Mapping[str, object]) -> MagicMock:
return _make_access_group_record(
access_group_id="ag-new",
access_group_name=data.get("access_group_name", "new"),
@ -92,6 +99,7 @@ def client_and_mocks(monkeypatch):
access_mcp_server_ids=data.get("access_mcp_server_ids", []),
access_agent_ids=data.get("access_agent_ids", []),
access_skill_names=data.get("access_skill_names", []),
access_vector_store_ids=data.get("access_vector_store_ids", []),
assigned_team_ids=data.get("assigned_team_ids", []),
assigned_key_ids=data.get("assigned_key_ids", []),
created_by=data.get("created_by"),
@ -110,6 +118,7 @@ def client_and_mocks(monkeypatch):
access_mcp_server_ids=data.get("access_mcp_server_ids", []),
access_agent_ids=data.get("access_agent_ids", []),
access_skill_names=data.get("access_skill_names", []),
access_vector_store_ids=data.get("access_vector_store_ids", []),
assigned_team_ids=data.get("assigned_team_ids", []),
assigned_key_ids=data.get("assigned_key_ids", []),
updated_by=data.get("updated_by"),
@ -1714,6 +1723,41 @@ def test_update_access_group_writes_skill_names(client_and_mocks, update_payload
assert mock_table.update.await_args.kwargs["data"]["access_skill_names"] == expected_skills
def test_create_access_group_persists_and_returns_vector_store_ids(client_and_mocks: _ClientAndMocks) -> None:
client, _, mock_table, *_ = client_and_mocks
resp: Final = client.post(
"/v1/access_group", json={"access_group_name": "stores", "access_vector_store_ids": ["store-a"]}
)
assert resp.status_code == 201
assert resp.json()["access_vector_store_ids"] == ["store-a"]
assert mock_table.create.await_args.kwargs["data"]["access_vector_store_ids"] == ["store-a"]
@pytest.mark.parametrize(
("update_payload", "expected_stores"),
[
({"access_vector_store_ids": ["store-a"]}, ["store-a"]),
({"access_vector_store_ids": []}, []),
({"access_vector_store_ids": None}, []),
],
)
def test_update_access_group_writes_vector_store_ids(
client_and_mocks: _ClientAndMocks, update_payload: Mapping[str, object], expected_stores: Sequence[str]
) -> None:
client, _, mock_table, *_ = client_and_mocks
mock_table.find_unique = AsyncMock(
return_value=_make_access_group_record(access_group_id="ag-stores", access_vector_store_ids=["old-store"])
)
resp: Final = client.put("/v1/access_group/ag-stores", json=update_payload)
assert resp.status_code == 200
assert resp.json()["access_vector_store_ids"] == expected_stores
assert mock_table.update.await_args.kwargs["data"]["access_vector_store_ids"] == expected_stores
def test_update_access_group_without_skill_field_leaves_skills_untouched(client_and_mocks):
client, _, mock_table, *_ = client_and_mocks
mock_table.find_unique = AsyncMock(

View file

@ -0,0 +1,104 @@
"""Proxy state and identities shared by the vector store access group tests."""
from collections.abc import Sequence
from typing import Final
from unittest.mock import AsyncMock, MagicMock
import pytest
from litellm.models.access_group import LiteLLM_AccessGroupTable
from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
LiteLLM_TeamTableCachedObj,
LitellmUserRoles,
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import cache_access_object
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.types.vector_stores import LiteLLM_ManagedVectorStore
TOKEN: Final = "hashed-key"
TEAM_ID: Final = "team-t"
OTHER_TEAM_ID: Final = "owner-team"
class ProxyState:
"""The proxy globals the checks read: a real cache in front of a database that holds no groups."""
def __init__(
self,
monkeypatch: pytest.MonkeyPatch,
*,
group_reads: AsyncMock | None = None,
team_reads: AsyncMock | None = None,
store_reads: AsyncMock | None = None,
cache: UserApiKeyCache | MagicMock | None = None,
) -> None:
self.cache: Final = cache if cache is not None else UserApiKeyCache()
self.group_reads: Final = group_reads if group_reads is not None else AsyncMock(return_value=None)
self.prisma: Final = MagicMock(
db=MagicMock(
litellm_accessgrouptable=MagicMock(find_unique=self.group_reads),
litellm_teamtable=MagicMock(find_unique=team_reads or AsyncMock(return_value=None)),
litellm_managedvectorstorestable=MagicMock(find_unique=store_reads or AsyncMock(return_value=None)),
)
)
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", self.prisma)
monkeypatch.setattr("litellm.proxy.proxy_server.user_api_key_cache", self.cache)
async def add_group(
self,
group_id: str,
stores: Sequence[str] = (),
*,
models: Sequence[str] = (),
team_ids: Sequence[str] = (TEAM_ID,),
key_ids: Sequence[str] = (TOKEN,),
) -> None:
await cache_access_object(
access_group_id=group_id,
access_group_table=LiteLLM_AccessGroupTable(
access_group_id=group_id,
access_group_name=group_id,
access_model_names=list(models),
access_vector_store_ids=tuple(stores),
assigned_team_ids=list(team_ids),
assigned_key_ids=list(key_ids),
),
user_api_key_cache=self.cache,
)
async def add_team(self, team: LiteLLM_TeamTableCachedObj) -> None:
await self.cache.async_set_cache(key=f"team_id:{team.team_id}", value=team)
def own_permission(stores: Sequence[str] | None) -> LiteLLM_ObjectPermissionTable | None:
if stores is None:
return None
return LiteLLM_ObjectPermissionTable(object_permission_id="own", vector_stores=list(stores))
def make_key(
own_stores: Sequence[str] | None = None, groups: Sequence[str] = (), team_id: str | None = None
) -> UserAPIKeyAuth:
return UserAPIKeyAuth(
token=TOKEN,
team_id=team_id,
access_group_ids=list(groups),
object_permission_id="own" if own_stores is not None else None,
object_permission=own_permission(own_stores),
user_role=LitellmUserRoles.INTERNAL_USER,
)
def make_team(own_stores: Sequence[str] | None = None, groups: Sequence[str] = ()) -> LiteLLM_TeamTableCachedObj:
return LiteLLM_TeamTableCachedObj(
team_id=TEAM_ID,
access_group_ids=list(groups),
object_permission_id="own" if own_stores is not None else None,
object_permission=own_permission(own_stores),
)
def team_owned_store(store_id: str) -> LiteLLM_ManagedVectorStore:
return LiteLLM_ManagedVectorStore(vector_store_id=store_id, custom_llm_provider="openai", team_id=OTHER_TEAM_ID)

View file

@ -0,0 +1,310 @@
"""A store an access group lists is answered exactly like the same store on the key's or team's own list."""
import json
from collections.abc import Mapping, Sequence
from typing import Final, Literal
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
import litellm
from litellm import Router
from litellm.proxy import proxy_server
from litellm.proxy._types import UI_TEAM_ID, LiteLLM_TeamTableCachedObj, LiteLLM_UserTable, UserAPIKeyAuth
from litellm.proxy.proxy_server import app
from litellm.proxy.utils import hash_token
from litellm.proxy.vector_store_endpoints.utils import can_user_access_vector_store, filter_listable_vector_stores
from litellm.types.vector_stores import LiteLLM_ManagedVectorStore
from litellm.vector_stores.vector_store_registry import VectorStoreRegistry
from tests._master_key import MASTER_KEY
from tests.unit.proxy.vector_store_access_group_state import (
TEAM_ID,
TOKEN,
ProxyState,
make_key,
make_team,
own_permission,
team_owned_store,
)
Layer = Literal["key", "team"]
_KEY: Final = "sk-group-store"
_STORES: Final = (
team_owned_store("store-a"),
team_owned_store("store-b"),
LiteLLM_ManagedVectorStore(vector_store_id="store-free", custom_llm_provider="openai"),
)
async def _caller(proxy: ProxyState, layer: Layer, how: Literal["own-list", "group"]) -> UserAPIKeyAuth:
"""A team key granted store-a on `layer`, either on that layer's own list or through its access group."""
if how == "own-list":
await proxy.add_team(make_team(("store-a",) if layer == "team" else None))
key: Final = make_key(("store-a",) if layer == "key" else None, team_id=TEAM_ID)
return (
key.model_copy(update={"team_object_permission": own_permission(("store-a",))}) if layer == "team" else key
)
await proxy.add_group("g1", ("store-a",))
await proxy.add_team(make_team(groups=("g1",) if layer == "team" else ()))
return make_key(groups=("g1",) if layer == "key" else (), team_id=TEAM_ID)
@pytest.mark.asyncio
@pytest.mark.parametrize("layer", ["key", "team"])
@pytest.mark.parametrize("how", ["own-list", "group"])
async def test_the_store_gate_and_the_list_answer_a_group_store_like_an_own_list_store(
monkeypatch: pytest.MonkeyPatch, layer: Layer, how: Literal["own-list", "group"]
) -> None:
caller: Final = await _caller(ProxyState(monkeypatch), layer, how)
assert [await can_user_access_vector_store(store, caller) for store in _STORES] == [True, False, True]
assert await filter_listable_vector_stores(_STORES, caller) == (_STORES[0],)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("team_is_readable", "allowed"),
[pytest.param(True, [True, True, True], id="readable"), pytest.param(False, [True, False, True], id="unreadable")],
)
async def test_a_team_that_cannot_be_read_loses_its_group_stores_and_the_keys_still_count(
monkeypatch: pytest.MonkeyPatch, team_is_readable: bool, allowed: Sequence[bool]
) -> None:
"""The key's group lists store-a and names only the key's team. The team's group lists store-b."""
proxy: Final = ProxyState(monkeypatch, team_reads=AsyncMock(side_effect=RuntimeError("database unavailable")))
await proxy.add_group("key-group", ("store-a",), key_ids=())
await proxy.add_group("team-group", ("store-b",))
if team_is_readable:
await proxy.add_team(make_team(groups=("team-group",)))
caller: Final = make_key(groups=("key-group",), team_id=TEAM_ID)
assert [await can_user_access_vector_store(store, caller) for store in _STORES] == allowed
@pytest.mark.asyncio
async def test_a_caller_without_groups_keeps_its_answers_when_its_team_cannot_be_read(
monkeypatch: pytest.MonkeyPatch,
) -> None:
ProxyState(monkeypatch, team_reads=AsyncMock(side_effect=RuntimeError("database unavailable")))
caller: Final = make_key(("store-a",), team_id=TEAM_ID)
assert [await can_user_access_vector_store(store, caller) for store in _STORES] == [True, False, True]
assert await filter_listable_vector_stores(_STORES, caller) == (_STORES[0],)
@pytest.mark.asyncio
async def test_a_group_that_cannot_be_read_grants_nothing_and_own_list_stores_still_pass(
monkeypatch: pytest.MonkeyPatch,
) -> None:
proxy: Final = ProxyState(monkeypatch, group_reads=AsyncMock(side_effect=RuntimeError("database unavailable")))
await proxy.add_team(make_team(groups=("g1",)))
caller: Final = make_key(("store-b",), ("g1",), TEAM_ID)
assert [await can_user_access_vector_store(store, caller) for store in _STORES] == [False, True, True]
@pytest.mark.asyncio
async def test_a_dashboard_session_gets_the_group_stores_of_each_of_the_users_teams(
monkeypatch: pytest.MonkeyPatch,
) -> None:
proxy: Final = ProxyState(monkeypatch)
await proxy.add_group("g1", ("store-a",), team_ids=("team-1",))
await proxy.add_group("g2", ("store-c",), team_ids=("team-2",))
for team_id, group in (("team-1", "g1"), ("team-2", "g2")):
await proxy.cache.async_set_cache(
key=f"team_id:{team_id}", value=LiteLLM_TeamTableCachedObj(team_id=team_id, access_group_ids=[group])
)
await proxy.cache.async_set_cache(
key="session-user", value=LiteLLM_UserTable(user_id="session-user", teams=["team-1", "team-2"])
)
session: Final = UserAPIKeyAuth(token=TOKEN, user_id="session-user", team_id=UI_TEAM_ID)
stores: Final = tuple(team_owned_store(store_id) for store_id in ("store-a", "store-b", "store-c"))
assert await filter_listable_vector_stores(stores, session) == (stores[0], stores[2])
assert [await can_user_access_vector_store(store, session) for store in stores] == [True, False, True]
READ_ROUTES: Final = (
pytest.param("POST", "/v1/vector_stores/{}/search", {"query": "hi"}, id="search"),
pytest.param("GET", "/v1/vector_stores/{}", None, id="retrieve"),
pytest.param("GET", "/v1/vector_stores/{}/files", None, id="file-list"),
pytest.param("GET", "/v1/vector_stores/{}/files/file-1", None, id="file-retrieve"),
pytest.param("GET", "/v1/vector_stores/{}/files/file-1/content", None, id="file-content"),
pytest.param("POST", "/vector_store/info", {"vector_store_id": "{}"}, id="info"),
pytest.param(
"POST",
"/v1/rag/query",
{"model": "m", "messages": [{"role": "user", "content": "hi"}], "retrieval_config": {"vector_store_id": "{}"}},
id="rag-query",
),
pytest.param(
"POST",
"/v1/responses",
{"model": "m", "input": "hi", "tools": [{"type": "file_search", "vector_store_ids": ["{}"]}]},
id="responses",
),
)
MANAGE_ROUTES: Final = (
pytest.param(
"POST", "/vector_store/update", {"vector_store_id": "{}", "vector_store_name": "renamed"}, id="update"
),
pytest.param("POST", "/vector_store/delete", {"vector_store_id": "{}"}, id="delete"),
pytest.param("POST", "/v1/vector_stores/{}", {"name": "renamed"}, id="provider-update"),
pytest.param("DELETE", "/v1/vector_stores/{}", None, id="provider-delete"),
pytest.param("POST", "/v1/vector_stores/{}/files", {"file_id": "file-1"}, id="file-create"),
pytest.param("POST", "/v1/vector_stores/{}/files/file-1", {"attributes": {"topic": "parks"}}, id="file-update"),
pytest.param("DELETE", "/v1/vector_stores/{}/files/file-1", None, id="file-delete"),
)
def _body_for(body: Mapping[str, object] | None, store_id: str) -> object:
return None if body is None else json.loads(json.dumps(body).replace("{}", store_id))
def _openai_answer(request: httpx.Request) -> httpx.Response:
path: Final = request.url.path
if path.endswith("/responses"):
return httpx.Response(
200,
json={
"id": "resp_1",
"object": "response",
"created_at": 1,
"status": "completed",
"model": "gpt-4o-mini",
"output": [],
"parallel_tool_calls": True,
"tool_choice": "auto",
"tools": [],
"usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
},
)
if path.endswith("/chat/completions"):
return httpx.Response(
200,
json={
"id": "chat_1",
"object": "chat.completion",
"created": 1,
"model": "gpt-4o-mini",
"choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "hi"}}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
},
)
if path.endswith("/search"):
return httpx.Response(
200,
json={
"object": "vector_store.search_results.page",
"search_query": "hi",
"data": [],
"has_more": False,
"next_page": None,
},
)
if request.method == "DELETE":
return httpx.Response(
200, json={"id": path.rsplit("/", 1)[-1], "object": "vector_store.deleted", "deleted": True}
)
if path.endswith("/files"):
return httpx.Response(
200, json={"object": "list", "data": [], "first_id": None, "last_id": None, "has_more": False}
)
if path.endswith("/content"):
return httpx.Response(200, json={"object": "vector_store.file_content.page", "data": [], "has_more": False})
if "/files/" in path:
return httpx.Response(
200,
json={
"id": "file-1",
"object": "vector_store.file",
"created_at": 1,
"usage_bytes": 0,
"vector_store_id": "store-a",
"status": "completed",
},
)
return httpx.Response(
200,
json={
"id": "store-a",
"object": "vector_store",
"created_at": 1,
"name": "store-a",
"usage_bytes": 0,
"file_counts": {"in_progress": 0, "completed": 0, "failed": 0, "cancelled": 0, "total": 0},
"status": "completed",
"metadata": {},
},
)
@pytest.fixture
def openai_stub(monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter, httpx_transport: None) -> None:
respx_mock.route(host="api.openai.com").mock(side_effect=_openai_answer)
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setattr(
"litellm.proxy.proxy_server.llm_router",
Router(
model_list=[{"model_name": "m", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-test"}}]
),
)
monkeypatch.setattr(litellm, "vector_store_registry", VectorStoreRegistry(vector_stores=list(_STORES)))
async def _statuses(
monkeypatch: pytest.MonkeyPatch,
layer: Layer,
how: Literal["own-list", "group"],
method: str,
path: str,
body: Mapping[str, object] | None,
) -> Sequence[int]:
stores: Final = {store["vector_store_id"]: store for store in _STORES}
def row(where: Mapping[str, str], data: object = None) -> MagicMock:
return MagicMock(model_dump=MagicMock(return_value=stores[where["vector_store_id"]]))
proxy: Final = ProxyState(monkeypatch, store_reads=AsyncMock(side_effect=row))
monkeypatch.setattr(proxy.prisma.db.litellm_managedvectorstorestable, "update", AsyncMock(side_effect=row))
monkeypatch.setattr(proxy.prisma.db.litellm_managedvectorstorestable, "delete", AsyncMock())
token: Final = hash_token(_KEY)
caller: Final = (await _caller(proxy, layer, how)).model_copy(
update={"token": token, "allowed_routes": [path] if path.startswith("/vector_store/") else []}
)
await proxy.cache.async_set_cache(key=token, value=caller)
monkeypatch.setattr(proxy_server, "master_key", MASTER_KEY)
client: Final = TestClient(app, raise_server_exceptions=False)
return [
client.request(
method,
path.format(store_id),
json=_body_for(body, store_id),
headers={"Authorization": f"Bearer {_KEY}"},
).status_code
for store_id in stores
]
@pytest.mark.asyncio
@pytest.mark.parametrize("layer", ["key", "team"])
@pytest.mark.parametrize(("method", "path", "body"), [*READ_ROUTES, *MANAGE_ROUTES])
async def test_every_store_route_answers_a_group_store_like_an_own_list_store(
monkeypatch: pytest.MonkeyPatch,
openai_stub: None,
layer: Layer,
method: str,
path: str,
body: Mapping[str, object] | None,
) -> None:
"""Real auth runs the request check and the route's store gate. Statuses are for store-a (granted),
store-b (another team's) and store-free (no team), in that order."""
listed: Final = await _statuses(monkeypatch, layer, "own-list", method, path, body)
grouped: Final = await _statuses(monkeypatch, layer, "group", method, path, body)
assert grouped == listed
assert grouped[0] == 200
assert grouped[1] in (401, 403)