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:
@@ -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
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user