fix(vector_stores): classify write endpoints before reads on substring collisions

This commit is contained in:
mateo-berri 2026-08-14 16:53:48 -07:00
parent 08966c842b
commit b14c4a8d45
3 changed files with 71 additions and 11 deletions

View file

@ -48,8 +48,11 @@ class AzureAIVectorStoreConfig(BaseVectorStoreConfig, BaseAzureLLM):
Patterns stay literal rather than ``{placeholder}`` templates because the
matcher falls back to the substring before a ``{``, which here is always
``/indexes/`` -- broad enough that a templated read, matched first, would
shadow the ``/docs/index`` write.
``/indexes/``. The matcher is substring-based, so an index name may
itself contain a read fragment (an index named ``analyze*`` puts
``/analyze`` inside the batch-write path); writes are classified before
reads, so such a path demands the write grant rather than being
shadowed into a read.
"""
return {
"read": [

View file

@ -387,17 +387,19 @@ def is_allowed_to_call_vector_store_endpoint(
)
return True
# Determine the permission type based on the request
# Writes are classified before reads so a path matching both patterns
# requires the stronger grant (e.g. the azure batch write on an index
# named "analyze*" also contains the "/analyze" read fragment)
permission_type = None
for endpoint in provider_vector_store_endpoints["read"]:
for endpoint in provider_vector_store_endpoints["write"]:
if request.method == endpoint[0] and _does_endpoint_match(endpoint[1], request_route):
permission_type = "read"
permission_type = "write"
break
if permission_type is None:
for endpoint in provider_vector_store_endpoints["write"]:
for endpoint in provider_vector_store_endpoints["read"]:
if request.method == endpoint[0] and _does_endpoint_match(endpoint[1], request_route):
permission_type = "write"
permission_type = "read"
break
if permission_type is None:
@ -454,15 +456,15 @@ def is_allowed_to_call_vector_store_files_endpoint(
request_route: Final = get_request_route(request)
permission_type: str | None = None
for endpoint in provider_vector_store_endpoints.get("read", ()):
for endpoint in provider_vector_store_endpoints.get("write", ()):
if request.method == endpoint[0] and _does_endpoint_match(endpoint[1], request_route):
permission_type = "read"
permission_type = "write"
break
if permission_type is None:
for endpoint in provider_vector_store_endpoints.get("write", ()):
for endpoint in provider_vector_store_endpoints.get("read", ()):
if request.method == endpoint[0] and _does_endpoint_match(endpoint[1], request_route):
permission_type = "write"
permission_type = "read"
break
if permission_type is None:

View file

@ -3057,3 +3057,58 @@ class TestAzureAIDocumentWritePassthroughPermission:
)
assert exc_info.value.status_code == 403
assert f"Only proxy admins can {operation}" in exc_info.value.detail
class TestAzureAIAnalyzeNamedIndexClassification:
"""Regression tests for write-before-read endpoint classification.
The endpoint matcher is substring-based, so the batch-write path of an
index named ``analyze*`` contains the ``("POST", "/analyze")`` read
fragment. Reads-first classification labeled that write a read, letting a
read-only grant upload, merge, and delete documents (and refusing
legitimate write-only grants). Writes are classified first now, so an
ambiguous path demands the stronger grant.
"""
def _request(self, method: str, path: str) -> MagicMock:
request = MagicMock(spec=Request)
request.method = method
request.url.path = path
return request
def _team_member(self, index: str, permissions: list) -> MagicMock:
user = MagicMock(spec=UserAPIKeyAuth)
user.user_role = None
user.metadata = {"allowed_vector_store_indexes": [{"index_name": index, "index_permissions": permissions}]}
user.team_metadata = None
return user
@pytest.mark.parametrize("index", ["analyze", "analyzer-reports"])
def test_read_only_grant_cannot_upload_to_analyze_named_index(self, index):
with pytest.raises(HTTPException) as exc_info:
is_allowed_to_call_vector_store_endpoint(
provider=LlmProviders.AZURE_AI,
index_name=index,
request=self._request("POST", f"/azure_ai/indexes/{index}/docs/index"),
user_api_key_dict=self._team_member(index, ["read"]),
)
assert exc_info.value.status_code == 403
@pytest.mark.parametrize("index", ["analyze", "analyzer-reports"])
def test_write_grant_can_upload_to_analyze_named_index(self, index):
result = is_allowed_to_call_vector_store_endpoint(
provider=LlmProviders.AZURE_AI,
index_name=index,
request=self._request("POST", f"/azure_ai/indexes/{index}/docs/index"),
user_api_key_dict=self._team_member(index, ["write"]),
)
assert result is True
def test_read_only_grant_can_still_analyze_on_analyze_named_index(self):
result = is_allowed_to_call_vector_store_endpoint(
provider=LlmProviders.AZURE_AI,
index_name="analyze",
request=self._request("POST", "/azure_ai/indexes/analyze/analyze"),
user_api_key_dict=self._team_member("analyze", ["read"]),
)
assert result is True