fix(security): scope every HybridRAG leg and ingestion path to the caller's tenant

A live /query/hybrid probe as user=llm_tester returned jpmschweitzer
pages. Audit of all legs (vector, graph, web-persistence, volatile,
documents) plus enrichment/persistence found and fixed these unscoped
paths:

- vector_service.update_from_page and graph_service.update_from_page now
  refuse pages outside users/{user}/ - previously any tenant could
  ingest any wiki page (incl. another tenant's) into its own collection
  and graph labels, which is how foreign content entered the vector leg.
- ingestion_service.ingest_all_pages clamps path_prefix to the caller's
  namespace (segment-exact, sanitized comparison) and defaults to
  users/{user}; /ingest/all returns 400 on cross-tenant prefixes.
- hybrid_rag_service._persist_search_for_librarian linked SearchQuery
  nodes to unscoped (d:Document {page_id}); now matches only
  User_{Tenant}_Document nodes.
- graph_service: _get_entity_mention_count, entity-stub mention/related
  queries, generate_entity_stubs, find/purge_orphan_entities matched
  unscoped Document nodes; cleanup_broken_relationships matched all
  tenants' SearchQuery nodes; _entity_has_wiki_page listed all wiki
  pages. All are now tenant-label / namespace scoped.
- volatile_service collection names now use the sanitized user id.
- is_path_in_user_namespace enforces a path-segment boundary
  (users/llm_tester2 is not llm_tester's namespace) and treats
  hyphen/underscore tenant spellings as the same sanitized tenant.
- New offline unit tests per leg (mocked clients) assert the
  tenant-scoped collection/label/path is used and cross-tenant access
  is refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:15:43 +02:00
co-authored by Claude Fable 5
parent a4c299bf9b
commit 8a1c9ba5f3
9 changed files with 522 additions and 30 deletions
+8
View File
@@ -13,6 +13,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed (security)
- **Cross-tenant leaks in HybridRAG legs and ingestion closed** - A live probe as `user=llm_tester` returned `jpmschweitzer` pages. Root causes fixed:
- **Ingestion namespace enforcement**: `vector` and `graph` `update_from_page` now refuse pages whose wiki path is outside `users/{user}/` (previously any tenant could ingest any page id — including another tenant's — into its own collection/labels, which is how foreign content ended up in the vector leg). `/ingest/all` clamps `path_prefix` to the caller's namespace (400 on cross-tenant prefixes) and defaults to `users/{user}`.
- **Search persistence**: the `FOUND` link in HybridRAG phase 6 matched `(d:Document {page_id})` unscoped, attaching the caller's SearchQuery to other tenants' Document nodes; it now matches only `User_{Tenant}_Document` nodes.
- **Graph enrichment/consolidation queries scoped**: `_get_entity_mention_count`, entity-stub generation, orphan-entity find/purge, and `cleanup_broken_relationships` matched unscoped `Document`/`SearchQuery` nodes; all now use the tenant's labels. Entity-page existence checks list only the tenant's wiki namespace.
- **Volatile collections sanitized**: `volatile_{user}` collection names now use the sanitized user id (same scheme as document collections).
- **Namespace matching hardened**: `is_path_in_user_namespace` now enforces a path-segment boundary (`users/llm_tester2` is no longer inside `llm_tester`'s namespace) and compares sanitized tenant segments.
- Offline unit tests added per leg (vector, graph, volatile, documents, enrichment, persistence, ingestion) asserting the tenant-scoped collection/label/path is used.
- **`/query/graph` and `/graph/query` hardened to read-only** - The documented "automatic user scoping" was a no-op (a live probe confirmed any user string could read the whole graph) and the client permitted writes. Raw Cypher queries are now (1) rejected with 400 when they contain write clauses (`CREATE`/`MERGE`/`DELETE`/`DETACH`/`SET`/`REMOVE`/`DROP`/`FOREACH`/`LOAD CSV`) or any `CALL` procedure (conservative denylist on the uppercased query), and (2) executed through a Neo4j session opened with `default_access_mode=READ_ACCESS` so the database itself refuses writes as a backstop. The endpoints are now honestly documented as **admin/debug, unscoped read-only**: results are not restricted to the caller's tenant labels — use `/graph/nodes` for tenant-scoped access.
### Added
+12 -2
View File
@@ -243,6 +243,16 @@ def is_path_in_user_namespace(path: str, user_id: str) -> bool:
False
>>> is_path_in_user_namespace("/public/docs", "jpmschweitzer")
False
>>> is_path_in_user_namespace("/users/llm_tester2/x", "llm_tester")
False
>>> is_path_in_user_namespace("users/llm-tester/x", "llm_tester")
True
"""
namespace = get_wikijs_namespace(user_id)
return path.startswith(namespace)
# Compare the tenant path segment exactly (after sanitization, since
# canonical wiki namespaces use sanitized user ids). This enforces a
# segment boundary — "users/llm_tester2" is NOT in "llm_tester"'s
# namespace — and treats "llm-tester"/"llm_tester" as the same tenant.
parts = str(path).lstrip("/").split("/")
if len(parts) < 2 or parts[0] != "users":
return False
return sanitize_user_id(parts[1]) == sanitize_user_id(user_id)
+9 -6
View File
@@ -159,11 +159,14 @@ async def ingest_all_pages(
-H "Authorization: Bearer $API_KEY"
```
"""
result = await ingestion.ingest_all_pages(
user=user,
path_prefix=path_prefix,
force_refresh=force_refresh,
max_concurrent=max_concurrent
)
try:
result = await ingestion.ingest_all_pages(
user=user,
path_prefix=path_prefix,
force_refresh=force_refresh,
max_concurrent=max_concurrent
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return result
+31 -14
View File
@@ -12,7 +12,7 @@ import logging
from src.clients.neo4j_client import Neo4jClient
from src.clients.wikijs_client import WikiJSClient
from src.core.multi_tenancy import get_neo4j_user_label
from src.core.multi_tenancy import get_neo4j_user_label, is_path_in_user_namespace
from src.models.graph import (
GraphNode, GraphRelationship, GraphNodeDetail,
CypherQueryResponse, GraphUpdateSummary, EntityMention,
@@ -424,6 +424,14 @@ class GraphService:
if not page:
raise ValueError(f"Page {page_id} not found")
# TENANT ISOLATION: only pages inside the user's own wiki
# namespace may be written into that user's graph labels.
if not is_path_in_user_namespace(page.get("path", ""), user):
raise ValueError(
f"Page {page_id} (path: {page.get('path')!r}) is outside "
f"user '{user}' namespace - refusing cross-tenant ingestion"
)
# PROTECTION: Skip entity extraction on auto-generated entity stub pages
tags = page.get("tags", [])
if "entity-stub" in tags or "auto-generated" in tags:
@@ -675,10 +683,11 @@ class GraphService:
"""
from src.core.multi_tenancy import get_neo4j_user_base_label
user_base_label = get_neo4j_user_base_label(user)
user_doc_label = get_neo4j_user_label(user)
query = f"""
MATCH (e:{user_base_label}:{entity_type} {{name: $name}})
MATCH (d:Document)-[:MENTIONS]->(e)
MATCH (d:{user_doc_label}:Document)-[:MENTIONS]->(e)
RETURN count(distinct d) as mention_count
"""
@@ -714,8 +723,8 @@ class GraphService:
entity_path = f"{user_namespace}/entities/{entity_type.lower()}/{entity_name.lower().replace(' ', '-')}"
try:
# Search for page by path
pages = await self.wiki.list_pages(limit=1000)
# Search for page by path (scoped to the user's namespace)
pages = await self.wiki.list_pages(path_prefix=user_namespace, limit=1000)
# list_pages returns a list directly, not a dict
for page in pages:
if page.get("path", "") == entity_path:
@@ -822,11 +831,12 @@ Feel free to expand it with more details!
try:
from src.core.multi_tenancy import get_neo4j_user_base_label
user_base_label = get_neo4j_user_base_label(user)
user_doc_label = get_neo4j_user_label(user)
# Get mentioning documents
# Get mentioning documents (scoped to this tenant's documents)
mention_query = f"""
MATCH (e:{user_base_label}:{entity_type} {{name: $name}})
MATCH (d:Document)-[:MENTIONS]->(e)
MATCH (d:{user_doc_label}:Document)-[:MENTIONS]->(e)
RETURN d.title as title, d.path as path, d.page_id as page_id
"""
@@ -844,7 +854,7 @@ Feel free to expand it with more details!
# Only match entity nodes (not Document nodes)
related_query = f"""
MATCH (e1:{user_base_label}:{entity_type} {{name: $name}})
MATCH (d:Document)-[:MENTIONS]->(e1)
MATCH (d:{user_doc_label}:Document)-[:MENTIONS]->(e1)
MATCH (d)-[:MENTIONS]->(e2:{user_base_label})
WHERE e2 <> e1 AND NOT (e2:Document)
RETURN DISTINCT e2.name as name, labels(e2) as labels,
@@ -934,15 +944,16 @@ Feel free to expand it with more details!
from src.core.multi_tenancy import get_neo4j_user_base_label
user_base_label = get_neo4j_user_base_label(user)
user_doc_label = get_neo4j_user_label(user)
pages_created = []
pages_skipped = []
try:
# Query for entities with sufficient mentions
# Query for entities with sufficient mentions (tenant-scoped)
for entity_type in entity_types:
query = f"""
MATCH (e:{user_base_label}:{entity_type})
MATCH (d:Document)-[:MENTIONS]->(e)
MATCH (d:{user_doc_label}:Document)-[:MENTIONS]->(e)
WITH e, count(distinct d) as mention_count
WHERE mention_count >= $min_mentions
RETURN e.name as name, mention_count
@@ -1427,12 +1438,13 @@ Feel free to expand it with more details!
from src.core.multi_tenancy import get_neo4j_user_base_label
user_base_label = get_neo4j_user_base_label(user)
user_doc_label = get_neo4j_user_label(user)
query = f"""
MATCH (e:{user_base_label})
WHERE NOT e:Document
AND NOT e:DocumentCollection
AND NOT EXISTS {{ (d:Document)-[:MENTIONS]->(e) }}
AND NOT EXISTS {{ (d:{user_doc_label}:Document)-[:MENTIONS]->(e) }}
RETURN elementId(e) as id, e.name as name, labels(e) as labels
"""
@@ -1475,12 +1487,13 @@ Feel free to expand it with more details!
from src.core.multi_tenancy import get_neo4j_user_base_label
user_base_label = get_neo4j_user_base_label(user)
user_doc_label = get_neo4j_user_label(user)
query = f"""
MATCH (e:{user_base_label})
WHERE NOT e:Document
AND NOT e:DocumentCollection
AND NOT EXISTS {{ (d:Document)-[:MENTIONS]->(e) }}
AND NOT EXISTS {{ (d:{user_doc_label}:Document)-[:MENTIONS]->(e) }}
DETACH DELETE e
RETURN count(e) as purged_count
"""
@@ -1608,9 +1621,13 @@ Feel free to expand it with more details!
Returns:
Number of relationships cleaned
"""
query = """
MATCH (sq:SearchQuery)-[r:FOUND]->(d)
WHERE NOT EXISTS { (d) }
from src.core.multi_tenancy import get_neo4j_user_base_label
user_base_label = get_neo4j_user_base_label(user)
query = f"""
MATCH (sq:{user_base_label}_SearchQuery:SearchQuery)-[r:FOUND]->(d)
WHERE NOT EXISTS {{ (d) }}
DELETE r
RETURN count(r) as cleaned_count
"""
+6 -1
View File
@@ -916,6 +916,7 @@ Ranking:"""
"""
try:
user_base_label = get_neo4j_user_base_label(user)
user_doc_label = get_neo4j_user_label(user)
search_id = str(uuid.uuid4())
# Create SearchQuery node
@@ -956,9 +957,13 @@ Ranking:"""
page_id = result.get("page_id")
if page_id:
# TENANT ISOLATION: only link to this tenant's Document
# nodes. An unscoped (d:Document {page_id}) match would
# attach FOUND relationships to other tenants' documents
# that share the same Wiki.js page id.
link_doc_query = f"""
MATCH (sq:{user_base_label}_SearchQuery:SearchQuery {{id: $search_id}})
MATCH (d:Document {{page_id: $page_id}})
MATCH (d:{user_doc_label}:Document {{page_id: $page_id}})
MERGE (sq)-[f:FOUND]->(d)
SET f.source = $source,
f.rank = $rank,
+24 -4
View File
@@ -386,12 +386,32 @@ class IngestionService:
Returns:
BatchIngestionResult
"""
logger.info(f"Finding all pages for user {user} (prefix: {path_prefix or 'all'})")
from src.core.multi_tenancy import sanitize_user_id
# TENANT ISOLATION: the listing prefix is clamped to the user's own
# wiki namespace. A caller-supplied prefix outside users/{user}/
# would otherwise ingest another tenant's pages into this tenant's
# collection and graph labels.
if path_prefix:
parts = path_prefix.strip("/").split("/")
if (
len(parts) < 2
or parts[0] != "users"
or sanitize_user_id(parts[1]) != sanitize_user_id(user)
):
raise ValueError(
f"path_prefix {path_prefix!r} is outside user '{user}' "
f"namespace (users/{sanitize_user_id(user)}/) - refusing "
"cross-tenant ingestion"
)
effective_prefix = path_prefix.strip("/")
else:
effective_prefix = f"users/{sanitize_user_id(user)}"
logger.info(f"Finding all pages for user {user} (prefix: {effective_prefix})")
# List all pages (not search - search requires a query and may have stale index)
pages = await self.wiki.list_all_pages(
path_prefix=path_prefix or f"users/{user}"
)
pages = await self.wiki.list_all_pages(path_prefix=effective_prefix)
if not pages:
logger.warning(f"No pages found for user {user}")
+11 -1
View File
@@ -14,7 +14,7 @@ import logging
from src.clients.qdrant_client import QdrantClientWrapper
from src.clients.wikijs_client import WikiJSClient
from src.clients.ollama_client import OllamaClient
from src.core.multi_tenancy import get_qdrant_collection_name
from src.core.multi_tenancy import get_qdrant_collection_name, is_path_in_user_namespace
from src.models.vector import (
SearchResult, SearchResponse, VectorUpdateSummary,
DocumentChunk, CollectionInfo, CollectionListResponse
@@ -120,6 +120,16 @@ class VectorService:
if not page:
raise ValueError(f"Page {page_id} not found")
# TENANT ISOLATION: only pages inside the user's own wiki
# namespace may be embedded into that user's collection.
# Without this check any tenant could ingest (and then read)
# another tenant's wiki content.
if not is_path_in_user_namespace(page.get("path", ""), user):
raise ValueError(
f"Page {page_id} (path: {page.get('path')!r}) is outside "
f"user '{user}' namespace - refusing cross-tenant ingestion"
)
# Get collection name for user
collection_name = get_qdrant_collection_name(user)
+9 -2
View File
@@ -58,8 +58,15 @@ class VolatileCacheService:
logger.info("Initialized VolatileCacheService (Qdrant backend)")
def _collection_name(self, user: str) -> str:
"""Get volatile collection name for user."""
return f"{self.COLLECTION_PREFIX}{user}"
"""
Get volatile collection name for user.
The user id is sanitized (same rules as the document collections)
so raw identifiers cannot alias or escape the per-tenant
collection naming scheme.
"""
from src.core.multi_tenancy import sanitize_user_id
return f"{self.COLLECTION_PREFIX}{sanitize_user_id(user)}"
def _make_vector_id(self, namespace: str, key: str) -> str:
"""
+412
View File
@@ -0,0 +1,412 @@
"""
Offline tenant-isolation unit tests for every HybridRAG leg (mocked clients).
For each retrieval leg (vector, graph, volatile, documents) plus the
enrichment and persistence phases, assert that the tenant-scoped
collection / label / path is used and that cross-tenant access is refused.
Live probes showed /query/hybrid as user=llm_tester returning jpmschweitzer
pages: the root cause was unscoped ingestion (any tenant could ingest any
page id / any path prefix into its own collection) and an unscoped
Document match in search persistence. These tests pin the fixes.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.models.hybrid_rag import HybridRAGConfig
from src.services.graph_service import GraphService
from src.services.hybrid_rag_service import HybridRAGService
from src.services.ingestion_service import IngestionService
from src.services.vector_service import VectorService
from src.services.volatile_service import VolatileCacheService
TENANT = "llm_tester"
TENANT_COLLECTION = "library_desk_llm_tester"
TENANT_BASE_LABEL = "User_Llm_Tester"
TENANT_DOC_LABEL = "User_Llm_Tester_Document"
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def mock_qdrant():
qdrant = MagicMock()
qdrant.collection_exists = AsyncMock(return_value=True)
qdrant.ensure_collection = AsyncMock()
qdrant.search_vectors = AsyncMock(return_value=[])
qdrant.search_with_expiry_filter = AsyncMock(return_value=[])
qdrant.upsert_vector = AsyncMock(return_value=True)
qdrant.delete_by_filter = AsyncMock(return_value=0)
qdrant.client = MagicMock()
qdrant.client.search = MagicMock(return_value=[])
return qdrant
@pytest.fixture
def mock_ollama():
ollama = MagicMock()
ollama.embed = AsyncMock(return_value=[0.1] * 768)
ollama.embed_text = AsyncMock(return_value=[0.1] * 768)
ollama.generate_text = AsyncMock(return_value="{}")
return ollama
@pytest.fixture
def mock_neo4j():
neo4j = MagicMock()
neo4j.execute_query = AsyncMock(return_value=[])
neo4j.execute_read = AsyncMock(return_value=[])
return neo4j
@pytest.fixture
def mock_wiki():
wiki = MagicMock()
wiki.get_page = AsyncMock(return_value=None)
wiki.list_all_pages = AsyncMock(return_value=[])
wiki.list_pages = AsyncMock(return_value=[])
return wiki
@pytest.fixture
def vector_service(mock_qdrant, mock_wiki, mock_ollama):
return VectorService(mock_qdrant, mock_wiki, mock_ollama)
@pytest.fixture
def graph_service(mock_neo4j, mock_wiki):
return GraphService(mock_neo4j, mock_wiki)
@pytest.fixture
def volatile_service(mock_qdrant, mock_ollama):
return VolatileCacheService(mock_qdrant, mock_ollama, MagicMock())
@pytest.fixture
def hybrid_service(vector_service, graph_service, volatile_service, mock_ollama):
settings = MagicMock()
settings.ollama_llm_model = "test-model"
settings.vector_similarity_threshold = 0.7
return HybridRAGService(
vector_service=vector_service,
graph_service=graph_service,
searxng_client=MagicMock(),
ollama_client=mock_ollama,
content_extractor=MagicMock(),
settings=settings,
volatile_service=volatile_service,
)
def _all_cypher(mock_neo4j) -> str:
"""Concatenate all Cypher sent to the mocked Neo4j client."""
return "\n".join(
str(call.args[0]) for call in mock_neo4j.execute_query.await_args_list
)
# =============================================================================
# Vector leg
# =============================================================================
@pytest.mark.unit
class TestVectorLegScoping:
async def test_search_uses_tenant_collection(self, vector_service, mock_qdrant):
await vector_service.search(query="q", user=TENANT)
mock_qdrant.collection_exists.assert_awaited_with(TENANT_COLLECTION)
assert (
mock_qdrant.search_vectors.await_args.kwargs["collection_name"]
== TENANT_COLLECTION
)
async def test_delete_page_chunks_uses_tenant_collection(
self, vector_service, mock_qdrant
):
await vector_service.delete_page_chunks(page_id=1, user=TENANT)
assert (
mock_qdrant.delete_by_filter.await_args.kwargs["collection_name"]
== TENANT_COLLECTION
)
async def test_update_from_page_rejects_foreign_namespace(
self, vector_service, mock_qdrant, mock_wiki
):
"""Ingesting another tenant's page into our collection must fail."""
mock_wiki.get_page = AsyncMock(return_value={
"id": 42, "title": "T", "path": "users/jpmschweitzer/secret",
"content": "secret content", "tags": [],
})
summary = await vector_service.update_from_page(page_id=42, user=TENANT)
assert summary.success is False
assert "outside user" in (summary.error_message or "")
mock_qdrant.upsert_vector.assert_not_awaited()
mock_qdrant.delete_by_filter.assert_not_awaited()
async def test_update_from_page_rejects_sibling_prefix_namespace(
self, vector_service, mock_qdrant, mock_wiki
):
"""users/llm_tester2 is NOT inside llm_tester's namespace."""
mock_wiki.get_page = AsyncMock(return_value={
"id": 43, "title": "T", "path": "users/llm_tester2/page",
"content": "content", "tags": [],
})
summary = await vector_service.update_from_page(page_id=43, user=TENANT)
assert summary.success is False
mock_qdrant.upsert_vector.assert_not_awaited()
async def test_update_from_page_accepts_own_namespace(
self, vector_service, mock_qdrant, mock_wiki
):
mock_wiki.get_page = AsyncMock(return_value={
"id": 44, "title": "T", "path": "users/llm_tester/page",
"content": "hello world", "tags": [],
})
summary = await vector_service.update_from_page(page_id=44, user=TENANT)
assert summary.success is True
assert (
mock_qdrant.upsert_vector.await_args.kwargs["collection_name"]
== TENANT_COLLECTION
)
# =============================================================================
# Graph leg
# =============================================================================
@pytest.mark.unit
class TestGraphLegScoping:
async def test_search_documents_uses_tenant_labels(
self, graph_service, mock_neo4j
):
await graph_service.search_documents(
query="docker", user=TENANT, keywords_data={"core_keywords": ["docker"]}
)
cypher = _all_cypher(mock_neo4j)
assert f"(e:{TENANT_BASE_LABEL})" in cypher
assert f"(d:{TENANT_DOC_LABEL}:Document)" in cypher
async def test_update_from_page_rejects_foreign_namespace(
self, graph_service, mock_neo4j, mock_wiki
):
mock_wiki.get_page = AsyncMock(return_value={
"id": 42, "title": "T", "path": "users/jpmschweitzer/secret",
"content": "secret", "tags": [],
})
summary = await graph_service.update_from_page(page_id=42, user=TENANT)
assert summary.success is False
assert "outside user" in (summary.error_message or "")
mock_neo4j.execute_query.assert_not_awaited()
async def test_update_from_page_writes_tenant_labels(
self, graph_service, mock_neo4j, mock_wiki
):
mock_wiki.get_page = AsyncMock(return_value={
"id": 44, "title": "T", "path": "users/llm_tester/page",
"content": "Uses Docker daily", "tags": [],
})
summary = await graph_service.update_from_page(page_id=44, user=TENANT)
assert summary.success is True
cypher = _all_cypher(mock_neo4j)
assert TENANT_DOC_LABEL in cypher
# No unscoped Document writes
assert "MERGE (d:Document {" not in cypher
async def test_entity_mention_count_scoped(self, graph_service, mock_neo4j):
await graph_service._get_entity_mention_count("Docker", "Technology", TENANT)
cypher = _all_cypher(mock_neo4j)
assert f"(d:{TENANT_DOC_LABEL}:Document)" in cypher
assert "MATCH (d:Document)" not in cypher
async def test_orphan_queries_scoped(self, graph_service, mock_neo4j):
await graph_service.find_orphan_entities(TENANT)
await graph_service.purge_orphan_entities(TENANT)
cypher = _all_cypher(mock_neo4j)
assert f"(d:{TENANT_DOC_LABEL}:Document)" in cypher
assert "(d:Document)-[:MENTIONS]" not in cypher
async def test_cleanup_broken_relationships_scoped(
self, graph_service, mock_neo4j
):
await graph_service.cleanup_broken_relationships(TENANT)
cypher = _all_cypher(mock_neo4j)
assert f"{TENANT_BASE_LABEL}_SearchQuery" in cypher
assert "MATCH (sq:SearchQuery)" not in cypher
# =============================================================================
# Volatile leg
# =============================================================================
@pytest.mark.unit
class TestVolatileLegScoping:
async def test_search_uses_tenant_collection(self, volatile_service, mock_qdrant):
await volatile_service.search(user=TENANT, query="weather")
mock_qdrant.collection_exists.assert_awaited_with("volatile_llm_tester")
assert (
mock_qdrant.search_with_expiry_filter.await_args.kwargs["collection_name"]
== "volatile_llm_tester"
)
async def test_store_uses_tenant_collection(self, volatile_service, mock_qdrant):
await volatile_service.store(
user=TENANT, namespace="weather", key="rotterdam", data={"t": 1}
)
assert (
mock_qdrant.upsert_vector.await_args.kwargs["collection_name"]
== "volatile_llm_tester"
)
def test_collection_name_is_sanitized(self, volatile_service):
"""Raw user strings cannot alias/escape the collection scheme."""
assert volatile_service._collection_name("llm-tester") == "volatile_llm_tester"
assert volatile_service._collection_name("Evil User!") == "volatile_evil_user"
# =============================================================================
# Documents leg (Paperless chunks in the tenant collection)
# =============================================================================
@pytest.mark.unit
class TestDocumentLegScoping:
async def test_document_search_uses_tenant_collection(
self, hybrid_service, mock_qdrant
):
config = HybridRAGConfig(
enable_vector=False, enable_graph=False, enable_web=False,
enable_volatile=False, enable_documents=True,
)
await hybrid_service._retrieve_parallel("q", TENANT, config, {})
assert (
mock_qdrant.client.search.call_args.kwargs["collection_name"]
== TENANT_COLLECTION
)
# =============================================================================
# Enrichment phase
# =============================================================================
@pytest.mark.unit
class TestEnrichmentScoping:
async def test_related_documents_scoped_to_tenant(
self, graph_service, mock_neo4j
):
await graph_service.get_related_documents(page_id=1, user=TENANT)
cypher = _all_cypher(mock_neo4j)
assert f"(d1:{TENANT_DOC_LABEL}:Document" in cypher
assert f"(d2:{TENANT_DOC_LABEL}:Document)" in cypher
# =============================================================================
# Persistence phase (SearchQuery / FOUND / WebResult)
# =============================================================================
@pytest.mark.unit
class TestPersistenceScoping:
async def test_persist_scopes_searchquery_links_and_webresults(
self, hybrid_service, mock_neo4j
):
final_results = [
{
"result": {"page_id": 42, "title": "wiki hit"},
"source_type": "wiki", "sources": ["vector"],
"rrf_score": 0.5, "final_rank": 1,
},
{
"result": {"url": "http://example.com", "title": "web hit",
"content": "c"},
"source_type": "web", "sources": ["web"],
"rrf_score": 0.4, "final_rank": 2,
},
]
await hybrid_service._persist_search_for_librarian(
query="q", user=TENANT, keywords_data={},
raw_results={}, final_results=final_results, timing={},
)
cypher = _all_cypher(mock_neo4j)
# SearchQuery + WebResult nodes carry the tenant label
assert f"{TENANT_BASE_LABEL}_SearchQuery" in cypher
assert f"{TENANT_BASE_LABEL}_WebResult" in cypher
# FOUND link matches only this tenant's Document nodes
assert f"MATCH (d:{TENANT_DOC_LABEL}:Document {{page_id: $page_id}})" in cypher
# The old cross-tenant match must be gone
assert "MATCH (d:Document {page_id: $page_id})" not in cypher
# =============================================================================
# Ingestion (feeds the vector/graph legs)
# =============================================================================
@pytest.mark.unit
class TestIngestAllPagesScoping:
@pytest.fixture
def ingestion_service(self, vector_service, graph_service, mock_wiki):
return IngestionService(vector_service, graph_service, mock_wiki)
async def test_defaults_to_own_namespace(self, ingestion_service, mock_wiki):
await ingestion_service.ingest_all_pages(user=TENANT)
mock_wiki.list_all_pages.assert_awaited_once_with(
path_prefix="users/llm_tester"
)
async def test_rejects_foreign_prefix(self, ingestion_service, mock_wiki):
with pytest.raises(ValueError, match="outside user"):
await ingestion_service.ingest_all_pages(
user=TENANT, path_prefix="users/jpmschweitzer"
)
mock_wiki.list_all_pages.assert_not_awaited()
async def test_rejects_sibling_prefix(self, ingestion_service, mock_wiki):
with pytest.raises(ValueError, match="outside user"):
await ingestion_service.ingest_all_pages(
user=TENANT, path_prefix="users/llm_tester2"
)
async def test_accepts_subtree_of_own_namespace(
self, ingestion_service, mock_wiki
):
await ingestion_service.ingest_all_pages(
user=TENANT, path_prefix="users/llm_tester/tech"
)
mock_wiki.list_all_pages.assert_awaited_once_with(
path_prefix="users/llm_tester/tech"
)