- app.py: register /api/v1/vector-sync/purge only when vector_sync_enabled, so it returns 404 (not a 500 from get_qdrant_client) when sync is off - scanner: bound _consent_backstop_done so a long-running multi-tenant process with user churn can't grow it without limit (clears on overflow) - purge route: distinct 400 for a missing doc_types key; enforce the admin check even for an empty no-op request (destructive route) - tests: missing-key 400, admin-gated empty no-op, non-admin empty 403 The _consent_narrowed_doc_types precondition is enforced by its non-Optional frozenset[str] signature (ty rejects a None caller). The httpx.BasicAuth SonarCloud hotspot matches the existing webhook routes (false positive, credential from the app-password store) — left consistent for UI triage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
5.6 KiB
Python
155 lines
5.6 KiB
Python
"""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__)
|
|
|
|
# Upper bound on doc_types per purge request. There are only a handful of real
|
|
# indexed types; this caps a hostile/buggy caller's fan-out of count+delete
|
|
# calls without constraining legitimate use.
|
|
_MAX_PURGE_DOC_TYPES = 64
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
if not isinstance(body, dict):
|
|
return JSONResponse(
|
|
{"error": "Bad request", "message": "body must be a JSON object"},
|
|
status_code=400,
|
|
)
|
|
|
|
raw = body.get("doc_types")
|
|
if raw is None:
|
|
return JSONResponse(
|
|
{"error": "Bad request", "message": "doc_types is required"},
|
|
status_code=400,
|
|
)
|
|
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]
|
|
# Bound the batch: there are only a handful of real indexed types, so a huge
|
|
# list is abuse — cap it rather than fan out unbounded count+delete calls.
|
|
if len(doc_types) > _MAX_PURGE_DOC_TYPES:
|
|
return JSONResponse(
|
|
{
|
|
"error": "Bad request",
|
|
"message": f"doc_types exceeds the maximum of {_MAX_PURGE_DOC_TYPES}",
|
|
},
|
|
status_code=400,
|
|
)
|
|
|
|
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 —
|
|
# enforced even for an empty (no-op) request, since this is a
|
|
# destructive admin route.
|
|
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,
|
|
)
|
|
|
|
if not doc_types:
|
|
return JSONResponse({"purged": {}})
|
|
|
|
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.exception("Error purging doc types for user %s", user_id)
|
|
return JSONResponse(
|
|
{
|
|
"error": "Internal error",
|
|
"message": _sanitize_error_for_client(e, "purge_doc_types"),
|
|
},
|
|
status_code=500,
|
|
)
|