fix(search): verify shared files by global file id (ACL-aware)
The ACL-aware vector filter (PR #813) expands a user's search to documents whose owner shared them, but verify-on-read still re-checked each file by PATH under the *searching* user's WebDAV root. Nextcloud mounts received shares at the recipient's root by basename, so a nested shared file (e.g. owner's /docs/report.pdf) 404s for the recipient and was silently dropped — defeating the filter for everything but root-level files. Verify files by their global Nextcloud file id instead (the file doc_id IS that id): WebDAVClient.get_file_info_by_id was insufficient (the dav/meta endpoint only resolves the user's own storage, not shares), so add WebDAVClient.file_accessible_by_id which runs a WebDAV SEARCH over the user's whole tree (incl. mounted shares) filtered on oc:fileid. Empirically this resolves owned, directly-shared, and folder-shared files; an empty result is a definitive drop, transport errors are kept as transient. - search/verification.py: _verify_files now checks file_accessible_by_id. - client/webdav.py: add file_accessible_by_id (SEARCH by fileid). - tests/integration/test_acl_owner_filter.py: filter matrix vs real Qdrant. - tests/integration/test_acl_shared_search.py: real-Nextcloud share -> search. - tests/integration/test_verify_on_read.py: nested shared file kept for the recipient; unshared file dropped. - tests/unit/search/test_verification.py: id-based verifier semantics. 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
dc0653c415
commit
bf35200bab
@@ -1073,6 +1073,49 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def file_accessible_by_id(self, file_id: int) -> bool:
|
||||
"""ACL-aware access check for a file by its global Nextcloud file ID.
|
||||
|
||||
Used by verify-on-read (ADR-019). Searches the authenticated user's
|
||||
whole files tree — which *includes mounted shares* — via WebDAV SEARCH
|
||||
(RFC 5323) filtered on ``oc:fileid``, returning True iff the user can
|
||||
currently access the file.
|
||||
|
||||
This is the only check that resolves shared files correctly:
|
||||
|
||||
- :meth:`get_file_info` resolves a path under the caller's *own* root,
|
||||
so it 404s on a file shared into the caller's account (Nextcloud
|
||||
mounts received shares at the recipient's root by basename, a
|
||||
different path than the owner indexed).
|
||||
- The ``/remote.php/dav/meta/{id}/`` endpoint resolves only the user's
|
||||
*own* storage, so it 404s on shared files too.
|
||||
|
||||
SEARCH-by-fileid handles all cases: owned files, directly-shared files,
|
||||
and files reachable via a shared parent folder (verified empirically).
|
||||
|
||||
Args:
|
||||
file_id: Nextcloud internal (global) file ID.
|
||||
|
||||
Returns:
|
||||
True if the user can access the file, False if it is not present
|
||||
in their tree (not owned and not shared with them).
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: On transport/server errors — callers treat these
|
||||
as transient (keep the result), not as a definitive denial.
|
||||
"""
|
||||
where = (
|
||||
"<d:eq><d:prop><oc:fileid/></d:prop>"
|
||||
f"<d:literal>{int(file_id)}</d:literal></d:eq>"
|
||||
)
|
||||
results = await self.search_files(
|
||||
scope="", # user's whole files tree, incl. mounted shares
|
||||
where_conditions=where,
|
||||
properties=["fileid"],
|
||||
limit=1,
|
||||
)
|
||||
return len(results) > 0
|
||||
|
||||
async def _get_file_info_by_id(self, file_id: int) -> Dict[str, Any]:
|
||||
"""Get file information by Nextcloud file ID using WebDAV.
|
||||
|
||||
|
||||
@@ -138,39 +138,39 @@ async def _verify_files(
|
||||
async def check(result: SearchResult) -> None:
|
||||
doc_id = result.id
|
||||
# file_path is propagated from the Qdrant payload by the algorithm
|
||||
# layer (bm25_hybrid.py / semantic.py). No extra Qdrant round-trip.
|
||||
# layer (bm25_hybrid.py / semantic.py); kept here only for log context.
|
||||
file_path = (result.metadata or {}).get("path")
|
||||
if not file_path:
|
||||
# Cannot verify without a path; treat as accessible to avoid
|
||||
# silently dropping legitimate results when payload is missing
|
||||
# (legacy data, or a future doc_type that doesn't propagate path).
|
||||
|
||||
# Verify by *global* file ID via an ACL-aware WebDAV SEARCH, NOT by
|
||||
# path. For files the vector ``doc_id`` IS the Nextcloud file ID, and
|
||||
# file_accessible_by_id searches the user's whole tree (incl. mounted
|
||||
# shares), so a file an owner shared with this user verifies as
|
||||
# accessible even though it lives at a different path under the owner's
|
||||
# root. A path-based check (the old behaviour) would 404 on shared
|
||||
# files mounted at the recipient's root by basename and silently drop
|
||||
# legitimate ACL-aware-search results.
|
||||
#
|
||||
# Hoisted cast mirrors _verify_notes: a malformed id keeps the result
|
||||
# (fail open) with a specific log line rather than a generic
|
||||
# "unexpected error" from the catch-all below.
|
||||
try:
|
||||
file_id_int = int(doc_id)
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(
|
||||
"No file path in metadata for file_id %s; keeping result "
|
||||
"(verification skipped)",
|
||||
"Non-numeric file id %r (%s): %s; keeping result",
|
||||
doc_id,
|
||||
file_path,
|
||||
e,
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
return
|
||||
|
||||
async with semaphore:
|
||||
try:
|
||||
info = await client.webdav.get_file_info(file_path)
|
||||
if info is None:
|
||||
# Contract (see WebDAVClient.get_file_info docstring):
|
||||
# `None` means a malformed PROPFIND response — an
|
||||
# ambiguous state, not a definitive 404. Treat as
|
||||
# transient and KEEP the result rather than evicting.
|
||||
# Real 404s raise HTTPStatusError and land in the
|
||||
# _is_definitive_404_or_403 branch below.
|
||||
logger.warning(
|
||||
"Malformed PROPFIND response verifying file %s (%s); "
|
||||
"keeping result (ambiguous state, not a definitive 404)",
|
||||
doc_id,
|
||||
file_path,
|
||||
)
|
||||
if await client.webdav.file_accessible_by_id(file_id_int):
|
||||
accessible.add(doc_id)
|
||||
return
|
||||
accessible.add(doc_id)
|
||||
# else: definitively inaccessible (not owned, not shared) —
|
||||
# drop and let the caller schedule eviction.
|
||||
except HTTPStatusError as e:
|
||||
if _is_definitive_404_or_403(e):
|
||||
return
|
||||
@@ -183,6 +183,8 @@ async def _verify_files(
|
||||
)
|
||||
accessible.add(doc_id)
|
||||
except Exception as e:
|
||||
# Network blip / unexpected WebDAV error — ambiguous, not a
|
||||
# definitive denial. Keep the result; the next query re-verifies.
|
||||
logger.warning(
|
||||
"Unexpected error verifying file %s (%s): %s; keeping result",
|
||||
doc_id,
|
||||
|
||||
Reference in New Issue
Block a user