From 5dee6c13d92f1206d3d27bdec4a910929af872c1 Mon Sep 17 00:00:00 2001 From: Ashay Date: Sat, 8 Aug 2026 18:23:39 +0530 Subject: [PATCH 1/3] Enforce tenant isolation via TenantScopedClient and CI guard Isolation used to rest on every call site remembering build_filter. Route all search reads through a wrapper that requires tenant_id and always injects the tenant filter (including hybrid Prefetch branches). A CI test fails if package code calls client.query_points/scroll/retrieve outside that wrapper. search() behaviour is unchanged for callers. Fixes #3 --- README.md | 16 ++++ src/tenantq/scoped_client.py | 138 +++++++++++++++++++++++++++++++++++ src/tenantq/search.py | 21 +++++- tests/test_scoped_access.py | 96 ++++++++++++++++++++++++ 4 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 src/tenantq/scoped_client.py create mode 100644 tests/test_scoped_access.py diff --git a/README.md b/README.md index 666ad7a..733d1f2 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,22 @@ built via `payload_m`. Every single query carries a mandatory `Filter` on that proves it). This is Qdrant's recommended multitenant layout: cheaper than a collection-per-tenant and strictly isolated. +**Enforced isolation (copy this).** Isolation used to depend on every call site +remembering `build_filter(tenant_id, ...)`. That is easy to forget on a new code +path, and the failure is silent. tenantq now routes all search reads through +`TenantScopedClient` (`src/tenantq/scoped_client.py`): + +1. `tenant_id` is a **required** keyword argument on `query_points` / `scroll` / + `retrieve`. +2. The wrapper **always** injects a `tenant_id` match into the filter (and into + every hybrid `Prefetch` branch). A raw filter cannot drop the tenant scope. +3. A CI test (`tests/test_scoped_access.py`) fails if package code calls + `.query_points(`, `.scroll(`, or `.retrieve(` outside that wrapper. + +`search()` behaviour is unchanged for callers; only the enforcement mechanism is +new. If you adapt this layout, copy the wrapper + the grep-style guard — not just +the filter helper. + **Hybrid retrieval.** Dense vectors come from `fastembed` (`sentence-transformers/all-MiniLM-L6-v2`), sparse from fastembed's `SparseTextEmbedding` (`Qdrant/bm25`, IDF modifier). Hybrid search uses the Qdrant diff --git a/src/tenantq/scoped_client.py b/src/tenantq/scoped_client.py new file mode 100644 index 0000000..3ea460a --- /dev/null +++ b/src/tenantq/scoped_client.py @@ -0,0 +1,138 @@ +"""Tenant-scoped Qdrant access. + +Raw ``QdrantClient.query_points`` / ``scroll`` / ``retrieve`` accept an optional +filter. A caller that forgets the tenant condition silently returns other +tenants' documents. This wrapper is the only code path that may issue those +reads: it requires ``tenant_id`` and always injects the tenant filter. + +``search()`` and any future query helpers must go through :class:`TenantScopedClient`. +A CI test greps the package for direct client reads outside this module. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from qdrant_client import QdrantClient, models + +from .config import TENANT_FIELD + + +def _require_tenant_id(tenant_id: str) -> str: + if tenant_id is None or not str(tenant_id).strip(): + raise ValueError("tenant_id is required and must be non-empty") + return str(tenant_id) + + +def _with_tenant(tenant_id: str, query_filter: Optional[models.Filter]) -> models.Filter: + """Merge a mandatory tenant match into an optional caller filter. + + The tenant condition is always present. Callers cannot pass a filter that + drops it: even if they include their own tenant clause, we still add ours + for the required ``tenant_id``. + """ + tenant_id = _require_tenant_id(tenant_id) + tenant_cond = models.FieldCondition( + key=TENANT_FIELD, match=models.MatchValue(value=tenant_id) + ) + if query_filter is None: + return models.Filter(must=[tenant_cond]) + + must = list(query_filter.must or []) + # Put tenant first so the isolation guarantee is obvious in serialized filters. + must.insert(0, tenant_cond) + return models.Filter( + must=must, + should=query_filter.should, + must_not=query_filter.must_not, + min_should=query_filter.min_should, + ) + + +def _scope_prefetch(tenant_id: str, prefetch: Any) -> Any: + """Ensure every Prefetch branch carries the tenant filter.""" + if prefetch is None: + return None + if isinstance(prefetch, list): + return [_scope_prefetch(tenant_id, p) for p in prefetch] + if isinstance(prefetch, models.Prefetch): + merged = _with_tenant(tenant_id, prefetch.filter) + nested = _scope_prefetch(tenant_id, prefetch.prefetch) if prefetch.prefetch else None + # model_copy keeps forward-compat with new Prefetch fields + if hasattr(prefetch, "model_copy"): + return prefetch.model_copy(update={"filter": merged, "prefetch": nested}) + return models.Prefetch( + query=prefetch.query, + using=prefetch.using, + filter=merged, + params=prefetch.params, + limit=prefetch.limit, + prefetch=nested, + ) + return prefetch + + +class TenantScopedClient: + """Thin wrapper: the only surface allowed to call read APIs on Qdrant. + + Write/ingest paths still use the raw client (points already carry tenant_id + in payload). Isolation at read time is what this enforces. + """ + + def __init__(self, client: QdrantClient) -> None: + self._client = client + + @property + def raw(self) -> QdrantClient: + """Escape hatch for ingest, collection admin, and tests — not for search.""" + return self._client + + def query_points( + self, + *, + tenant_id: str, + collection_name: str, + query_filter: Optional[models.Filter] = None, + prefetch: Any = None, + **kwargs: Any, + ): + qf = _with_tenant(tenant_id, query_filter) + pf = _scope_prefetch(tenant_id, prefetch) + return self._client.query_points( + collection_name=collection_name, + query_filter=qf, + prefetch=pf, + **kwargs, + ) + + def scroll( + self, + *, + tenant_id: str, + collection_name: str, + scroll_filter: Optional[models.Filter] = None, + **kwargs: Any, + ): + sf = _with_tenant(tenant_id, scroll_filter) + return self._client.scroll( + collection_name=collection_name, + scroll_filter=sf, + **kwargs, + ) + + def retrieve( + self, + *, + tenant_id: str, + collection_name: str, + ids: list, + **kwargs: Any, + ): + # retrieve has no filter param on all client versions; post-filter by tenant + points = self._client.retrieve(collection_name=collection_name, ids=ids, **kwargs) + tenant_id = _require_tenant_id(tenant_id) + return [ + p + for p in points + if (p.payload or {}).get(TENANT_FIELD) == tenant_id + ] diff --git a/src/tenantq/search.py b/src/tenantq/search.py index 41cc5c4..585ff5c 100644 --- a/src/tenantq/search.py +++ b/src/tenantq/search.py @@ -13,6 +13,8 @@ from qdrant_client import QdrantClient, models +from .scoped_client import TenantScopedClient + from .config import ( CATEGORY_FIELD, CREATED_AT_FIELD, @@ -90,14 +92,23 @@ def search( created_before: Optional[int] = None, prefetch_limit: int = 50, ) -> List[SearchHit]: - """Run a tenant-isolated search in the requested retrieval mode.""" + """Run a tenant-isolated search in the requested retrieval mode. + + All Qdrant reads go through :class:`TenantScopedClient`, which requires + ``tenant_id`` and injects the tenant filter so a missing filter cannot + silently cross tenants. + """ qfilter = build_filter(tenant_id, category, created_after, created_before) + # build_filter already includes tenant_id; scoped client re-injects it as a + # second hard guarantee (duplicate must-conditions are fine for MatchValue). + scoped = TenantScopedClient(client) params = models.SearchParams(hnsw_ef=settings.hnsw.hnsw_ef) start = time.perf_counter() if mode == "dense": dense_vec = embedder.embed_dense([query])[0] - res = client.query_points( + res = scoped.query_points( + tenant_id=tenant_id, collection_name=settings.collection, query=dense_vec, using=DENSE_VECTOR_NAME, @@ -108,7 +119,8 @@ def search( ) elif mode == "sparse": sv = embedder.embed_sparse([query])[0] - res = client.query_points( + res = scoped.query_points( + tenant_id=tenant_id, collection_name=settings.collection, query=models.SparseVector(indices=sv.indices, values=sv.values), using=SPARSE_VECTOR_NAME, @@ -119,7 +131,8 @@ def search( elif mode == "hybrid": dense_vec = embedder.embed_dense([query])[0] sv = embedder.embed_sparse([query])[0] - res = client.query_points( + res = scoped.query_points( + tenant_id=tenant_id, collection_name=settings.collection, prefetch=[ models.Prefetch( diff --git a/tests/test_scoped_access.py b/tests/test_scoped_access.py new file mode 100644 index 0000000..89eed29 --- /dev/null +++ b/tests/test_scoped_access.py @@ -0,0 +1,96 @@ +"""Structural isolation: no raw query_points/scroll/retrieve outside the wrapper.""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +from tenantq.scoped_client import TenantScopedClient, _with_tenant + +PKG = Path(__file__).resolve().parents[1] / "src" / "tenantq" + +# Modules allowed to call the underlying client read APIs. +_ALLOWED_READ_MODULES = frozenset({"scoped_client.py"}) + +_READ_ATTRS = ("query_points", "scroll", "retrieve") + + +def _python_files(): + return sorted(PKG.glob("*.py")) + + +# Raw client receivers only. Wrapper calls (scoped.query_points) are the allowed path. +_RAW_CLIENT_READ = re.compile( + r"(? Date: Sat, 8 Aug 2026 18:48:27 +0530 Subject: [PATCH 2/3] style: fix ruff import order and unused imports in tests --- tests/test_scoped_access.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_scoped_access.py b/tests/test_scoped_access.py index 89eed29..9f77dd9 100644 --- a/tests/test_scoped_access.py +++ b/tests/test_scoped_access.py @@ -2,7 +2,6 @@ from __future__ import annotations -import ast import re from pathlib import Path @@ -79,7 +78,6 @@ def test_with_tenant_requires_non_empty(): def test_with_tenant_injects_condition(): - from qdrant_client import models from tenantq.config import TENANT_FIELD f = _with_tenant("acme", None) From f561830cb351a32eb07b7fd99ffa023093dcfc68 Mon Sep 17 00:00:00 2001 From: Ashay Date: Sat, 8 Aug 2026 18:52:53 +0530 Subject: [PATCH 3/3] style: sort first-party imports in search.py for ruff I001 --- src/tenantq/search.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tenantq/search.py b/src/tenantq/search.py index 585ff5c..e544fb7 100644 --- a/src/tenantq/search.py +++ b/src/tenantq/search.py @@ -13,8 +13,6 @@ from qdrant_client import QdrantClient, models -from .scoped_client import TenantScopedClient - from .config import ( CATEGORY_FIELD, CREATED_AT_FIELD, @@ -26,6 +24,7 @@ ) from .embeddings import Embedder from .metrics import QUERY_LATENCY +from .scoped_client import TenantScopedClient Mode = Literal["dense", "sparse", "hybrid"]