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
+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:
"""