Merge remote-tracking branch 'origin/master' into feat/decomp-hook-points
# Conflicts: # nextcloud_mcp_server/vector/scanner.py
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user