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