Merge remote-tracking branch 'origin/master' into feat/decomp-hook-points

# Conflicts:
#	nextcloud_mcp_server/vector/scanner.py
This commit is contained in:
Chris Coutinho
2026-05-29 18:31:05 +02:00
40 changed files with 2070 additions and 1804 deletions
+5 -3
View File
@@ -30,9 +30,11 @@ from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
APP_PASSWORD_PATTERN = re.compile(
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
)
# Shape guard only — the authoritative check is the BasicAuth validation
# against Nextcloud below. Accepts both the dashed format a user copies from
# Security settings (xxxxx-xxxxx-xxxxx-xxxxx-xxxxx) and the raw token returned
# by the one-click ``core/getapppassword`` flow (a long alphanumeric string).
APP_PASSWORD_PATTERN = re.compile(r"^[a-zA-Z0-9-]{20,256}$")
# Timeout for Nextcloud API validation requests (seconds)
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
+144 -44
View File
@@ -11,6 +11,7 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
import base64
import logging
from collections.abc import Awaitable, Callable
from typing import Any
import pymupdf
@@ -30,10 +31,12 @@ from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
from nextcloud_mcp_server.search.access_filter import list_accessible_owners
from nextcloud_mcp_server.search.context import (
get_chunk_bbox_and_page_from_qdrant,
get_chunk_with_context,
)
from nextcloud_mcp_server.search.verification import verify_search_results
from nextcloud_mcp_server.utils.validation import is_valid_nextcloud_doc_id
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
@@ -43,6 +46,70 @@ from nextcloud_mcp_server.vector.visualization import compute_pca_coordinates
logger = logging.getLogger(__name__)
_NEXTCLOUD_HOST_NOT_CONFIGURED = "Nextcloud host not configured"
async def _search_with_acl(
request: Request,
user_id: str,
execute: Callable[[list[str] | None], Awaitable[list]],
) -> list:
"""Resolve the caller's Nextcloud client, run ``execute(accessible_owners)``,
and verify-on-read — shared by the /api/v1 search endpoints.
The OAuth bearer only authenticates Astrolabe → MCP Server; MCP Server →
Nextcloud uses the provisioned app password. When the caller never
provisioned background sync there is no client to expand shares or verify
with, so we fall back to self-only, unverified search (the pre-ACL
behaviour) rather than 401 — keeping search working for users who haven't
opted into background indexing.
Args:
request: The Starlette request (carries ``app.state.oauth_context``).
user_id: The authenticated caller.
execute: Coroutine that runs the search for a given owner scope
(``None`` ⇒ self-only).
Returns:
The result list (verified for provisioned callers).
Raises:
ValueError: If the Nextcloud host is not configured.
"""
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError(_NEXTCLOUD_HOST_NOT_CONFIGURED)
try:
nc_client = await get_user_client_basic_auth(user_id, nextcloud_host)
except NotProvisionedError:
logger.debug("User %s not provisioned; self-only unverified search", user_id)
results = await execute(None)
else:
async with nc_client:
# Expand to owners who shared content with the caller (same as the
# MCP tool path) so shared documents are searchable.
accessible_owners = await list_accessible_owners(nc_client.sharing, user_id)
results = await execute(accessible_owners)
# Verify-on-read (ADR-019): drop documents the caller can no longer
# access (e.g. a revoked share). Eviction runs inline — this
# Starlette route has no FastMCP lifespan task group.
results, _dropped = await verify_search_results(nc_client, results)
# Safe to log titles now: provisioned callers passed verify-on-read;
# non-provisioned ran self-only (unverified titles are never logged — see
# the search algorithms).
if results:
logger.debug(
"Top verified results: %s",
", ".join(
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
for r in results[:5]
),
)
return results
async def unified_search(request: Request) -> JSONResponse:
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
@@ -164,25 +231,41 @@ async def unified_search(request: Request) -> JSONResponse:
# Request extra results to handle offset
search_limit = limit + offset
# Execute search
all_results = []
if doc_types and isinstance(doc_types, list):
for doc_type in doc_types:
if doc_type:
results = await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
doc_type=doc_type,
)
all_results.extend(results)
all_results.sort(key=lambda r: r.score, reverse=True)
else:
all_results = await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
)
async def _execute(owners: list[str] | None) -> list:
"""Run the search across requested doc_types with the given owner
scope (None ⇒ self-only)."""
results: list = []
if doc_types and isinstance(doc_types, list):
for doc_type in doc_types:
if doc_type:
results.extend(
await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
doc_type=doc_type,
accessible_owners=owners,
)
)
# Sort, then cap to a fixed over-fetch budget before the result
# reaches verify-on-read. Without this, N doc_types each fetched
# at search_limit would send N*search_limit candidates into
# verification — one Nextcloud round-trip each — scaling the cost
# with len(doc_types). 2x leaves headroom for verify-on-read
# drops before pagination, matching the nc_semantic_search and
# viz_routes pattern.
results.sort(key=lambda r: r.score, reverse=True)
results = results[: search_limit * 2]
else:
results = await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
accessible_owners=owners,
)
return results
all_results = await _search_with_acl(request, user_id, _execute)
# Sort results by score (no deduplication - show all chunks)
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
@@ -357,29 +440,37 @@ async def vector_search(request: Request) -> JSONResponse:
score_threshold=score_threshold, fusion=fusion
)
# Execute search for each doc_type if specified, otherwise search all
all_results = []
if doc_types and isinstance(doc_types, list):
# Search each doc_type separately and merge results
for doc_type in doc_types:
if doc_type: # Skip empty strings
results = await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
doc_type=doc_type,
)
all_results.extend(results)
# Sort merged results by score and limit
all_results.sort(key=lambda r: r.score, reverse=True)
all_results = all_results[:limit]
else:
# Search all document types
all_results = await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
)
async def _execute(owners: list[str] | None) -> list:
"""Run the search across requested doc_types with the given owner
scope (None ⇒ self-only)."""
results: list = []
if doc_types and isinstance(doc_types, list):
# Search each doc_type separately and merge results
for doc_type in doc_types:
if doc_type: # Skip empty strings
results.extend(
await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
doc_type=doc_type,
accessible_owners=owners,
)
)
# Sort merged results by score and limit
results.sort(key=lambda r: r.score, reverse=True)
results = results[:limit]
else:
# Search all document types
results = await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
accessible_owners=owners,
)
return results
all_results = await _search_with_acl(request, user_id, _execute)
# Format results for PHP client
formatted_results = []
@@ -554,7 +645,7 @@ async def get_chunk_context(request: Request) -> JSONResponse:
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
raise ValueError(_NEXTCLOUD_HOST_NOT_CONFIGURED)
# Use the user's stored app password for Nextcloud calls.
# The OAuth bearer is only used to authenticate Astrolabe → MCP Server;
@@ -569,6 +660,10 @@ async def get_chunk_context(request: Request) -> JSONResponse:
)
async with nc_client:
# Expand to owners who shared content with the caller so the cached
# chunk lookup can resolve cross-user SHARED FILES (gated per-file
# inside get_chunk_with_context). Same expansion as the search path.
accessible_owners = await list_accessible_owners(nc_client.sharing, user_id)
chunk_context = await get_chunk_with_context(
nc_client=nc_client,
user_id=user_id,
@@ -579,6 +674,7 @@ async def get_chunk_context(request: Request) -> JSONResponse:
chunk_index=chunk_index,
total_chunks=total_chunks,
context_chars=context_chars,
accessible_owners=accessible_owners,
)
if chunk_context is None:
@@ -598,12 +694,16 @@ async def get_chunk_context(request: Request) -> JSONResponse:
page_number = chunk_context.page_number
if doc_type == "file":
# Reaching here means the file chunk context resolved, so access was
# already confirmed (get_chunk_with_context gates files by id);
# the bbox/page lookup uses the same owner scope for cross-user files.
qdrant_bbox, qdrant_page = await get_chunk_bbox_and_page_from_qdrant(
user_id=user_id,
doc_id=doc_id,
chunk_index=chunk_index,
chunk_start=start,
chunk_end=end,
accessible_owners=accessible_owners,
)
if qdrant_bbox is not None:
chunk_bbox = qdrant_bbox
@@ -711,7 +811,7 @@ async def get_pdf_preview(request: Request) -> JSONResponse:
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
raise ValueError(_NEXTCLOUD_HOST_NOT_CONFIGURED)
# Use the user's stored app password for Nextcloud calls.
# The OAuth bearer is only used to authenticate Astrolabe → MCP Server;
+27 -2
View File
@@ -68,17 +68,27 @@ class LoginFlowV2Client:
2. Poll for completion to receive the app password
Args:
nextcloud_host: Base URL of the Nextcloud instance
nextcloud_host: Base URL of the Nextcloud instance, reachable by this
server (may be an internal/Docker hostname, e.g. http://app:80).
verify_ssl: SSL verification setting (True, False, or SSLContext)
public_host: Externally-reachable Nextcloud base URL for the
browser-facing login URL (e.g. https://cloud.example.com). When the
server talks to Nextcloud over an internal hostname, Nextcloud
builds the login URL with that internal host — unusable in the
user's browser. If set, the login URL's origin is rewritten to this
public host. When None, the login URL is returned unchanged
(correct when nextcloud_host is already the public URL).
"""
def __init__(
self,
nextcloud_host: str,
verify_ssl: bool | ssl.SSLContext = True,
public_host: str | None = None,
):
self.nextcloud_host = nextcloud_host.rstrip("/")
self.verify_ssl = verify_ssl
self.public_host = public_host.rstrip("/") if public_host else None
async def initiate(
self, user_agent: str = "nextcloud-mcp-server"
@@ -119,8 +129,23 @@ class LoginFlowV2Client:
# so server-side polling works across Docker networks.
poll_endpoint = self._rewrite_to_nextcloud_host(raw_poll_endpoint)
# The login URL is opened in the *user's browser*, so it must use
# the externally-reachable host. Nextcloud builds it from the
# request host (our internal nextcloud_host), so rewrite it to the
# public host when one is configured (internal != external).
login_url = data["login"]
if self.public_host:
rewritten = rewrite_url_origin(login_url, self.public_host)
if rewritten != login_url:
logger.debug(
"Rewrote Login Flow v2 login_url to public host: %s%s",
login_url,
rewritten,
)
login_url = rewritten
result = LoginFlowInitResponse(
login_url=data["login"],
login_url=login_url,
poll_endpoint=poll_endpoint,
poll_token=poll_data["token"],
)
@@ -74,6 +74,7 @@ async def _poll_and_store(provision_id: str) -> None:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
)
poll_endpoint = session["poll_endpoint"]
@@ -205,6 +206,7 @@ async def provision_page(
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
)
init_response = await flow_client.initiate()
except Exception as e:
+55 -6
View File
@@ -33,10 +33,12 @@ from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
from nextcloud_mcp_server.search.access_filter import list_accessible_owners
from nextcloud_mcp_server.search.context import (
get_chunk_bbox_and_page_from_qdrant,
get_chunk_with_context,
)
from nextcloud_mcp_server.search.verification import verify_search_results
from nextcloud_mcp_server.utils.validation import is_valid_nextcloud_doc_id
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
@@ -158,7 +160,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
with trace_operation("vector_viz.get_auth_client"):
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
async with auth_client_ctx as nc_client: # noqa: F841
async with auth_client_ctx as nc_client:
# Create search algorithm (no client needed - verification removed)
if algorithm == "semantic":
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
@@ -172,6 +174,13 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
status_code=400,
)
# Expand the caller to every owner whose content they have
# read access to — same logic as the MCP tool path. See
# nextcloud_mcp_server.search.access_filter.
accessible_owners = await list_accessible_owners(
nc_client.sharing, username
)
# Execute search (supports cross-app when doc_types=None)
# Get unverified results with buffer for filtering
search_start = time.perf_counter()
@@ -192,6 +201,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
limit=limit * 2, # Buffer for verification filtering
doc_type=None, # Search all types
score_threshold=score_threshold,
accessible_owners=accessible_owners,
)
all_results.extend(unverified_results)
else:
@@ -211,15 +221,45 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
limit=limit * 2, # Buffer for verification filtering
doc_type=doc_type,
score_threshold=score_threshold,
accessible_owners=accessible_owners,
)
all_results.extend(unverified_results)
# Sort by score before verification
# Sort by score, then cap to the same limit*2 over-fetch budget
# as the cross-app branch and the nc_semantic_search tool path
# (server/semantic.py). Without this, N doc_types each fetched
# at limit*2 would send N*limit*2 candidates into verify-on-read,
# multiplying the Nextcloud round-trip cost (and latency) by N.
all_results.sort(key=lambda r: r.score, reverse=True)
all_results = all_results[: limit * 2]
# No verification needed for visualization - we only need Qdrant metadata
# (title, excerpt, doc_type) which is already in search results.
# Verification is only needed for sampling (LLM needs full content).
search_results = all_results[:limit]
# Verify-on-read (ADR-019). Now that accessible_owners is expanded
# via OCS shares, the result set can include OTHER users' shared
# documents — so we must drop any the caller can no longer access
# (e.g. a revoked share whose index entry hasn't reconciled yet),
# exactly as the nc_semantic_search tool path does. Skipping this
# would let the viz surface stale titles/excerpts from another
# user's index after a share is revoked.
# Eviction of dropped (e.g. revoked-share) points runs INLINE here
# by design: this is a Starlette route with no access to the
# FastMCP lifespan-owned ``eviction_task_group`` that the
# nc_semantic_search tool path passes for fire-and-forget eviction.
# The visualization is an interactive, low-QPS endpoint, so blocking
# briefly on the Qdrant delete is acceptable.
with trace_operation("vector_viz.verify_on_read"):
verified_results, _dropped = await verify_search_results(
nc_client, all_results
)
# Safe to log titles now: these passed verify-on-read (unverified
# titles are never logged — see the search algorithms).
if verified_results:
logger.debug(
"Top verified results: %s",
", ".join(
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
for r in verified_results[:5]
),
)
search_results = verified_results[:limit]
search_duration = time.perf_counter() - search_start
# Store original scores and normalize for visualization
@@ -636,6 +676,10 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
)
async with nc_client:
# Expand to owners who shared content with the caller so the cached
# chunk lookup can resolve cross-user SHARED FILES (gated per-file
# inside get_chunk_with_context). Same expansion as the search path.
accessible_owners = await list_accessible_owners(nc_client.sharing, user_id)
chunk_context = await get_chunk_with_context(
nc_client=nc_client,
user_id=user_id,
@@ -646,6 +690,7 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
chunk_index=chunk_index,
total_chunks=total_chunks,
context_chars=context_chars,
accessible_owners=accessible_owners,
)
# Check if context expansion succeeded
@@ -674,12 +719,16 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
chunk_bbox = None
page_number = chunk_context.page_number
if doc_type == "file":
# Reaching here means the file chunk context resolved, so access was
# already confirmed (get_chunk_with_context gates files by id);
# the bbox/page lookup uses the same owner scope for cross-user files.
qdrant_bbox, qdrant_page = await get_chunk_bbox_and_page_from_qdrant(
user_id=user_id,
doc_id=doc_id,
chunk_index=chunk_index,
chunk_start=start,
chunk_end=end,
accessible_owners=accessible_owners,
)
if qdrant_bbox is not None:
chunk_bbox = qdrant_bbox
+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.
@@ -0,0 +1,179 @@
"""ACL-aware ownership filter for semantic / BM25 search.
The vector store payload carries an ``owner_id`` field — the UID of the user
who owns the underlying Nextcloud document. At query time, a user should
be able to find every document whose owner has shared it (directly or via
group / link) with them, without re-indexing.
This module turns "who can user X read?" into a Qdrant filter:
``owner_id IN accessible_owners`` where ``accessible_owners`` is
``{X} {owners of files / objects shared with X}``.
A second OR-branch matches the legacy ``user_id`` field so points indexed
before this change (which carry only ``user_id``) continue to be findable
by their original indexer. New points carry both fields.
Operator note (existing data): a Qdrant ``owner_id`` field condition matches
nothing on points that lack the field, so documents indexed *before* this
change never surface to share recipients — only to their original indexer via
the legacy ``user_id`` branch. ACL-aware search is therefore effectively a
no-op for pre-existing data until each owner's scanner re-indexes it. Trigger a
re-index after deploying this feature if it should apply to already-indexed
content immediately.
"""
from __future__ import annotations
import logging
import time
from collections import OrderedDict
from typing import Any, Protocol
from qdrant_client.models import Condition, FieldCondition, Filter, MatchAny, MatchValue
logger = logging.getLogger(__name__)
# Short-lived per-user cache for the OCS shares lookup, which otherwise runs on
# every search/viz request. Trades up to this many seconds of share-visibility
# staleness (a freshly-granted share is searchable a little late) for avoiding
# an OCS round-trip per query. Safe: verify-on-read still gates each result
# against Nextcloud, so a revoked share is caught there regardless of this cache.
_OWNERS_CACHE_TTL_SECONDS = 30.0
# Cap the number of cached users so the process-global cache can't grow
# unboundedly in a long-running multi-user deployment (one entry per active
# user, never evicted otherwise). LRU eviction by insertion/access order via
# OrderedDict; the cap is generous relative to any realistic concurrent-user
# count, so steady state is effectively all-hit.
_OWNERS_CACHE_MAXSIZE = 1024
_owners_cache: OrderedDict[str, tuple[float, list[str]]] = OrderedDict()
def clear_accessible_owners_cache() -> None:
"""Drop all cached accessible-owners entries (used by tests)."""
_owners_cache.clear()
class _SharingClientProtocol(Protocol):
"""Subset of SharingClient that this module actually uses."""
async def list_shares(
self, path: str | None = None, shared_with_me: bool = False
) -> list[dict[str, Any]]: ...
async def list_accessible_owners(
sharing_client: _SharingClientProtocol,
user_id: str,
) -> list[str]:
"""Return every owner UID whose content `user_id` should be able to search.
The set is ``{user_id} {uid_owner of each share with shared_with_me=True}``.
Duplicates are removed; ordering is not significant (Qdrant ``MatchAny``
treats the list as a set).
Results are cached per user for ``_OWNERS_CACHE_TTL_SECONDS`` to keep the
OCS round-trip off the search hot path. Failures are not cached.
Note: ``list_shares(shared_with_me=True)`` returns whatever the OCS endpoint
yields in a single page (SharingClient does not paginate today). A user with
more incoming shares than the OCS page size could have some owners omitted;
if that becomes real, add pagination to SharingClient.
Granularity / over-fetch limitation (TODO, finer-grained filtering): this
expansion is *owner-level*, not *file-level*. If a prolific content creator
shares a single item with the querying user, that owner's whole indexed
corpus becomes a Qdrant candidate set for the querier even though only the
shared item is accessible. Verify-on-read correctly drops the inaccessible
"ghost" candidates, but because there is no second Qdrant pass to replenish,
a ``limit=N`` search can return fewer than N results when the over-fetch
buffer (2× in nc_semantic_search / viz_routes) is dominated by ghosts. A
per-file ownership index would remove this tension and is the natural
starting point for future work (intentionally out of scope here).
Sharing API failures are non-fatal — we degrade to ``[user_id]`` and log
so a hiccup in OCS doesn't black-hole the user's own search.
"""
now = time.monotonic()
cached = _owners_cache.get(user_id)
if cached is not None and now - cached[0] < _OWNERS_CACHE_TTL_SECONDS:
_owners_cache.move_to_end(user_id) # mark as recently used (LRU)
return list(cached[1]) # copy so callers can't mutate the cached value
owners: set[str] = {user_id}
try:
shares = await sharing_client.list_shares(shared_with_me=True)
except Exception as exc: # noqa: BLE001 — degrade gracefully
logger.warning(
"Sharing API unavailable; falling back to self-only owner filter "
"for user %s (%s)",
user_id,
exc,
)
return [user_id] # don't cache failures — retry on the next search
for share in shares:
# OCS returns the share owner under `uid_owner` (the file owner,
# not the share recipient). Some Nextcloud versions also surface
# `owner` as a fallback display field — we tolerate both. The intent is
# "absent, not empty": a missing/blank `uid_owner` falls through to
# `owner`, and a non-string or empty result skips the (malformed) share.
owner = share.get("uid_owner") or share.get("owner") or None
if not isinstance(owner, str) or not owner:
continue
owners.add(owner)
result = list(owners)
_owners_cache[user_id] = (now, result)
# Promote to the most-recently-used end. This is a no-op for a brand-new
# key (dict insertion already appends) but is needed when re-inserting an
# existing key after its TTL expired.
_owners_cache.move_to_end(user_id)
while len(_owners_cache) > _OWNERS_CACHE_MAXSIZE:
_owners_cache.popitem(last=False) # evict the least-recently-used entry
logger.debug(
"Accessible owners for user %s: %d entries (%d other owner(s))",
user_id,
len(result),
len(result) - 1,
)
return list(result)
def build_ownership_filter(
user_id: str, accessible_owners: list[str] | None = None
) -> Filter:
"""Build the Qdrant ``Filter`` constraining a search to readable points.
Matches points whose ``owner_id`` is in ``accessible_owners`` (excluding
self) OR whose ``user_id`` equals ``user_id``. The ``user_id`` branch covers
*all* of the caller's own content — both new points (where
``owner_id == user_id``) and legacy points indexed before ``owner_id``
existed — so self is intentionally NOT repeated in the ``owner_id`` branch.
Args:
user_id: Querying user (matched by the ``user_id`` branch, which is the
self-only default when ``accessible_owners`` is None).
accessible_owners: Pre-computed list of owner UIDs the user has
access to. When None, defaults to ``[user_id]`` (no shares
expansion — used by callers that genuinely want self-only
scope such as eviction sweeps).
Returns:
A Qdrant ``Filter`` ready to be nested under a parent ``must`` clause.
"""
owners = accessible_owners if accessible_owners is not None else [user_id]
# The ``user_id`` branch is always present and already covers self-owned
# content (new + legacy). The ``owner_id`` branch is added only for OTHER
# owners (share senders) — listing self there too would overlap the
# ``user_id`` branch for no benefit. When there are no other owners the
# ``owner_id`` branch is omitted entirely, so we never depend on
# ``MatchAny(any=[])`` matching nothing (not a documented Qdrant guarantee).
other_owners = [owner for owner in owners if owner != user_id]
conditions: list[Condition] = [
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
]
if other_owners:
conditions.insert(
0, FieldCondition(key="owner_id", match=MatchAny(any=other_owners))
)
return Filter(should=conditions)
+28 -4
View File
@@ -5,9 +5,10 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Protocol, runtime_checkable
from qdrant_client.models import FieldCondition, Filter, MatchValue, ScoredPoint
from qdrant_client.models import Filter, ScoredPoint
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.search.access_filter import build_ownership_filter
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
@@ -75,14 +76,24 @@ class NextcloudClientProtocol(Protocol):
...
async def get_indexed_doc_types(user_id: str) -> set[str]:
async def get_indexed_doc_types(
user_id: str, accessible_owners: list[str] | None = None
) -> set[str]:
"""Query Qdrant to get actually-indexed document types for a user.
This enables search algorithms to check which document types are available
before attempting to search/verify them, allowing graceful cross-app search.
Args:
user_id: User ID to filter by
user_id: User ID to filter by.
accessible_owners: Owner UIDs the user may read (self + share senders),
as computed by ``access_filter.list_accessible_owners``. When
provided, doc-type discovery is ACL-aware and matches the same
ownership scope as the actual search (so a share recipient discovers
cross-user doc_types). When ``None`` (the default), discovery is
**self-only** — a recipient won't see doc_types that exist only in
another owner's shared content. Pass the expanded set for cross-user
discovery.
Returns:
Set of document type strings (e.g., {"note", "file", "calendar"})
@@ -106,7 +117,9 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
scroll_filter=Filter(
must=[
get_placeholder_filter(), # Exclude placeholders from doc_type discovery
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
# ACL-aware ownership scope (owner_id IN owners OR legacy
# user_id == user_id), matching the real search filter.
build_ownership_filter(user_id, accessible_owners),
]
),
limit=1000, # Sample size to discover types
@@ -168,6 +181,9 @@ class SearchResult:
chunk_index: int = 0
total_chunks: int = 1
point_id: str | None = None
# Pre-normalization score, set by the visualization route before it rescales
# ``score`` to [0, 1] for visual encoding (see auth/viz_routes.py).
original_score: float | None = None
def __post_init__(self):
"""Validate score is non-negative.
@@ -271,6 +287,8 @@ class SearchAlgorithm(ABC):
user_id: str,
limit: int = 10,
doc_type: str | None = None,
*,
accessible_owners: list[str] | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""Execute search with the given parameters.
@@ -280,6 +298,12 @@ class SearchAlgorithm(ABC):
user_id: User ID for multi-tenant filtering
limit: Maximum number of results to return
doc_type: Optional document type filter (note, file, calendar, etc.)
accessible_owners: Owner UIDs the user is allowed to read (self plus
the owners of content shared with them), pre-computed from the
OCS Sharing API by the caller. Declared explicitly — rather than
buried in ``**kwargs`` — so a misspelled keyword is a type error
instead of a silent fall back to self-only scope. ``None`` means
self-only (``[user_id]``).
**kwargs: Algorithm-specific parameters
Returns:
+11 -10
View File
@@ -10,6 +10,7 @@ from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
from nextcloud_mcp_server.observability.tracing import trace_operation
from nextcloud_mcp_server.search.access_filter import build_ownership_filter
from nextcloud_mcp_server.search.algorithms import (
SearchAlgorithm,
SearchResult,
@@ -70,6 +71,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
user_id: str,
limit: int = 10,
doc_type: str | None = None,
*,
accessible_owners: list[str] | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""
@@ -88,6 +91,9 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
user_id: User ID for filtering
limit: Maximum results to return
doc_type: Optional document type filter
accessible_owners: Owner UIDs the user can read (self + share
senders), pre-computed by the caller from the OCS Sharing API.
Defaults to ``[user_id]`` (self-only) when ``None``.
**kwargs: Additional parameters (score_threshold override)
Returns:
@@ -131,10 +137,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
# Build Qdrant filter
filter_conditions = [
get_placeholder_filter(), # Always exclude placeholders from user-facing queries
FieldCondition(
key="user_id",
match=MatchValue(value=user_id),
),
build_ownership_filter(user_id, accessible_owners),
]
# Add doc_type filter if specified
@@ -238,12 +241,10 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
if len(results) >= limit:
break
# Log the count only — NOT titles. These results are unverified: with
# owner-level share expansion the candidate set can include other users'
# documents that verify-on-read will drop, so titles must not be logged
# until after verification (the verifying callers log verified titles).
logger.info("Returning %s unverified results after deduplication", len(results))
if results:
result_details = [
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
for r in results[:5] # Show top 5
]
logger.debug("Top results: %s", ", ".join(result_details))
return results
+97 -14
View File
@@ -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(
@@ -172,7 +190,13 @@ async def _get_deck_metadata_from_qdrant(
qdrant_client = await get_qdrant_client()
settings = get_settings()
# Query for any chunk of this card (we just need metadata)
# Query for any chunk of this card (we just need metadata).
# Intentionally self-only (raw user_id, not build_ownership_filter):
# deck cards are a documented cross-user gap — the Deck API is per-user,
# so cross-user deck context can't be fetched with the caller's
# credentials anyway (see the doc_type=="file"-only gate in
# get_chunk_with_context). Every other internal Qdrant lookup here is
# ACL-aware; this one is the deliberate exception.
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
@@ -217,6 +241,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 +281,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 +298,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 +377,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 +387,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 +398,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 +410,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 +468,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 +497,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 +522,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
+13 -11
View File
@@ -9,6 +9,7 @@ from nextcloud_mcp_server.acl_hash import accessible_hash_set
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import get_embedding_service
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
from nextcloud_mcp_server.search.access_filter import build_ownership_filter
from nextcloud_mcp_server.search.algorithms import (
SearchAlgorithm,
SearchResult,
@@ -50,6 +51,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
user_id: str,
limit: int = 10,
doc_type: str | None = None,
*,
accessible_owners: list[str] | None = None,
**kwargs: Any,
) -> list[SearchResult]:
"""Execute semantic search using vector similarity.
@@ -67,7 +70,11 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
user_id: User ID for filtering
limit: Maximum results to return
doc_type: Optional document type filter
**kwargs: Additional parameters (score_threshold override)
accessible_owners: Owner UIDs the user can read (self + share
senders), pre-computed by the caller from the OCS Sharing API.
Defaults to ``[user_id]`` (self-only) when ``None``.
**kwargs:
- score_threshold (float): override the instance default
Returns:
List of unverified SearchResult objects ranked by similarity score
@@ -99,10 +106,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
# Build Qdrant filter
filter_conditions = [
get_placeholder_filter(), # Always exclude placeholders from user-facing queries
FieldCondition(
key="user_id",
match=MatchValue(value=user_id),
),
build_ownership_filter(user_id, accessible_owners),
]
# Add doc_type filter if specified
@@ -175,12 +179,10 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
if len(results) >= limit:
break
# Log the count only — NOT titles. These results are unverified: with
# owner-level share expansion the candidate set can include other users'
# documents that verify-on-read will drop, so titles must not be logged
# until after verification (the verifying callers log verified titles).
logger.info("Returning %s unverified results after deduplication", len(results))
if results:
result_details = [
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
for r in results[:5] # Show top 5
]
logger.debug("Top results: %s", ", ".join(result_details))
return results
+44 -23
View File
@@ -132,45 +132,55 @@ async def _verify_files(
results: list[SearchResult],
semaphore: anyio.Semaphore,
) -> set[str]:
"""Return the doc_ids of file results this user may actually access.
Verifies each file by its *global* Nextcloud file id via an ACL-aware
WebDAV SEARCH (``webdav.file_accessible_by_id``), NOT by path. This is the
ACL-aware-search fix: a file an owner shared with the querying user mounts
at a different path under each tree, so the previous path-based check
(``get_file_info``) produced false 404s and dropped legitimate shared-file
hits. Definitive 403/404 → inaccessible (dropped + scheduled for eviction
by the caller); transient/ambiguous errors → kept (fail-open).
"""
# safe: cooperative concurrency, no lock needed (see verify_search_results)
accessible: set[str] = set()
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 +193,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,
@@ -584,6 +596,15 @@ async def verify_search_results(
if evict_on_missing and inaccessible:
async def evict(doc_id: str, doc_type: str) -> None:
# Eviction is scoped to the QUERYING user's own points
# (user_id == the searcher). For a cross-user shared document
# (owner_id=alice surfaced to bob via accessible_owners), bob
# failing verification evicts with user_id=bob — a deliberate
# no-op, because alice's points carry user_id=alice and must NOT
# be deleted just because bob's share was revoked. Bob's view
# self-heals via list_accessible_owners (alice drops out of his
# accessible owners once OCS no longer reports the share). See the
# legacy-user_id semantics note in build_ownership_filter.
try:
await delete_document_points(doc_id, doc_type, user_id)
except Exception as e:
@@ -113,6 +113,7 @@ def register_auth_tools(mcp: FastMCP) -> None:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
)
init_response = await flow_client.initiate()
except Exception as e:
@@ -258,6 +259,7 @@ def register_auth_tools(mcp: FastMCP) -> None:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
)
poll_result = await flow_client.poll(
poll_endpoint=session["poll_endpoint"],
@@ -431,6 +433,7 @@ def register_auth_tools(mcp: FastMCP) -> None:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
public_host=settings.nextcloud_public_issuer_url,
)
init_response = await flow_client.initiate()
except Exception as e:
+43 -1
View File
@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.auth.scope_authorization import invalidate_scope_cache
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
@@ -132,6 +133,26 @@ async def _get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSt
)
storage = await get_shared_storage()
# Login Flow v2 app password stored directly in this server's storage —
# written by nc_auth_provision_access and the management app-password API,
# and the credential that require_provisioning / get_client actually use.
# Checked here so check_provisioning_status and revoke_nextcloud_access stay
# consistent with what actually grants tool access (the dual-store drift in
# the original code reported "not provisioned" while tools still worked).
app_pw = await storage.get_app_password_with_scopes(user_id)
if app_pw:
logger.debug(
" get_provisioning_status: app password (login-flow store) FOUND "
"for user_id=%s",
user_id,
)
return ProvisioningStatus(
is_provisioned=True,
credential_type="app_password",
scopes=app_pw.get("scopes"),
flow_type="login_flow_v2",
)
token_data = await storage.get_refresh_token(user_id)
if not token_data:
@@ -297,9 +318,30 @@ async def _revoke_nextcloud_access(ctx: Context, user_id: str) -> RevocationResu
message="No Nextcloud access to revoke.",
)
# Initialize Token Broker to handle revocation
storage = await get_shared_storage()
# App-password credential (Login Flow v2 / management API): there is no
# IdP token to revoke — removing it from this server's storage drops the
# server's access. Without this, revoke previously only handled refresh
# tokens and left the app password in place (tools kept working).
if status.credential_type == "app_password":
deleted = await storage.delete_app_password(user_id)
invalidate_scope_cache(user_id)
if deleted:
return RevocationResult(
success=True,
message=(
"Successfully revoked Nextcloud access (app password "
"removed). You can run provisioning again if needed."
),
)
return RevocationResult(
success=True,
message="No Nextcloud access to revoke.",
)
# Refresh-token credential: revoke via the Token Broker (IdP revocation).
# Get OAuth client credentials from storage
client_creds = await storage.get_oauth_client()
if not client_creds:
+32 -1
View File
@@ -30,6 +30,7 @@ from nextcloud_mcp_server.models.semantic import (
from nextcloud_mcp_server.observability.metrics import (
instrument_tool,
)
from nextcloud_mcp_server.search.access_filter import list_accessible_owners
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.search.verification import verify_search_results
@@ -121,8 +122,19 @@ def configure_semantic_tools(mcp: FastMCP):
)
)
# Expand the caller's identity to every owner whose content they
# have read access to via Nextcloud shares. Lets a user find files
# owners have shared with them without having to re-index those
# files under their own user_id.
accessible_owners = await list_accessible_owners(client.sharing, username)
try:
# Create BM25 hybrid search algorithm with specified fusion
# The nc_semantic_search tool deliberately uses BM25-hybrid (dense +
# sparse with RRF/DBSF fusion) as the single tool-layer algorithm.
# SemanticSearchAlgorithm is not dead code — it backs the dense-only
# option that the visualization/API surfaces expose explicitly
# (auth/viz_routes.py and api/visualization.py). Both algorithms take
# accessible_owners, so ACL-aware search works on every surface.
search_algo = BM25HybridSearchAlgorithm(
score_threshold=score_threshold, fusion=fusion
)
@@ -153,6 +165,7 @@ def configure_semantic_tools(mcp: FastMCP):
limit=limit * 2,
doc_type=None, # Signal to search all types
score_threshold=score_threshold,
accessible_owners=accessible_owners,
)
all_results.extend(unverified_results)
else:
@@ -177,6 +190,7 @@ def configure_semantic_tools(mcp: FastMCP):
limit=limit * 2,
doc_type=dtype,
score_threshold=score_threshold,
accessible_owners=accessible_owners,
)
all_results.extend(unverified_results)
@@ -221,6 +235,17 @@ def configure_semantic_tools(mcp: FastMCP):
verified_chunk_count,
dropped_count,
)
# Safe to log titles now: these results passed verify-on-read, so the
# caller is confirmed to have access (unverified titles were never
# logged — see the search algorithms).
if verified_results:
logger.debug(
"Top verified results: %s",
", ".join(
f"{r.doc_type}_{r.id} (score={r.score:.3f}, title='{r.title}')"
for r in verified_results[:5]
),
)
search_results = verified_results[:limit]
# Convert SearchResult objects to SemanticSearchResult for response.
@@ -314,6 +339,12 @@ def configure_semantic_tools(mcp: FastMCP):
chunk_index=result.chunk_index,
total_chunks=result.total_chunks,
context_chars=context_chars,
# Forward the share-expanded owner set so context
# expansion works for shared files (the per-file
# file_accessible_by_id gate inside still enforces
# access). Without this the lookup stays self-only
# and silently falls back to the plain excerpt.
accessible_owners=accessible_owners,
)
if chunk_context:
+36 -3
View File
@@ -43,6 +43,38 @@ class NotProvisionedError(Exception):
pass
# Process-wide app-password storage for the BasicAuth client path.
#
# get_user_client_basic_auth is on the search hot path (Unified Search and the
# /api/v1 viz endpoints call it per request). Creating a fresh
# RefreshTokenStorage and running ``initialize()`` — a full Alembic upgrade in
# a worker thread — on every call is both wasteful and unsafe: concurrent
# upgrades race on Alembic's non-thread-safe module-global EnvironmentContext
# proxy, surfacing as ``KeyError: 'script'``. Cache one initialized instance,
# guarded by a lock so the one-time migration runs exactly once. The lock is
# created lazily inside an async context (anyio primitives must not be built at
# import time — trio compatibility), mirroring vector/qdrant_client.py.
_basic_auth_storage: "RefreshTokenStorage | None" = None
_basic_auth_storage_lock: anyio.Lock | None = None
async def _get_initialized_basic_auth_storage() -> "RefreshTokenStorage":
"""Return the process-wide, already-initialized app-password storage."""
global _basic_auth_storage, _basic_auth_storage_lock
if _basic_auth_storage is not None:
return _basic_auth_storage
# Safe under cooperative scheduling: no await between the None-check and the
# assignment, so two coroutines cannot both create a lock.
if _basic_auth_storage_lock is None:
_basic_auth_storage_lock = anyio.Lock()
async with _basic_auth_storage_lock:
if _basic_auth_storage is None:
storage = RefreshTokenStorage.from_env()
await storage.initialize()
_basic_auth_storage = storage
return _basic_auth_storage
@dataclass
class UserSyncState:
"""State for a single user's scanner task."""
@@ -74,10 +106,11 @@ async def get_user_client_basic_auth(
Raises:
NotProvisionedError: If user has not provisioned an app password
"""
# Get or create storage instance
# Get or create storage instance. Reuse a process-wide initialized instance
# rather than building one (and running an Alembic upgrade) per call — see
# _get_initialized_basic_auth_storage for why (hot path + Alembic race).
if storage is None:
storage = RefreshTokenStorage.from_env()
await storage.initialize()
storage = await _get_initialized_basic_auth_storage()
# Retrieve app password from local storage
app_password = await storage.get_app_password(user_id)
+10
View File
@@ -717,6 +717,16 @@ async def _index_document(
},
payload={
"user_id": doc_task.user_id,
# owner_id is the UID of the file's owner — what
# search-time ACL expansion filters on. Today the scanner
# always runs as the file's owner (per-user crawl, only
# surfaces files the user owns or that fall under their
# WebDAV root), so owner_id == user_id is correct for
# every doc type indexed here. The fields are kept
# separate so a future indexer change that lets a user
# crawl shared-with-them content can set owner_id to the
# true owner without losing the "who indexed this" trail.
"owner_id": doc_task.owner_id or doc_task.user_id,
"doc_id": doc_task.doc_id,
"doc_type": doc_task.doc_type,
"is_placeholder": False, # Real indexed document (not placeholder)
@@ -38,6 +38,15 @@ logger = logging.getLogger(__name__)
_PAYLOAD_INDEX_FIELDS: dict[str, PayloadSchemaType] = {
"doc_id": PayloadSchemaType.KEYWORD,
"user_id": PayloadSchemaType.KEYWORD,
# owner_id is the ACL-aware filter field: every search applies
# MatchAny(key="owner_id", any=accessible_owners) (see
# search/access_filter.py). Without a keyword index Qdrant full-scans the
# collection to evaluate it — invisible at small scale, but a latency
# regression at tens of thousands of points and an HTTP 400 on Qdrant
# Cloud strict payload-validation mode. Mirrors the user_id treatment;
# _ensure_payload_indexes is idempotent so existing collections migrate
# at startup without operator intervention.
"owner_id": PayloadSchemaType.KEYWORD,
"doc_type": PayloadSchemaType.KEYWORD,
"is_placeholder": PayloadSchemaType.BOOL,
"chunk_index": PayloadSchemaType.INTEGER,
+6
View File
@@ -113,6 +113,12 @@ class DocumentTask:
# when it is None (deletes, or sources whose etag isn't threaded). Harmless
# in local mode — the in-process processor reads its own etag.
etag: str | None = None
# UID of the true owner of the indexed object, used by the search-time
# ACL filter. None today (scanner always runs as the owner, so the
# processor falls back to user_id), but settable so a future
# shared-with-me crawl can pass through the actual owner without
# reshaping the payload contract.
owner_id: str | None = None
# Track documents potentially deleted (grace period before actual deletion)