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:
Chris Coutinho
2026-05-29 16:55:00 +02:00
co-authored by Claude Opus 4.8
parent ac041d5c97
commit 8deb48e6fa
11 changed files with 393 additions and 87 deletions
@@ -24,6 +24,7 @@ from qdrant_client.models import Distance, PointStruct, VectorParams
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import SimpleEmbeddingProvider
from nextcloud_mcp_server.search.algorithms import get_indexed_doc_types
from nextcloud_mcp_server.search.context import _get_chunk_by_index_from_qdrant
from nextcloud_mcp_server.search.semantic import SemanticSearchAlgorithm
pytestmark = pytest.mark.integration
@@ -98,6 +99,11 @@ async def seeded_collection(monkeypatch):
"nextcloud_mcp_server.search.algorithms.get_qdrant_client",
AsyncMock(return_value=client),
)
# The cached-chunk lookups read the client from the context module.
monkeypatch.setattr(
"nextcloud_mcp_server.search.context.get_qdrant_client",
AsyncMock(return_value=client),
)
yield provider
@@ -189,3 +195,18 @@ async def test_get_indexed_doc_types_is_acl_aware(seeded_collection):
}
# Self-only (default): Bob owns nothing here → discovers nothing.
assert await get_indexed_doc_types("bob") == set()
async def test_cached_chunk_lookup_is_acl_aware(seeded_collection):
"""The cached-chunk Qdrant lookup honours accessible_owners: Bob retrieves
the excerpt of Alice's file point (owner_id=alice, chunk_index=0) when alice
is in his accessible owners, but not when scoped self-only. This is the
Qdrant-layer half of cross-user file chunk context (the per-file access
gate lives in get_chunk_with_context / file_accessible_by_id)."""
# Alice's seeded file point (_ALICE_FILE) carries excerpt=_DOC_TEXT at chunk 0.
text = await _get_chunk_by_index_from_qdrant(
"bob", "101", "file", 0, accessible_owners=["bob", "alice"]
)
assert text == _DOC_TEXT
# Self-only Bob cannot reach Alice's cached chunk.
assert await _get_chunk_by_index_from_qdrant("bob", "101", "file", 0) is None
@@ -37,6 +37,7 @@ from nextcloud_mcp_server.search.access_filter import (
clear_accessible_owners_cache,
list_accessible_owners,
)
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.search.semantic import SemanticSearchAlgorithm
from nextcloud_mcp_server.search.verification import verify_search_results
@@ -149,6 +150,12 @@ async def seeded_semantic(monkeypatch, shared_file):
"nextcloud_mcp_server.search.semantic.get_embedding_service",
lambda: provider,
)
# The cached-chunk lookups (get_chunk_with_context) read the client from the
# context module — point it at the same in-memory Qdrant.
monkeypatch.setattr(
"nextcloud_mcp_server.search.context.get_qdrant_client",
AsyncMock(return_value=client),
)
yield file_id
await client.close()
@@ -215,3 +222,50 @@ async def test_file_accessible_by_id_resolves_shares(acl_users, shared_file):
assert await acl_users["bob"].webdav.file_accessible_by_id(fid) is True
# ...the non-recipient cannot.
assert await acl_users["diana"].webdav.file_accessible_by_id(fid) is False
async def test_cross_user_file_chunk_context(acl_users, seeded_semantic):
"""End-to-end cross-user FILE chunk context: Bob (a share recipient) gets
Alice's cached chunk text, Diana (no share) gets None.
Exercises the full secure path: the ACL-aware Qdrant cached-chunk lookup
(owner_id=alice surfaces for Bob) gated by a real per-file
``file_accessible_by_id`` check against live Nextcloud. Diana fails the gate
and is denied even though the chunk is cached. Per-user types are covered by
the self-only behaviour elsewhere — this is the file path the feature adds.
"""
file_id = seeded_semantic
bob = acl_users["bob"]
diana = acl_users["diana"]
bob_owners = await list_accessible_owners(bob.sharing, "bob")
assert "alice" in bob_owners
ctx = await get_chunk_with_context(
nc_client=bob,
user_id="bob",
doc_id=str(file_id),
doc_type="file",
chunk_start=0,
chunk_end=len(_DOC_TEXT),
chunk_index=0,
total_chunks=1,
accessible_owners=bob_owners,
)
assert ctx is not None, "Bob (share recipient) must get Alice's cached chunk"
assert ctx.chunk_text == _DOC_TEXT
# Diana has no share → per-file gate denies even though the chunk is cached.
diana_owners = await list_accessible_owners(diana.sharing, "diana")
denied = await get_chunk_with_context(
nc_client=diana,
user_id="diana",
doc_id=str(file_id),
doc_type="file",
chunk_start=0,
chunk_end=len(_DOC_TEXT),
chunk_index=0,
total_chunks=1,
accessible_owners=diana_owners,
)
assert denied is None, "Diana (no share) must not get cross-user chunk context"