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