fix: address PR #813 review round 4 (log leak, cross-user chunk ctx, algo, overlap)
1. Don't log unverified result titles: both search algorithms logged top-5 titles at DEBUG before verify-on-read; with owner-level share expansion the unverified set can contain other users' docs. Algorithms now log a count only; the verifying callers (server/semantic, viz_routes, api/visualization) log verified titles after verify-on-read. 2. Cross-user FILE chunk context: get_chunk_with_context + the Qdrant chunk helpers now take accessible_owners and use build_ownership_filter. For files the expanded scope is honoured only after a per-file file_accessible_by_id check (accessible_owners is owner-level, so the gate prevents a one-file share recipient from reading any of the owner's cached chunks). note/deck/ news stay self-only (per-user APIs) — a documented gap. Both chunk endpoints pass accessible_owners. 3. Algorithm usage: SemanticSearchAlgorithm is not dead (it backs the dense-only option on the viz/API surfaces); added a clarifying comment in server/ semantic.py. Additionally wired accessible_owners + verify-on-read into the /api/v1 search routes (unified_search, vector_search) so the astrolabe surface is ACL-aware too — degrading gracefully to self-only/unverified for non-provisioned callers instead of 401. 4. Overlapping conditions: build_ownership_filter no longer lists self in the owner_id MatchAny branch (self is already covered by the user_id branch); the owner_id branch carries only the OTHER owners. Tests: build_ownership_filter dedup + chunk-bbox filter-shape updates; new ACL-aware get_indexed_doc_types, cached-chunk lookup, and end-to-end cross-user file chunk-context (recipient gets the chunk, non-recipient denied) tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
ac041d5c97
commit
8deb48e6fa
@@ -7,10 +7,12 @@ position markers for better visualization and understanding of search results.
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.search.access_filter import build_ownership_filter
|
||||
from nextcloud_mcp_server.utils.validation import is_valid_nextcloud_doc_id
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
@@ -20,7 +22,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_chunk_from_qdrant(
|
||||
user_id: str, doc_id: str, doc_type: str, chunk_start: int, chunk_end: int
|
||||
user_id: str,
|
||||
doc_id: str,
|
||||
doc_type: str,
|
||||
chunk_start: int,
|
||||
chunk_end: int,
|
||||
accessible_owners: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""Retrieve full chunk text from Qdrant payload.
|
||||
|
||||
@@ -28,11 +35,15 @@ async def _get_chunk_from_qdrant(
|
||||
chunk content already stored in Qdrant.
|
||||
|
||||
Args:
|
||||
user_id: User ID who owns the document
|
||||
user_id: Querying user.
|
||||
doc_id: Document ID
|
||||
doc_type: Document type (e.g., "note", "file")
|
||||
chunk_start: Character offset where chunk starts
|
||||
chunk_end: Character offset where chunk ends
|
||||
accessible_owners: Owner UIDs the caller may read (self + share senders).
|
||||
When None, the lookup is self-only. Callers must only pass an
|
||||
expanded set after confirming the caller can access the document
|
||||
(see ``get_chunk_with_context``) — the filter is owner-level.
|
||||
|
||||
Returns:
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
@@ -46,7 +57,7 @@ async def _get_chunk_from_qdrant(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
build_ownership_filter(user_id, accessible_owners),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
FieldCondition(
|
||||
@@ -93,17 +104,24 @@ async def _get_chunk_from_qdrant(
|
||||
|
||||
|
||||
async def _get_chunk_by_index_from_qdrant(
|
||||
user_id: str, doc_id: str, doc_type: str, chunk_index: int
|
||||
user_id: str,
|
||||
doc_id: str,
|
||||
doc_type: str,
|
||||
chunk_index: int,
|
||||
accessible_owners: list[str] | None = None,
|
||||
) -> str | None:
|
||||
"""Retrieve chunk text by chunk_index from Qdrant payload.
|
||||
|
||||
Used to fetch adjacent chunks for context expansion.
|
||||
|
||||
Args:
|
||||
user_id: User ID who owns the document
|
||||
user_id: Querying user.
|
||||
doc_id: Document ID
|
||||
doc_type: Document type (e.g., "note", "file")
|
||||
chunk_index: Zero-based chunk index in document
|
||||
accessible_owners: Owner UIDs the caller may read; None ⇒ self-only.
|
||||
Only pass an expanded set after a per-document access check (see
|
||||
``get_chunk_with_context``).
|
||||
|
||||
Returns:
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
@@ -117,7 +135,7 @@ async def _get_chunk_by_index_from_qdrant(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
build_ownership_filter(user_id, accessible_owners),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
FieldCondition(
|
||||
@@ -217,6 +235,7 @@ async def get_chunk_bbox_and_page_from_qdrant(
|
||||
chunk_index: int | None,
|
||||
chunk_start: int,
|
||||
chunk_end: int,
|
||||
accessible_owners: list[str] | None = None,
|
||||
) -> tuple[list | None, int | None]:
|
||||
"""Fetch chunk_bbox and page_number for a chunk from Qdrant payload.
|
||||
|
||||
@@ -256,7 +275,7 @@ async def get_chunk_bbox_and_page_from_qdrant(
|
||||
must=[
|
||||
get_placeholder_filter(),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
build_ownership_filter(user_id, accessible_owners),
|
||||
FieldCondition(
|
||||
key="chunk_index", match=MatchValue(value=chunk_index)
|
||||
),
|
||||
@@ -273,7 +292,7 @@ async def get_chunk_bbox_and_page_from_qdrant(
|
||||
must=[
|
||||
get_placeholder_filter(),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
build_ownership_filter(user_id, accessible_owners),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset",
|
||||
match=MatchValue(value=chunk_start),
|
||||
@@ -352,6 +371,7 @@ async def get_chunk_with_context(
|
||||
chunk_index: int | None = None,
|
||||
total_chunks: int = 1,
|
||||
context_chars: int = 300,
|
||||
accessible_owners: list[str] | None = None,
|
||||
) -> ChunkContext | None:
|
||||
"""Fetch chunk with surrounding context.
|
||||
|
||||
@@ -361,7 +381,7 @@ async def get_chunk_with_context(
|
||||
|
||||
Args:
|
||||
nc_client: Authenticated Nextcloud client
|
||||
user_id: User ID who owns the document
|
||||
user_id: Querying user.
|
||||
doc_id: Document ID (str — keyword-indexed in Qdrant payload)
|
||||
doc_type: Type of document ("note", "file", etc.)
|
||||
chunk_start: Character offset where chunk starts
|
||||
@@ -372,6 +392,10 @@ async def get_chunk_with_context(
|
||||
field). When None, falls back to the (chunk_start, chunk_end) lookup.
|
||||
total_chunks: Total number of chunks in document
|
||||
context_chars: Number of characters to include before/after chunk
|
||||
accessible_owners: Owner UIDs the caller may read (self + share senders).
|
||||
Used to support cross-user context for SHARED FILES only, and only
|
||||
after a per-file access check (see ``lookup_owners`` below). For
|
||||
non-file types the lookup stays self-only.
|
||||
|
||||
Returns:
|
||||
ChunkContext with expanded context and markers, or None if document
|
||||
@@ -380,13 +404,53 @@ async def get_chunk_with_context(
|
||||
# doc_id is keyword-indexed in Qdrant as str — pass through verbatim
|
||||
# (no int coercion; producers always stringify on write).
|
||||
|
||||
# Determine the ownership scope for the Qdrant cached-chunk lookups.
|
||||
#
|
||||
# ``accessible_owners`` is OWNER-level (every owner who shared anything with
|
||||
# the caller), so widening the lookup to it unconditionally would let a
|
||||
# recipient of a single shared file read ANY of that owner's cached chunks
|
||||
# by guessing doc_ids. We therefore honour it only for FILES, and only after
|
||||
# confirming the caller can access THIS file by id (``file_accessible_by_id``
|
||||
# is cross-user-safe: a WebDAV SEARCH over the caller's whole tree incl.
|
||||
# mounted shares). For per-user types (note/deck/news) there is no
|
||||
# share-mounted by-id access via the caller's credentials, so the lookup
|
||||
# stays self-only — cross-user context for those types is a known gap.
|
||||
lookup_owners: list[str] | None = None # None ⇒ self-only
|
||||
if doc_type == "file" and accessible_owners:
|
||||
try:
|
||||
if await nc_client.webdav.file_accessible_by_id(int(doc_id)):
|
||||
lookup_owners = accessible_owners
|
||||
else:
|
||||
# Not owned and not shared with the caller → no access. Return
|
||||
# early rather than falling back to a self-only lookup that
|
||||
# would also miss (and so the result is the same None, but this
|
||||
# is explicit and skips a pointless Qdrant round-trip).
|
||||
logger.debug(
|
||||
"File %s not accessible to %s; no cross-user chunk context",
|
||||
doc_id,
|
||||
user_id,
|
||||
)
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
# Non-numeric doc_id: shouldn't happen (endpoints validate), but
|
||||
# degrade to self-only rather than raising.
|
||||
logger.warning("Non-numeric file doc_id %r; using self-only scope", doc_id)
|
||||
except HTTPStatusError as exc:
|
||||
# Transient transport/server error — treat as inconclusive and fall
|
||||
# back to self-only so the caller's own files still resolve.
|
||||
logger.warning(
|
||||
"file_accessible_by_id(%s) failed (%s); using self-only scope",
|
||||
doc_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Try to get chunk from Qdrant (fast path).
|
||||
# Prefer chunk_index lookup (always-indexed field) when caller supplied it;
|
||||
# fall back to (chunk_start, chunk_end) lookup otherwise.
|
||||
chunk_text: str | None = None
|
||||
if chunk_index is not None:
|
||||
chunk_text = await _get_chunk_by_index_from_qdrant(
|
||||
user_id, doc_id, doc_type, chunk_index
|
||||
user_id, doc_id, doc_type, chunk_index, accessible_owners=lookup_owners
|
||||
)
|
||||
# When chunk_index is supplied, the indexed lookup is canonical: both the
|
||||
# index path and the offset path query the same Qdrant collection, so an
|
||||
@@ -398,7 +462,12 @@ async def get_chunk_with_context(
|
||||
skip_offset_lookup = chunk_index is not None
|
||||
if chunk_text is None and not skip_offset_lookup:
|
||||
chunk_text = await _get_chunk_from_qdrant(
|
||||
user_id, doc_id, doc_type, chunk_start, chunk_end
|
||||
user_id,
|
||||
doc_id,
|
||||
doc_type,
|
||||
chunk_start,
|
||||
chunk_end,
|
||||
accessible_owners=lookup_owners,
|
||||
)
|
||||
|
||||
if chunk_text:
|
||||
@@ -422,7 +491,11 @@ async def get_chunk_with_context(
|
||||
# Fetch previous chunk if not first chunk
|
||||
if chunk_index > 0:
|
||||
before_chunk = await _get_chunk_by_index_from_qdrant(
|
||||
user_id, doc_id, doc_type, chunk_index - 1
|
||||
user_id,
|
||||
doc_id,
|
||||
doc_type,
|
||||
chunk_index - 1,
|
||||
accessible_owners=lookup_owners,
|
||||
)
|
||||
if before_chunk:
|
||||
# Remove overlap: the last chunk_overlap chars of previous chunk
|
||||
@@ -443,7 +516,11 @@ async def get_chunk_with_context(
|
||||
# Fetch next chunk if not last chunk
|
||||
if chunk_index < total_chunks - 1:
|
||||
after_chunk = await _get_chunk_by_index_from_qdrant(
|
||||
user_id, doc_id, doc_type, chunk_index + 1
|
||||
user_id,
|
||||
doc_id,
|
||||
doc_type,
|
||||
chunk_index + 1,
|
||||
accessible_owners=lookup_owners,
|
||||
)
|
||||
if after_chunk:
|
||||
# Remove overlap: the first chunk_overlap chars of next chunk
|
||||
|
||||
Reference in New Issue
Block a user