Merge pull request #911 from cbcoutinho/feat/admin-searchable-sources

feat(vector-sync): honor Astrolabe admin consent for searchable sources
This commit is contained in:
Chris Coutinho
2026-06-16 16:26:35 +02:00
committed by GitHub
14 changed files with 1407 additions and 7 deletions
+22
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
@@ -590,6 +591,27 @@ 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,
)
# Alertable counter so a flood of webhook events for a
# disabled source is observable (not silently swallowed).
record_ingest_dropped("admin_disabled")
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
+77
View File
@@ -0,0 +1,77 @@
"""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.
The count is taken just before the delete (two separate Qdrant calls), so
it is approximate — a point indexed in the gap is deleted but not counted.
This is acceptable: indexing of a disabled source is already gated upstream,
so the window is effectively empty in practice.
"""
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.exception(
"Failed to purge indexed points for doc_type=%s",
doc_type,
)
if not purged and last_error is not None:
# Nothing succeeded — surface the failure to the caller (HTTP 500).
raise last_error
return purged
+183 -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
@@ -279,6 +280,150 @@ def _app_enabled(app_id: str, enabled_apps: set[str] | None) -> bool:
return enabled_apps is None or app_id in enabled_apps
def _should_scan(
app_id: str,
doc_type: str,
enabled_apps: set[str] | None,
allowed: frozenset[str] | None,
) -> bool:
"""Whether to scan ``app_id``: installed for the user AND admin-approved."""
return _app_enabled(app_id, enabled_apps) and is_doc_type_allowed(doc_type, allowed)
# Text doc types whose deletion-tracking lives *inside* their scan_* function,
# so skipping that function (when admin-disabled) leaves indexed points with no
# grace-period backstop. Derived from INDEXED_DOC_TYPES so a newly-indexed type
# automatically gets the backstop. ``file`` is excluded: its scan path empties
# discovery and lets the existing reconcile loop purge on disable.
_TEXT_BACKSTOP_DOC_TYPES: tuple[str, ...] = tuple(sorted(INDEXED_DOC_TYPES - {"file"}))
# Per-process record of (user_id, doc_type) whose consent backstop deletes have
# already been enqueued, so a *standing* admin-disable doesn't re-flood the
# processor with idempotent deletes on every scan tick. An entry is cleared once
# the type is allowed again, so a later re-disable re-triggers the backstop.
# A dict (not a set) so it stays insertion-ordered for oldest-first eviction.
_consent_backstop_done: dict[tuple[str, str], None] = {}
# Safety bound on the tracking dict so a long-running multi-tenant process with
# heavy user churn (deprovisioned users leave stale entries) can't grow it
# without limit. At <= len(INDEXED_DOC_TYPES) entries per user this is generous;
# on overflow we evict the *oldest* entries down to half capacity (not a full
# clear) so the backstop re-fires for only those, avoiding a fleet-wide burst.
_CONSENT_BACKSTOP_MAX = 50_000
def _mark_backstop_done(key: tuple[str, str]) -> None:
"""Record a one-shot backstop marker, evicting oldest entries on overflow.
Evicts oldest-first down to half capacity (insertion-ordered dict) rather
than clearing wholesale, so a bound hit re-fires the backstop only for the
oldest markers, not the whole fleet. A re-fire is idempotent regardless.
"""
if len(_consent_backstop_done) >= _CONSENT_BACKSTOP_MAX:
overage = len(_consent_backstop_done) - _CONSENT_BACKSTOP_MAX // 2
logger.info(
"consent backstop tracking hit %d entries; evicting %d oldest",
_CONSENT_BACKSTOP_MAX,
overage,
)
for stale_key in list(_consent_backstop_done)[:overage]:
del _consent_backstop_done[stale_key]
_consent_backstop_done[key] = None
async def _backstop_delete_doc_type(
user_id: str,
send_stream: TaskProducer,
doc_type: str,
qdrant_client: AsyncQdrantClient,
collection: str,
scan_id: int,
) -> int:
"""Enqueue delete tasks for every indexed point of one disabled doc_type.
Returns the number of delete tasks enqueued.
"""
points = await _scroll_all_points(
qdrant_client,
collection_name=collection,
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
]
),
payload_fields=["doc_id"],
)
doc_ids = {
str(p.payload["doc_id"])
for p in points
if p.payload is not None and "doc_id" in p.payload
}
if doc_ids:
logger.info(
"[SCAN-%s] %s disabled by admin for %s; enqueueing %d delete(s) (backstop)",
scan_id,
doc_type,
user_id,
len(doc_ids),
)
for doc_id in doc_ids:
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type=doc_type,
operation="delete",
modified_at=0,
)
)
return len(doc_ids)
async def _enqueue_deletes_for_disabled_types(
user_id: str,
send_stream: TaskProducer,
allowed: frozenset[str] | None,
scan_id: int,
) -> int:
"""Enqueue delete tasks for indexed text-source points the admin disabled.
Backstop for a failed eager purge: scrolls this user's indexed points for
each admin-disallowed text doc_type and queues a delete. No-op when
``allowed`` is ``None`` (fail-open — never delete on a transient capability
read failure). Returns the number of delete tasks enqueued.
"""
if allowed is None:
return 0
# Re-enabled types: clear their one-shot marker so a later re-disable
# re-triggers the backstop.
for doc_type in _TEXT_BACKSTOP_DOC_TYPES:
if doc_type in allowed:
_consent_backstop_done.pop((user_id, doc_type), None)
# Disabled types not yet backstopped this episode.
disabled = [
dt
for dt in _TEXT_BACKSTOP_DOC_TYPES
if dt not in allowed and (user_id, dt) not in _consent_backstop_done
]
if not disabled:
return 0
qdrant_client = await get_qdrant_client()
collection = get_settings().get_collection_name()
queued = 0
for doc_type in disabled:
queued += await _backstop_delete_doc_type(
user_id, send_stream, doc_type, qdrant_client, collection, scan_id
)
# Mark backstopped (even when nothing was found) so subsequent scans
# don't re-scroll/re-enqueue for this disable episode.
_mark_backstop_done((user_id, doc_type))
return queued
async def scan_user_documents(
user_id: str,
send_stream: TaskProducer,
@@ -365,6 +510,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 +525,17 @@ async def scan_user_documents(
current_time = time.time()
queued = 0
if _app_enabled("notes", enabled_apps):
# Backstop purge for admin-disabled text sources. Their deletion-
# tracking lives inside the scan_* function we skip below, so (unlike
# files, whose discovery-empties-then-reconcile path purges on disable)
# they'd linger if Astrolabe's eager purge failed. Enqueue deletes for
# any indexed points of a now-disallowed type. Gated on a concrete
# allow-set, so a fail-open None never triggers deletion.
queued += await _enqueue_deletes_for_disabled_types(
user_id, send_stream, allowed, scan_id
)
if _should_scan("notes", "note", enabled_apps, allowed):
try:
queued += await scan_notes(
user_id=user_id,
@@ -454,9 +616,24 @@ 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.
# Asymmetry (intentional): files purge up to 1.5x scan_interval
# later than text types, which get immediate one-shot backstop
# deletes via _enqueue_deletes_for_disabled_types.
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 +887,7 @@ async def scan_user_documents(
# Scan News items (starred + unread)
news_queued = 0
if _app_enabled("news", enabled_apps):
if _should_scan("news", "news_item", enabled_apps, allowed):
try:
news_queued = await scan_news_items(
user_id=user_id,
@@ -731,7 +908,7 @@ async def scan_user_documents(
# Scan Deck cards
deck_queued = 0
if _app_enabled("deck", enabled_apps):
if _should_scan("deck", "deck_card", enabled_apps, allowed):
try:
deck_queued = await scan_deck_cards(
user_id=user_id,