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