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:
co-authored by
Claude Opus 4.8
parent
07ee91399b
commit
ef5b3f3873
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user