feat(vector-sync): honor Astrolabe admin consent for searchable sources

Consume the astrolabe.semantic_search capability as the source of truth for
which content sources an admin has approved for semantic search, and enforce
it independently of Astrolabe (this server queries Qdrant directly).

- capabilities.py: cached per-user reader for enabled_doc_types (TTL+LRU,
  fail-open so older Astrolabe / transient OCS errors don't break search)
- semantic search: intersect requested doc_types with the allowed set;
  restrict to the allowed set when none requested; short-circuit when empty
- scanner: skip disabled sources during discovery (files discovery yields
  nothing when disabled, so the existing grace-period reconcile purges them)
- processor: drop near-real-time index tasks for disabled doc_types
  (webhook events bypass the scanner gate); deletes always proceed
- vector/purge.py + POST /api/v1/vector-sync/purge: admin-only global
  delete-by-doc_type, called by Astrolabe when a source is disabled so
  consent is binding on data-at-rest

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-16 00:38:35 +02:00
co-authored by Claude Opus 4.8
parent 07ee91399b
commit ef5b3f3873
11 changed files with 770 additions and 6 deletions
+5
View File
@@ -36,6 +36,9 @@ from nextcloud_mcp_server.api.passwords import (
get_app_password_status,
provision_app_password,
)
from nextcloud_mcp_server.api.vector_sync import (
purge_doc_types_route,
)
from nextcloud_mcp_server.api.visualization import (
get_chunk_context,
get_pdf_preview,
@@ -78,6 +81,8 @@ __all__ = [
"list_webhooks",
"create_webhook",
"delete_webhook",
# Vector-sync admin endpoints (from vector_sync.py)
"purge_doc_types_route",
# Visualization endpoints (from visualization.py)
"unified_search",
"vector_search",
+125
View File
@@ -0,0 +1,125 @@
"""Vector-sync admin API endpoints.
Provides the purge endpoint Astrolabe calls when an admin disables a content
source for semantic search. Consent is binding on data-at-rest, so the
already-indexed content for the disabled source's doc type(s) is deleted
globally (every owner) — see :mod:`nextcloud_mcp_server.vector.purge`.
Auth: the OAuth bearer identifies the caller (``validate_token_and_get_user``);
because the purge deletes every owner's content for a doc type, it is further
restricted to Nextcloud administrators (verified via the ``admin`` group using
the caller's app password). This is stricter than the per-user webhook routes
in :mod:`nextcloud_mcp_server.api.webhooks` precisely because the blast radius
is global.
"""
import logging
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api._auth import get_basic_auth_for_user
from nextcloud_mcp_server.api.management import (
_sanitize_error_for_client,
validate_token_and_get_user,
)
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
from nextcloud_mcp_server.client.users import UsersClient
from nextcloud_mcp_server.vector.purge import purge_doc_types
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
async def purge_doc_types_route(request: Request) -> JSONResponse:
"""POST /api/v1/vector-sync/purge — delete indexed vectors by doc type.
Request body::
{"doc_types": ["file", "note"]}
Returns ``{"purged": {doc_type: deleted_count}}``. Admin-only.
Requires OAuth bearer token for authentication.
"""
try:
user_id, _ = await validate_token_and_get_user(request)
except Exception as e:
logger.warning("Unauthorized access to /api/v1/vector-sync/purge: %s", e)
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "purge_doc_types"),
},
status_code=401,
)
try:
body = await request.json()
except Exception as e:
logger.warning("Purge payload was not valid JSON: %s", e)
return JSONResponse(
{"error": "Bad request", "message": "invalid JSON"},
status_code=400,
)
raw = body.get("doc_types")
if not isinstance(raw, list) or not all(isinstance(d, str) for d in raw):
return JSONResponse(
{
"error": "Bad request",
"message": "doc_types must be a list of strings",
},
status_code=400,
)
doc_types = [d for d in raw if d]
if not doc_types:
return JSONResponse({"purged": {}})
try:
username, app_password = await get_basic_auth_for_user(user_id)
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")
# Verify admin via the caller's own app password before any deletion.
async with nextcloud_httpx_client(
base_url=nextcloud_host,
auth=httpx.BasicAuth(username, app_password),
timeout=30.0,
) as client:
users_client = UsersClient(client, username)
user_groups = await users_client.get_user_groups(username)
if "admin" not in user_groups:
logger.warning("Non-admin user %s attempted vector-sync purge", user_id)
return JSONResponse(
{
"error": "Forbidden",
"message": "Administrator privileges required",
},
status_code=403,
)
purged = await purge_doc_types(doc_types)
logger.info("Vector-sync purge by admin %s: %s", user_id, purged)
return JSONResponse({"purged": purged})
except ProvisioningRequiredError as e:
logger.info("Provisioning required for user %s: %s", user_id, e)
return JSONResponse(
{"error": "Provisioning required", "message": str(e)},
status_code=428,
)
except Exception as e:
logger.error("Error purging doc types for user %s: %s", user_id, e)
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "purge_doc_types"),
},
status_code=500,
)
+10
View File
@@ -47,6 +47,7 @@ from nextcloud_mcp_server.api import (
list_supported_scopes,
list_webhooks,
provision_app_password,
purge_doc_types_route,
revoke_user_access,
unified_search,
update_user_scopes,
@@ -2422,6 +2423,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
routes.append(
Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"])
)
# Vector-sync admin: purge indexed vectors by doc type (admin consent —
# called by Astrolabe when a source is disabled for semantic search)
routes.append(
Route(
"/api/v1/vector-sync/purge",
purge_doc_types_route,
methods=["POST"],
)
)
# Access and scope management endpoints (ADR-022)
routes.append(
Route(
+115
View File
@@ -0,0 +1,115 @@
"""Reads admin-approved searchable sources from the Astrolabe capability.
The Astrolabe Nextcloud app advertises, per user, which content sources an
admin has approved for semantic search, under
``capabilities.astrolabe.semantic_search.enabled_doc_types`` on the OCS
capabilities endpoint (``/ocs/v2.php/cloud/capabilities``). This is the single
source of truth for admin consent: the search layer filters results to these
doc types, and the indexing layer (scanner + webhook ingest) skips everything
else (the hard data-at-rest guarantee is the eager purge Astrolabe triggers on
disable; see ``vector.purge``).
Fail-open for *availability*: if the capability block is absent (an older
Astrolabe that predates this feature) or the OCS call fails, ``allowed_doc_types``
returns ``None`` meaning "no restriction", so search keeps working. ``None`` is
distinct from an empty set, which means "the admin disabled every source".
"""
from __future__ import annotations
import logging
import time
from collections import OrderedDict
from typing import Any, Protocol
logger = logging.getLogger(__name__)
# Short-lived per-user cache for the OCS capabilities lookup. Admin consent
# changes rarely, but search/scan paths consult it frequently, so trade a little
# staleness for keeping the OCS round-trip off the hot path. Mirrors the
# list_accessible_owners cache in search/access_filter.py.
_CACHE_TTL_SECONDS = 30.0
_CACHE_MAXSIZE = 1024
# user_id -> (monotonic_ts, frozenset[doc_type] | None). None = no restriction.
_cache: OrderedDict[str, tuple[float, frozenset[str] | None]] = OrderedDict()
class _CapabilitiesClientProtocol(Protocol):
async def capabilities(self) -> Any: ...
def _parse_enabled_doc_types(payload: Any) -> set[str] | None:
"""Extract ``enabled_doc_types`` from an OCS capabilities payload.
Returns ``None`` when the ``astrolabe.semantic_search`` block is absent or
malformed (treated as "no restriction"). Returns a set (possibly empty) when
the block is present and well-formed; an empty set means the admin disabled
every source.
"""
if not isinstance(payload, dict):
return None
try:
caps = payload["ocs"]["data"]["capabilities"]
except (KeyError, TypeError):
return None
if not isinstance(caps, dict):
return None
block = caps.get("astrolabe")
if not isinstance(block, dict):
return None
semantic = block.get("semantic_search")
if not isinstance(semantic, dict):
return None
raw = semantic.get("enabled_doc_types")
if not isinstance(raw, list):
return None
return {dt for dt in raw if isinstance(dt, str)}
async def allowed_doc_types(
client: _CapabilitiesClientProtocol, user_id: str
) -> frozenset[str] | None:
"""Admin-approved doc types for ``user_id``, or ``None`` for "no restriction".
Cached per user with a short TTL (+ LRU eviction). Failures are not cached so
a transient OCS hiccup retries on the next call. Fail-open: a missing
capability block or an error yields ``None`` so search remains available.
"""
now = time.monotonic()
cached = _cache.get(user_id)
if cached is not None and now - cached[0] < _CACHE_TTL_SECONDS:
_cache.move_to_end(user_id) # mark recently used (LRU)
return cached[1]
try:
payload = await client.capabilities()
except Exception as exc: # noqa: BLE001 — degrade gracefully (fail-open)
logger.warning(
"Astrolabe capabilities unavailable for user %s (%s); "
"not restricting doc types this cycle",
user_id,
exc,
)
return None # don't cache failures — retry next call
parsed = _parse_enabled_doc_types(payload)
result = frozenset(parsed) if parsed is not None else None
_cache[user_id] = (now, result)
_cache.move_to_end(user_id)
while len(_cache) > _CACHE_MAXSIZE:
_cache.popitem(last=False) # evict least-recently-used
return result
def is_doc_type_allowed(doc_type: str, allowed: frozenset[str] | None) -> bool:
"""Whether ``doc_type`` may be indexed/searched given an allow-set.
``allowed=None`` means "no restriction" (fail-open / older Astrolabe), so
everything is permitted.
"""
return allowed is None or doc_type in allowed
def clear_cache() -> None:
"""Test hook: drop all cached entries."""
_cache.clear()
+28
View File
@@ -20,6 +20,7 @@ from mcp.types import (
from pydantic import Field
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.capabilities import allowed_doc_types
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.semantic import (
@@ -300,6 +301,33 @@ def configure_semantic_tools(mcp: FastMCP):
# files under their own user_id.
accessible_owners = await list_accessible_owners(client.sharing, username)
# Admin consent gate: restrict to source types the Astrolabe admin has
# approved (and that are installed for this user). This mirrors
# Astrolabe's own server-side enforcement but is independent because
# this tool queries Qdrant directly. ``None`` = no restriction
# (fail-open / Astrolabe predating this feature). An empty allow-set
# means the admin disabled every source.
allowed = await allowed_doc_types(client, username)
if allowed is not None:
if doc_types is None:
doc_types = sorted(allowed)
else:
doc_types = [dt for dt in doc_types if dt in allowed]
if not doc_types:
logger.info(
"Semantic search short-circuited for user %s: no requested "
"doc_type is both installed and admin-approved",
username,
)
return SemanticSearchResponse(
results=[],
query=query,
total_found=0,
search_method=f"bm25_hybrid_{fusion}",
verified_chunk_count=0,
dropped_document_count=0,
)
try:
# The nc_semantic_search tool deliberately uses BM25-hybrid (dense +
# sparse with RRF/DBSF fusion) as the single tool-layer algorithm.
+19
View File
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
from nextcloud_mcp_server.document_processors.registry import ProcessorRegistry
from nextcloud_mcp_server.acl_hash import compute_acl_hash
from nextcloud_mcp_server.capabilities import allowed_doc_types, is_doc_type_allowed
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
@@ -574,6 +575,24 @@ async def process_document(
):
await _reconcile_tag_event(doc_task, nc_client)
# Admin consent gate (Astrolabe): never index a source the admin has
# disabled for semantic search — this catches near-real-time webhook
# events that bypass the scanner's discovery gate. Deletes always
# proceed (removing data honours consent). ``None`` from the reader
# means no restriction (fail-open / older Astrolabe), so a transient
# capabilities failure never silently drops indexing.
if doc_task.operation == "index":
allowed = await allowed_doc_types(nc_client, doc_task.user_id)
if not is_doc_type_allowed(doc_task.doc_type, allowed):
logger.info(
"Skipping index of %s_%s for %s: doc_type disabled by admin",
doc_task.doc_type,
doc_task.doc_id,
doc_task.user_id,
)
record_vector_sync_processing(time.time() - start_time, "skipped")
return
# Handle deletion
if doc_task.operation == "delete":
# Release this user rather than blind-delete: a file shared across
+73
View File
@@ -0,0 +1,73 @@
"""Global purge of indexed vectors by doc type (admin consent enforcement).
When an admin disables a content source for semantic search in Astrolabe,
consent is binding on data-at-rest: the already-indexed content for that
source's doc type(s) must be deleted, not merely hidden. Astrolabe calls the
``/api/v1/vector-sync/purge`` route on disable, which delegates here.
The purge is global (every owner) because the admin disable is a global
decision. It is safe to call for a doc type with no indexed points — Qdrant
deletes zero points and reports a count of 0.
"""
from __future__ import annotations
import logging
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
def _doc_type_filter(doc_type: str) -> Filter:
return Filter(
must=[FieldCondition(key="doc_type", match=MatchValue(value=doc_type))]
)
async def purge_doc_types(doc_types: list[str]) -> dict[str, int]:
"""Delete every indexed point whose ``doc_type`` is in ``doc_types``.
Returns a mapping of doc_type -> number of points deleted (counted before
deletion). Each doc type is purged independently so a failure on one does
not abort the rest; failures re-raise after the loop only if every doc type
failed, otherwise partial progress is returned.
"""
qdrant_client = await get_qdrant_client()
collection = get_settings().get_collection_name()
purged: dict[str, int] = {}
last_error: Exception | None = None
for doc_type in dict.fromkeys(doc_types): # de-dupe, preserve order
flt = _doc_type_filter(doc_type)
try:
count_result = await qdrant_client.count(
collection_name=collection,
count_filter=flt,
exact=True,
)
await qdrant_client.delete(
collection_name=collection,
points_selector=flt,
)
purged[doc_type] = int(count_result.count)
logger.info(
"Purged %d indexed point(s) for disabled doc_type=%s",
purged[doc_type],
doc_type,
)
except Exception as exc: # noqa: BLE001 — record and continue
last_error = exc
logger.error(
"Failed to purge indexed points for doc_type=%s: %s",
doc_type,
exc,
)
if not purged and last_error is not None:
# Nothing succeeded — surface the failure to the caller (HTTP 500).
raise last_error
return purged
+30 -6
View File
@@ -16,6 +16,7 @@ from httpx import HTTPStatusError
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import FieldCondition, Filter, MatchValue, Record
from nextcloud_mcp_server.capabilities import allowed_doc_types, is_doc_type_allowed
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.client.news import NewsItemType
from nextcloud_mcp_server.config import get_settings
@@ -365,6 +366,13 @@ async def scan_user_documents(
# detection failed: fall back to scanning every app (prior behaviour).
enabled_apps = await _get_enabled_apps_or_none(nc_client, user_id, scan_id)
# Admin consent gate (Astrolabe): only index sources the admin has
# approved for semantic search. ``None`` = no restriction (fail-open /
# older Astrolabe), so a transient capabilities failure never silently
# halts (or worse, mass-deletes) indexing. This is independent of
# ``enabled_apps``, which reflects only what the user has installed.
allowed = await allowed_doc_types(nc_client, user_id)
# Notes (isolated so an uninstalled or disabled Notes app — whose API
# returns 404 — cannot abort scanning of the other apps; this mirrors the
# per-app try/except guards already wrapping files/news/deck below).
@@ -373,7 +381,7 @@ async def scan_user_documents(
current_time = time.time()
queued = 0
if _app_enabled("notes", enabled_apps):
if _app_enabled("notes", enabled_apps) and is_doc_type_allowed("note", allowed):
try:
queued += await scan_notes(
user_id=user_id,
@@ -454,9 +462,21 @@ async def scan_user_documents(
# folder applies to every PDF beneath it.
settings = get_settings()
tag_name = settings.vector_sync_pdf_tag
tagged_files = await nc_client.find_files_by_tag(
tag_name, mime_type_filter="application/pdf"
)
if is_doc_type_allowed("file", allowed):
tagged_files = await nc_client.find_files_by_tag(
tag_name, mime_type_filter="application/pdf"
)
else:
# Files disabled by admin: discover nothing so no new file is
# indexed. The deletion-reconcile below then sees every indexed
# file as "missing" and purges it after the grace period — the
# backstop for the eager purge Astrolabe runs on disable.
logger.debug(
"[SCAN-%s] Files disabled by admin for %s; skipping tagged-file discovery",
scan_id,
user_id,
)
tagged_files = []
# Apply EXCLUDED_TAGS as defense-in-depth: a folder marked
# off-limits via the exclusion tag must not be indexed even if
@@ -710,7 +730,9 @@ async def scan_user_documents(
# Scan News items (starred + unread)
news_queued = 0
if _app_enabled("news", enabled_apps):
if _app_enabled("news", enabled_apps) and is_doc_type_allowed(
"news_item", allowed
):
try:
news_queued = await scan_news_items(
user_id=user_id,
@@ -731,7 +753,9 @@ async def scan_user_documents(
# Scan Deck cards
deck_queued = 0
if _app_enabled("deck", enabled_apps):
if _app_enabled("deck", enabled_apps) and is_doc_type_allowed(
"deck_card", allowed
):
try:
deck_queued = await scan_deck_cards(
user_id=user_id,