DELETE returned 204 for a library owned by another user, having deleted nothing. The caller (Daedalus) recorded the workspace as cleaned up and dropped its own row, while the Library survived holding its globally-unique name — so that name could never be reused, and nothing anywhere recorded why. An unowned library now returns 409 owner_conflict, reusing the code the create path already emits for the same condition. Ownership stays opaque on GET (404 as before): the disclosure concern is about reads, and a delete that silently does nothing is the worse failure. A genuinely absent library still returns 204 — that idempotency is relied upon and is correct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
262 lines
9.9 KiB
Python
262 lines
9.9 KiB
Python
"""
|
|
Workspace lifecycle endpoints for the Daedalus integration.
|
|
|
|
A "workspace" in Mnemosyne is a Library scoped to a Daedalus workspace UUID.
|
|
It uses the same Library node as a global library; the difference is that
|
|
`workspace_id` is set, and search must filter on it.
|
|
|
|
These endpoints are called by the Daedalus backend authenticated as the
|
|
Mnemosyne user the workspace belongs to (per-user ``UserToken``,
|
|
``Authorization: Bearer <plaintext>``, minted at ``/profile/tokens/``). The
|
|
workspace's owning user is recorded on the Library node as
|
|
``owner_username``; every read and mutation is scoped to that user.
|
|
Non-owners receive 404 so a workspace's existence isn't disclosed
|
|
across users.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from neomodel import db
|
|
from neomodel.exceptions import UniqueProperty
|
|
from rest_framework import status
|
|
from rest_framework.decorators import api_view, permission_classes
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
|
|
from library.content_types import get_library_type_config
|
|
from library.services.library_delete import delete_library_cascade
|
|
from mcp_server.drf_auth import request_token_label
|
|
|
|
from .serializers import WorkspaceCreateSerializer, WorkspaceStatusSerializer
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _serialize_workspace(lib):
|
|
"""Build a WorkspaceStatus payload from a Library node + chunk/item counts."""
|
|
counts, _ = db.cypher_query(
|
|
"MATCH (l:Library {workspace_id: $wsid}) "
|
|
"OPTIONAL MATCH (l)-[:CONTAINS]->(:Collection)-[:CONTAINS]->(i:Item) "
|
|
"OPTIONAL MATCH (i)-[:HAS_CHUNK]->(c:Chunk) "
|
|
"RETURN count(DISTINCT i) AS item_count, count(DISTINCT c) AS chunk_count",
|
|
{"wsid": lib.workspace_id},
|
|
)
|
|
item_count = counts[0][0] if counts else 0
|
|
chunk_count = counts[0][1] if counts else 0
|
|
|
|
return {
|
|
"workspace_id": lib.workspace_id,
|
|
"library_uid": lib.uid,
|
|
"name": lib.name,
|
|
"library_type": lib.library_type,
|
|
"description": lib.description or "",
|
|
"managed_by": lib.managed_by,
|
|
"item_count": item_count,
|
|
"chunk_count": chunk_count,
|
|
"created_at": lib.created_at,
|
|
}
|
|
|
|
|
|
@api_view(["POST"])
|
|
@permission_classes([IsAuthenticated])
|
|
def workspace_create(request):
|
|
"""
|
|
Create a workspace Library, idempotently.
|
|
|
|
A POST with a `workspace_id` already in use returns the existing
|
|
workspace (200) — not an error. The library_type is frozen at first
|
|
create; subsequent calls are not allowed to change it.
|
|
"""
|
|
from library.models import Library, find_library_by_name_ci
|
|
|
|
serializer = WorkspaceCreateSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
data = serializer.validated_data
|
|
|
|
# Idempotent path: workspace already exists.
|
|
try:
|
|
existing = Library.nodes.get(workspace_id=data["workspace_id"])
|
|
except Library.DoesNotExist:
|
|
existing = None
|
|
|
|
if existing is not None:
|
|
if existing.owner_username and existing.owner_username != request.user.username:
|
|
# Same workspace_id under a different owner. Don't leak the
|
|
# collision shape; surface a generic conflict.
|
|
logger.warning(
|
|
"workspace_create owner_conflict workspace_id=%s caller=%s",
|
|
data["workspace_id"], request.user.username,
|
|
)
|
|
return Response(
|
|
{
|
|
"detail": "Workspace id is already in use.",
|
|
"code": "owner_conflict",
|
|
},
|
|
status=status.HTTP_409_CONFLICT,
|
|
)
|
|
if existing.library_type != data["library_type"]:
|
|
return Response(
|
|
{
|
|
"detail": (
|
|
"library_type is immutable for an existing workspace "
|
|
f"(have '{existing.library_type}', "
|
|
f"got '{data['library_type']}')."
|
|
),
|
|
"code": "library_type_immutable",
|
|
},
|
|
status=status.HTTP_409_CONFLICT,
|
|
)
|
|
# Lazy backfill: pre-managed_by libraries pick up the label from
|
|
# the first idempotent re-POST. Null-only — an already-stamped
|
|
# library never changes manager.
|
|
if not existing.managed_by:
|
|
label = request_token_label(request)
|
|
if label:
|
|
existing.managed_by = label
|
|
existing.save()
|
|
logger.info(
|
|
"Backfilled managed_by=%s workspace_id=%s library_uid=%s",
|
|
label, existing.workspace_id, existing.uid,
|
|
)
|
|
logger.info(
|
|
"Workspace already exists workspace_id=%s library_uid=%s",
|
|
data["workspace_id"], existing.uid,
|
|
)
|
|
return Response(
|
|
WorkspaceStatusSerializer(_serialize_workspace(existing)).data,
|
|
status=status.HTTP_200_OK,
|
|
)
|
|
|
|
# New workspace: reject a name already taken by any other library,
|
|
# case-insensitively — the Neo4j index is case-sensitive, so without
|
|
# this check "amazon connect" would silently coexist with an existing
|
|
# "Amazon Connect" and confuse every name-matching client.
|
|
name_taken = find_library_by_name_ci(data["name"])
|
|
if name_taken is not None:
|
|
logger.warning(
|
|
"workspace_create name_conflict workspace_id=%s name=%s "
|
|
"existing_uid=%s",
|
|
data["workspace_id"], data["name"], name_taken.uid,
|
|
)
|
|
return Response(
|
|
{
|
|
"detail": (
|
|
f"A library named '{name_taken.name}' already exists in "
|
|
"Mnemosyne."
|
|
),
|
|
"code": "name_conflict",
|
|
},
|
|
status=status.HTTP_409_CONFLICT,
|
|
)
|
|
|
|
defaults = get_library_type_config(data["library_type"])
|
|
lib = Library(
|
|
name=data["name"],
|
|
library_type=data["library_type"],
|
|
description=data.get("description", ""),
|
|
workspace_id=data["workspace_id"],
|
|
owner_username=request.user.username,
|
|
managed_by=request_token_label(request),
|
|
chunking_config=defaults["chunking_config"],
|
|
embedding_instruction=defaults["embedding_instruction"],
|
|
reranker_instruction=defaults["reranker_instruction"],
|
|
llm_context_prompt=defaults["llm_context_prompt"],
|
|
)
|
|
try:
|
|
lib.save()
|
|
except UniqueProperty:
|
|
# Library.name is globally unique. A name collision here almost always
|
|
# means an orphaned Library survived a failed Daedalus workspace delete
|
|
# (the old node kept the name), and the recreate under a new
|
|
# workspace_id now clashes. Surface a clean 409 instead of a 500 so
|
|
# Daedalus can record + report it; the operator clears the orphan
|
|
# (admin delete) or renames the workspace.
|
|
logger.warning(
|
|
"workspace_create name_conflict workspace_id=%s name=%s",
|
|
data["workspace_id"], data["name"],
|
|
)
|
|
return Response(
|
|
{
|
|
"detail": (
|
|
f"A library named '{data['name']}' already exists in "
|
|
"Mnemosyne."
|
|
),
|
|
"code": "name_conflict",
|
|
},
|
|
status=status.HTTP_409_CONFLICT,
|
|
)
|
|
logger.info(
|
|
"Workspace created workspace_id=%s library_uid=%s library_type=%s",
|
|
data["workspace_id"], lib.uid, lib.library_type,
|
|
)
|
|
|
|
return Response(
|
|
WorkspaceStatusSerializer(_serialize_workspace(lib)).data,
|
|
status=status.HTTP_201_CREATED,
|
|
)
|
|
|
|
|
|
@api_view(["GET", "DELETE"])
|
|
@permission_classes([IsAuthenticated])
|
|
def workspace_detail_or_delete(request, workspace_id):
|
|
"""
|
|
GET: return workspace status (item/chunk counts, metadata).
|
|
|
|
DELETE: delete the workspace Library and everything reachable AND unique
|
|
to it. Concept-safe: orphan-only Concept GC happens at the end.
|
|
Concepts referenced by other libraries (workspace or global) are preserved.
|
|
"""
|
|
from library.models import Library
|
|
|
|
try:
|
|
lib = Library.nodes.get(workspace_id=workspace_id)
|
|
except Library.DoesNotExist:
|
|
lib = None
|
|
|
|
# Cross-user reads look like "not found" — don't disclose existence
|
|
# across users. DELETE is handled separately below: telling the caller
|
|
# "deleted" about a library we did not touch is what silently orphans
|
|
# libraries, and an orphan holds its globally-unique name forever.
|
|
unowned = lib is not None and lib.owner_username != request.user.username
|
|
|
|
if request.method == "GET":
|
|
if lib is None or unowned:
|
|
return Response(
|
|
{"detail": "Workspace not found."},
|
|
status=status.HTTP_404_NOT_FOUND,
|
|
)
|
|
return Response(WorkspaceStatusSerializer(_serialize_workspace(lib)).data)
|
|
|
|
# DELETE — idempotent only where nothing exists to delete.
|
|
if lib is None:
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
|
|
|
if unowned:
|
|
# Still opaque about ownership, but never a false success: the
|
|
# caller must not record this workspace as cleaned up.
|
|
logger.warning(
|
|
"workspace_delete owner_conflict workspace_id=%s library_uid=%s "
|
|
"caller=%s",
|
|
workspace_id, lib.uid, request.user.username,
|
|
)
|
|
return Response(
|
|
{
|
|
"detail": "Workspace id is already in use.",
|
|
"code": "owner_conflict",
|
|
},
|
|
status=status.HTTP_409_CONFLICT,
|
|
)
|
|
|
|
# Delete the Library and everything reachable + unique to it, plus
|
|
# orphan-Concept GC. Shared with the admin/HTML delete path.
|
|
result = delete_library_cascade(lib)
|
|
|
|
logger.info(
|
|
"Workspace deleted workspace_id=%s library_uid=%s name=%s "
|
|
"items=%d orphans_deleted=%d",
|
|
workspace_id, result["library_uid"], result["name"],
|
|
result["item_count"], result["orphans_deleted"],
|
|
)
|
|
|
|
return Response(status=status.HTTP_204_NO_CONTENT)
|