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
+144
View File
@@ -0,0 +1,144 @@
"""Unit tests for the Astrolabe searchable-sources capability reader."""
from __future__ import annotations
import nextcloud_mcp_server.capabilities as cap
from nextcloud_mcp_server.capabilities import (
_parse_enabled_doc_types,
allowed_doc_types,
clear_cache,
is_doc_type_allowed,
)
def _payload(enabled_doc_types) -> dict:
"""Build an OCS capabilities envelope carrying the astrolabe block.
``enabled_doc_types=...`` (Ellipsis) omits the key entirely.
"""
semantic: dict = {}
if enabled_doc_types is not ...:
semantic["enabled_doc_types"] = enabled_doc_types
return {
"ocs": {
"meta": {"status": "ok"},
"data": {"capabilities": {"astrolabe": {"semantic_search": semantic}}},
}
}
# ---------------------------------------------------------------------------
# _parse_enabled_doc_types
# ---------------------------------------------------------------------------
def test_parse_present_list_returns_set():
assert _parse_enabled_doc_types(_payload(["note", "file"])) == {"note", "file"}
def test_parse_empty_list_returns_empty_set():
# Admin disabled every source — distinct from "no restriction".
assert _parse_enabled_doc_types(_payload([])) == set()
def test_parse_missing_astrolabe_block_returns_none():
payload = {"ocs": {"data": {"capabilities": {}}}}
assert _parse_enabled_doc_types(payload) is None
def test_parse_missing_enabled_key_returns_none():
assert _parse_enabled_doc_types(_payload(...)) is None
def test_parse_malformed_payload_returns_none():
assert _parse_enabled_doc_types(None) is None
assert _parse_enabled_doc_types({"ocs": "nope"}) is None
assert _parse_enabled_doc_types(_payload("not-a-list")) is None
def test_parse_drops_non_string_entries():
assert _parse_enabled_doc_types(_payload(["note", 5, None])) == {"note"}
# ---------------------------------------------------------------------------
# is_doc_type_allowed
# ---------------------------------------------------------------------------
def test_is_doc_type_allowed_none_means_no_restriction():
assert is_doc_type_allowed("anything", None) is True
def test_is_doc_type_allowed_respects_set():
allowed = frozenset({"note"})
assert is_doc_type_allowed("note", allowed) is True
assert is_doc_type_allowed("file", allowed) is False
def test_is_doc_type_allowed_empty_set_blocks_all():
assert is_doc_type_allowed("note", frozenset()) is False
# ---------------------------------------------------------------------------
# allowed_doc_types (cache + fail-open)
# ---------------------------------------------------------------------------
class _FakeClient:
def __init__(self, payload=None, raises: Exception | None = None):
self._payload = payload
self._raises = raises
self.calls = 0
async def capabilities(self):
self.calls += 1
if self._raises is not None:
raise self._raises
return self._payload
async def test_allowed_doc_types_parses_and_caches():
clear_cache()
client = _FakeClient(_payload(["note", "file"]))
first = await allowed_doc_types(client, "alice")
second = await allowed_doc_types(client, "alice")
assert first == frozenset({"note", "file"})
assert second == frozenset({"note", "file"})
# Second call served from the cache — only one OCS round-trip.
assert client.calls == 1
async def test_allowed_doc_types_missing_block_returns_none():
clear_cache()
client = _FakeClient({"ocs": {"data": {"capabilities": {}}}})
assert await allowed_doc_types(client, "bob") is None
async def test_allowed_doc_types_fail_open_not_cached():
clear_cache()
client = _FakeClient(raises=RuntimeError("ocs down"))
assert await allowed_doc_types(client, "carol") is None
# Failures are not cached — the next call retries the OCS lookup.
assert await allowed_doc_types(client, "carol") is None
assert client.calls == 2
async def test_allowed_doc_types_cache_is_per_user():
clear_cache()
alice = _FakeClient(_payload(["note"]))
bob = _FakeClient(_payload(["file"]))
assert await allowed_doc_types(alice, "alice") == frozenset({"note"})
assert await allowed_doc_types(bob, "bob") == frozenset({"file"})
async def test_clear_cache_forces_refetch():
clear_cache()
client = _FakeClient(_payload(["note"]))
await allowed_doc_types(client, "dave")
cap.clear_cache()
await allowed_doc_types(client, "dave")
assert client.calls == 2
+138
View File
@@ -0,0 +1,138 @@
"""Unit tests for the /api/v1/vector-sync/purge admin route.
The purge is global and destructive (deletes every owner's content for a doc
type), so the route must: authenticate the bearer, restrict to Nextcloud
admins, validate the body, and only then delegate to the global purge.
"""
from unittest.mock import AsyncMock, MagicMock
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from nextcloud_mcp_server.api.vector_sync import purge_doc_types_route
pytestmark = pytest.mark.unit
def _build_app() -> Starlette:
app = Starlette(
routes=[
Route(
"/api/v1/vector-sync/purge",
purge_doc_types_route,
methods=["POST"],
)
]
)
app.state.oauth_context = {"config": {"nextcloud_host": "http://nc.test"}}
return app
def _patch_token(mocker, user_id="admin"):
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.validate_token_and_get_user",
new=AsyncMock(return_value=(user_id, {"sub": user_id})),
)
def _patch_basic_auth(mocker, username="admin"):
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.get_basic_auth_for_user",
new=AsyncMock(return_value=(username, "app-pwd")),
)
def _patch_outbound_client(mocker):
client = AsyncMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.nextcloud_httpx_client",
MagicMock(return_value=client),
)
return client
def _patch_groups(mocker, groups):
instance = MagicMock()
instance.get_user_groups = AsyncMock(return_value=groups)
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.UsersClient",
MagicMock(return_value=instance),
)
def _patch_purge(mocker, result=None):
return mocker.patch(
"nextcloud_mcp_server.api.vector_sync.purge_doc_types",
new=AsyncMock(return_value=result or {}),
)
async def test_unauthorized_when_token_invalid(mocker):
mocker.patch(
"nextcloud_mcp_server.api.vector_sync.validate_token_and_get_user",
new=AsyncMock(side_effect=ValueError("bad token")),
)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 401
purge.assert_not_called()
async def test_bad_request_when_doc_types_not_list(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": "file"})
assert resp.status_code == 400
purge.assert_not_called()
async def test_forbidden_when_not_admin(mocker):
_patch_token(mocker, "bob")
_patch_basic_auth(mocker, "bob")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["users"]) # not an admin
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 403
purge.assert_not_called()
async def test_empty_doc_types_is_noop(mocker):
_patch_token(mocker)
purge = _patch_purge(mocker)
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": []})
assert resp.status_code == 200
assert resp.json() == {"purged": {}}
purge.assert_not_called()
async def test_admin_purge_happy_path(mocker):
_patch_token(mocker, "admin")
_patch_basic_auth(mocker, "admin")
_patch_outbound_client(mocker)
_patch_groups(mocker, ["admin"])
purge = _patch_purge(mocker, {"file": 12})
client = TestClient(_build_app())
resp = client.post("/api/v1/vector-sync/purge", json={"doc_types": ["file"]})
assert resp.status_code == 200
assert resp.json() == {"purged": {"file": 12}}
purge.assert_awaited_once_with(["file"])
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for global purge-by-doc-type (admin consent enforcement)."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import nextcloud_mcp_server.vector.purge as purge_module
from nextcloud_mcp_server.vector.purge import purge_doc_types
def _patch_qdrant(monkeypatch, *, counts: dict[str, int], delete_raises=None):
"""Wire a fake Qdrant client whose ``count`` reflects ``counts`` per
doc_type (read off the filter's MatchValue) and whose ``delete`` optionally
raises for given doc_types."""
client = AsyncMock()
def _doc_type_of(flt):
return flt.must[0].match.value
async def fake_count(*, collection_name, count_filter, exact):
return SimpleNamespace(count=counts.get(_doc_type_of(count_filter), 0))
async def fake_delete(*, collection_name, points_selector):
dt = _doc_type_of(points_selector)
if delete_raises and dt in delete_raises:
raise RuntimeError(f"delete failed for {dt}")
client.count.side_effect = fake_count
client.delete.side_effect = fake_delete
async def fake_get_qdrant_client():
return client
monkeypatch.setattr(purge_module, "get_qdrant_client", fake_get_qdrant_client)
monkeypatch.setattr(
purge_module,
"get_settings",
lambda: SimpleNamespace(get_collection_name=lambda: "test_collection"),
)
return client
async def test_purges_each_doc_type_and_reports_counts(monkeypatch):
client = _patch_qdrant(monkeypatch, counts={"file": 7, "note": 3})
result = await purge_doc_types(["file", "note"])
assert result == {"file": 7, "note": 3}
assert client.delete.await_count == 2
async def test_dedupes_doc_types(monkeypatch):
client = _patch_qdrant(monkeypatch, counts={"file": 2})
result = await purge_doc_types(["file", "file"])
assert result == {"file": 2}
assert client.delete.await_count == 1
async def test_zero_points_is_safe(monkeypatch):
_patch_qdrant(monkeypatch, counts={})
assert await purge_doc_types(["deck_card"]) == {"deck_card": 0}
async def test_partial_failure_returns_partial(monkeypatch):
_patch_qdrant(
monkeypatch,
counts={"file": 5, "note": 4},
delete_raises={"note"},
)
# "note" delete fails, "file" succeeds — partial progress is returned.
result = await purge_doc_types(["file", "note"])
assert result == {"file": 5}
async def test_total_failure_raises(monkeypatch):
_patch_qdrant(monkeypatch, counts={"file": 5}, delete_raises={"file"})
with pytest.raises(RuntimeError):
await purge_doc_types(["file"])