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:
Chris Coutinho
2026-05-28 23:05:40 +02:00
co-authored by Claude Opus 4.8
parent dc0653c415
commit bf35200bab
8 changed files with 586 additions and 920 deletions
+43
View File
@@ -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.