4 Commits

Author SHA1 Message Date
f6e8e9bba5 🐾 fix(api): treat a NULL owner as unclaimed, not foreign
The previous commit's guard rejected any library whose owner_username didn't
match the caller — including NULL. On the live instance 12 of 16 workspace
libraries have no owner (created before owner stamping, or via the plain
/libraries/ endpoint, which never sets one), so that turned a silent false
204 into a hard 409 and made them undeletable. Strictly worse: the orphan
became permanent rather than merely unrecorded.

A NULL owner is unclaimed. Only a *different* non-null owner is foreign, and
only that returns 409.

Verified against live Neo4j on umbriel with throwaway libraries: NULL owner
deletes (204, node gone), caller-owned deletes (204, node gone), other-owned
is refused (409, node survives), absent stays 204. Test data cleaned up; the
30 real libraries were untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 18:32:23 -04:00
e10331dd44 🐾 fix(api): workspace DELETE must not report success it didn't perform
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>
2026-08-07 13:03:32 -04:00
b5fba26d4c Merge pull request '🐾 fix(api): cascade collection and item deletes too' (#10) from fix/collection-item-cascade into main
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 4m24s
Build & Deploy Docs / build-and-deploy (push) Successful in 1m12s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m38s
Reviewed-on: #10
2026-08-03 17:13:46 +00:00
15a517e389 🐾 fix(api): cascade collection and item deletes too
Collection and Item DELETE — both the REST endpoints and the HTML
views — called bare .delete(), leaking each collection's Items and every
item's Chunks/Images/ImageEmbeddings. New delete_collection_cascade and
delete_item_cascade in the shared library_delete service now back all
four paths. No orphan-Concept GC in either (that invariant stays
library-delete-only, matching the existing supersede path); the ingest
supersede helper in tasks.py now delegates to delete_item_cascade
instead of duplicating its Cypher.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 13:12:25 -04:00
7 changed files with 367 additions and 36 deletions

View File

@@ -18,7 +18,11 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from library.content_types import get_library_type_config from library.content_types import get_library_type_config
from library.services.library_delete import delete_library_cascade from library.services.library_delete import (
delete_collection_cascade,
delete_item_cascade,
delete_library_cascade,
)
from mcp_server.drf_auth import request_token_label from mcp_server.drf_auth import request_token_label
from .serializers import ( from .serializers import (
@@ -273,7 +277,13 @@ def collection_detail(request, uid):
col.save() col.save()
return Response(CollectionSerializer(col).data) return Response(CollectionSerializer(col).data)
col.delete() # DELETE — cascade Items/Chunks/Images too; a bare col.delete() leaks them.
result = delete_collection_cascade(col)
logger.info(
"Collection deleted via API collection_uid=%s name=%s items=%d caller=%s",
result["collection_uid"], result["name"], result["item_count"],
request.user.username,
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@@ -354,7 +364,13 @@ def item_detail(request, uid):
item.save() item.save()
return Response(ItemSerializer(item).data) return Response(ItemSerializer(item).data)
item.delete() # DELETE — cascade Chunks/Images/embeddings too; a bare item.delete()
# leaks them.
delete_item_cascade(item.uid)
logger.info(
"Item deleted via API item_uid=%s caller=%s",
item.uid, request.user.username,
)
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -213,23 +213,50 @@ def workspace_detail_or_delete(request, workspace_id):
except Library.DoesNotExist: except Library.DoesNotExist:
lib = None lib = None
# Cross-user reads/writes look like "not found" — don't disclose # A NULL owner_username is *unclaimed*, not someone else's: workspace
# existence across users. # libraries created before owner stamping (and any created through the
if lib is not None and lib.owner_username != request.user.username: # plain /libraries/ endpoint, which never sets it) have no owner. Treating
lib = None # those as foreign would make them permanently undeletable through this
# endpoint — the very orphans this view is trying not to create.
#
# Cross-user reads still look like "not found"; DELETE is handled
# separately below, because 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.
foreign = (
lib is not None
and lib.owner_username
and lib.owner_username != request.user.username
)
if request.method == "GET": if request.method == "GET":
if lib is None: if lib is None or foreign:
return Response( return Response(
{"detail": "Workspace not found."}, {"detail": "Workspace not found."},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
return Response(WorkspaceStatusSerializer(_serialize_workspace(lib)).data) return Response(WorkspaceStatusSerializer(_serialize_workspace(lib)).data)
# DELETE — idempotent: a missing (or unowned) workspace returns 204. # DELETE — idempotent only where nothing exists to delete.
if lib is None: if lib is None:
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
if foreign:
# 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 # Delete the Library and everything reachable + unique to it, plus
# orphan-Concept GC. Shared with the admin/HTML delete path. # orphan-Concept GC. Shared with the admin/HTML delete path.
result = delete_library_cascade(lib) result = delete_library_cascade(lib)

View File

@@ -106,3 +106,99 @@ def delete_library_cascade(lib) -> dict:
"item_s3_keys": item_s3_keys, "item_s3_keys": item_s3_keys,
"orphans_deleted": orphans_deleted, "orphans_deleted": orphans_deleted,
} }
def delete_collection_cascade(col) -> dict:
"""Delete ``col`` and all content reachable and unique to it.
Removes the Collection's Items with their Chunks, Images, and
ImageEmbeddings, then the Collection itself. No orphan-Concept GC —
that invariant belongs to library-level deletes only (see
:func:`delete_library_cascade`); Concepts orphaned here are collected
on the next library delete.
:param col: A ``library.models.Collection`` node instance.
:returns: Dict with ``collection_uid``, ``name``, ``item_count``, and
``item_s3_keys`` (list of ``(uid, s3_key)`` for async S3 cleanup).
"""
collection_uid = col.uid
collection_name = col.name
s3_rows, _ = db.cypher_query(
"MATCH (col:Collection {uid: $uid})-[:CONTAINS]->(i:Item) "
"RETURN i.uid, i.s3_key",
{"uid": collection_uid},
)
item_s3_keys = [(r[0], r[1]) for r in s3_rows if r[1]]
db.cypher_query(
"""
MATCH (col:Collection {uid: $uid})-[:CONTAINS]->(i:Item)
-[:HAS_CHUNK]->(c:Chunk)
DETACH DELETE c
""",
{"uid": collection_uid},
)
db.cypher_query(
"""
MATCH (col:Collection {uid: $uid})-[:CONTAINS]->(i:Item)
-[:HAS_IMAGE]->(img:Image)
OPTIONAL MATCH (img)-[:HAS_EMBEDDING]->(emb:ImageEmbedding)
DETACH DELETE img, emb
""",
{"uid": collection_uid},
)
db.cypher_query(
"""
MATCH (col:Collection {uid: $uid})-[:CONTAINS]->(i:Item)
DETACH DELETE i
""",
{"uid": collection_uid},
)
db.cypher_query(
"MATCH (col:Collection {uid: $uid}) DETACH DELETE col",
{"uid": collection_uid},
)
logger.info(
"Collection cascade-deleted collection_uid=%s name=%s items=%d",
collection_uid, collection_name, len(item_s3_keys),
)
return {
"collection_uid": collection_uid,
"name": collection_name,
"item_count": len(item_s3_keys),
"item_s3_keys": item_s3_keys,
}
def delete_item_cascade(item_uid: str) -> dict:
"""Delete the Item ``item_uid`` with its Chunks, Images, and embeddings.
Keyed on the uid (not a node instance) so the ingest supersede path in
``library.tasks`` can share it. No orphan-Concept GC — see
:func:`delete_collection_cascade`.
:returns: Dict with ``item_uid`` and ``s3_key`` (empty string when the
item had no stored file) for async S3 cleanup.
"""
s3_rows, _ = db.cypher_query(
"MATCH (i:Item {uid: $uid}) RETURN i.s3_key",
{"uid": item_uid},
)
s3_key = (s3_rows[0][0] or "") if s3_rows else ""
db.cypher_query(
"""
MATCH (i:Item {uid: $uid})
OPTIONAL MATCH (i)-[:HAS_CHUNK]->(c:Chunk)
OPTIONAL MATCH (i)-[:HAS_IMAGE]->(img:Image)
OPTIONAL MATCH (img)-[:HAS_EMBEDDING]->(emb:ImageEmbedding)
DETACH DELETE c, img, emb, i
""",
{"uid": item_uid},
)
logger.info("Item cascade-deleted item_uid=%s", item_uid)
return {"item_uid": item_uid, "s3_key": s3_key}

View File

@@ -503,16 +503,9 @@ def ingest_from_daedalus(self, job_id: str):
def _delete_item_and_chunks(item_uid: str): def _delete_item_and_chunks(item_uid: str):
"""Delete an Item, its chunks, and its images. Concept GC is workspace-delete only.""" """Delete an Item, its chunks, and its images. Concept GC is workspace-delete only."""
db.cypher_query( from library.services.library_delete import delete_item_cascade
"""
MATCH (i:Item {uid: $uid}) delete_item_cascade(item_uid)
OPTIONAL MATCH (i)-[:HAS_CHUNK]->(c:Chunk)
OPTIONAL MATCH (i)-[:HAS_IMAGE]->(img:Image)
OPTIONAL MATCH (img)-[:HAS_EMBEDDING]->(emb:ImageEmbedding)
DETACH DELETE c, img, emb, i
""",
{"uid": item_uid},
)
def _resolve_or_create_default_collection(lib, collection_uid: str = ""): def _resolve_or_create_default_collection(lib, collection_uid: str = ""):

View File

@@ -1,9 +1,9 @@
"""Tests for the plain library REST endpoints beyond create. """Tests for the plain library REST endpoints beyond create.
Currently covers the DELETE cascade: ``DELETE /library/api/libraries/{uid}/`` Currently covers the DELETE cascades: library, collection, and item DELETE
must go through ``delete_library_cascade`` (shared with the HTML and endpoints must go through the shared ``library_delete`` service functions —
workspace delete paths) — a bare ``lib.delete()`` leaks Collections, Items, bare ``.delete()`` calls leak child nodes (Collections, Items, Chunks,
Chunks, and Images and skips orphan-Concept GC. Neo4j is stubbed via Images) and, for libraries, skip orphan-Concept GC. Neo4j is stubbed via
``sys.modules``, same style as ``test_managed_by.py``. ``sys.modules``, same style as ``test_managed_by.py``.
""" """
@@ -19,6 +19,23 @@ from rest_framework.test import APIClient
User = get_user_model() User = get_user_model()
def _fake_node_cls(instance):
"""A neomodel-class stand-in whose ``nodes.get`` returns ``instance``.
``instance=None`` makes ``nodes.get`` raise the class's DoesNotExist.
"""
fake_nodes = MagicMock()
class DoesNotExist(Exception):
pass
if instance is None:
fake_nodes.get.side_effect = DoesNotExist()
else:
fake_nodes.get.return_value = instance
return SimpleNamespace(nodes=fake_nodes, DoesNotExist=DoesNotExist)
class LibraryApiDeleteTests(TestCase): class LibraryApiDeleteTests(TestCase):
"""DELETE on the plain library endpoint cascades.""" """DELETE on the plain library endpoint cascades."""
@@ -28,17 +45,7 @@ class LibraryApiDeleteTests(TestCase):
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
def _fake_models_module(self, lib): def _fake_models_module(self, lib):
fake_nodes = MagicMock() return SimpleNamespace(Library=_fake_node_cls(lib))
if lib is None:
class DoesNotExist(Exception):
pass
fake_library = SimpleNamespace(nodes=fake_nodes, DoesNotExist=DoesNotExist)
fake_nodes.get.side_effect = DoesNotExist()
else:
fake_library = SimpleNamespace(nodes=fake_nodes, DoesNotExist=Exception)
fake_nodes.get.return_value = lib
return SimpleNamespace(Library=fake_library)
def test_delete_uses_shared_cascade(self): def test_delete_uses_shared_cascade(self):
lib = SimpleNamespace(uid="lib-1", name="Docs") lib = SimpleNamespace(uid="lib-1", name="Docs")
@@ -68,3 +75,74 @@ class LibraryApiDeleteTests(TestCase):
self.assertEqual(response.status_code, 404) self.assertEqual(response.status_code, 404)
mock_cascade.assert_not_called() mock_cascade.assert_not_called()
class CollectionApiDeleteTests(TestCase):
"""DELETE on the collection endpoint cascades Items/Chunks/Images."""
def setUp(self):
self.user = User.objects.create_user(username="op", password="pw")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
def test_delete_uses_shared_cascade(self):
col = SimpleNamespace(uid="col-1", name="Default")
with patch.dict(
"sys.modules",
{"library.models": SimpleNamespace(Collection=_fake_node_cls(col))},
), patch(
"library.api.views.delete_collection_cascade",
return_value={
"collection_uid": "col-1",
"name": "Default",
"item_count": 2,
"item_s3_keys": [],
},
) as mock_cascade:
response = self.client.delete("/library/api/collections/col-1/")
self.assertEqual(response.status_code, 204)
mock_cascade.assert_called_once_with(col)
def test_delete_missing_collection_returns_404(self):
with patch.dict(
"sys.modules",
{"library.models": SimpleNamespace(Collection=_fake_node_cls(None))},
), patch("library.api.views.delete_collection_cascade") as mock_cascade:
response = self.client.delete("/library/api/collections/nope/")
self.assertEqual(response.status_code, 404)
mock_cascade.assert_not_called()
class ItemApiDeleteTests(TestCase):
"""DELETE on the item endpoint cascades Chunks/Images/embeddings."""
def setUp(self):
self.user = User.objects.create_user(username="op", password="pw")
self.client = APIClient()
self.client.force_authenticate(user=self.user)
def test_delete_uses_shared_cascade(self):
item = SimpleNamespace(uid="item-1", title="Doc")
with patch.dict(
"sys.modules",
{"library.models": SimpleNamespace(Item=_fake_node_cls(item))},
), patch(
"library.api.views.delete_item_cascade",
return_value={"item_uid": "item-1", "s3_key": ""},
) as mock_cascade:
response = self.client.delete("/library/api/items/item-1/")
self.assertEqual(response.status_code, 204)
mock_cascade.assert_called_once_with("item-1")
def test_delete_missing_item_returns_404(self):
with patch.dict(
"sys.modules",
{"library.models": SimpleNamespace(Item=_fake_node_cls(None))},
), patch("library.api.views.delete_item_cascade") as mock_cascade:
response = self.client.delete("/library/api/items/nope/")
self.assertEqual(response.status_code, 404)
mock_cascade.assert_not_called()

View File

@@ -7,6 +7,9 @@ search scoping) require Neo4j and are validated by the manual end-to-end
test plan, not these unit tests. test plan, not these unit tests.
""" """
from unittest.mock import Mock, patch
from django.contrib.auth.models import User
from django.test import TestCase from django.test import TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -208,3 +211,113 @@ class WorkspaceEndpointAuthTests(TestCase):
def test_workspace_delete_requires_auth(self): def test_workspace_delete_requires_auth(self):
response = self.client.delete("/library/api/workspaces/ws_a/") response = self.client.delete("/library/api/workspaces/ws_a/")
self.assertIn(response.status_code, [401, 403]) self.assertIn(response.status_code, [401, 403])
class WorkspaceDeleteOwnershipTests(TestCase):
"""DELETE must never report success for a library it did not delete.
A false 204 is how libraries get orphaned: Daedalus records the
workspace as cleaned up and drops its own row, while the Library node
survives holding its globally-unique name forever — which then blocks
ever recreating a workspace under that name.
"""
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(
username="owner", password="pw" # noqa: S106 — test credential
)
self.client.force_authenticate(user=self.user)
def _library(self, owner_username):
lib = Mock()
lib.uid = "lib_1"
lib.owner_username = owner_username
return lib
def test_absent_library_still_returns_204(self):
"""Genuine idempotency is preserved — nothing exists, nothing to do."""
from library.models import Library
with patch(
"neomodel.sync_.match.NodeSet.get",
side_effect=Library.DoesNotExist("nope"),
):
response = self.client.delete("/library/api/workspaces/ws_gone/")
self.assertEqual(response.status_code, 204)
def test_unowned_library_returns_409_and_is_not_deleted(self):
from library.models import Library
lib = self._library("someone_else")
with (
patch("neomodel.sync_.match.NodeSet.get", return_value=lib),
patch(
"library.api.workspaces.delete_library_cascade"
) as cascade,
):
response = self.client.delete("/library/api/workspaces/ws_a/")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["code"], "owner_conflict")
cascade.assert_not_called()
def test_owned_library_is_deleted(self):
from library.models import Library
lib = self._library("owner")
with (
patch("neomodel.sync_.match.NodeSet.get", return_value=lib),
patch(
"library.api.workspaces.delete_library_cascade",
return_value={
"library_uid": "lib_1",
"name": "Assistant",
"item_count": 0,
"orphans_deleted": 0,
},
) as cascade,
):
response = self.client.delete("/library/api/workspaces/ws_a/")
self.assertEqual(response.status_code, 204)
cascade.assert_called_once_with(lib)
def test_unclaimed_library_is_deleted(self):
"""A NULL owner is unclaimed, not foreign.
Workspace libraries predating owner stamping (and any created via the
plain /libraries/ endpoint, which never sets an owner) have
owner_username=None. Refusing those would make them permanently
undeletable through this endpoint — orphans by construction.
"""
from library.models import Library
lib = self._library(None)
with (
patch("neomodel.sync_.match.NodeSet.get", return_value=lib),
patch(
"library.api.workspaces.delete_library_cascade",
return_value={
"library_uid": "lib_1",
"name": "Assistant",
"item_count": 0,
"orphans_deleted": 0,
},
) as cascade,
):
response = self.client.delete("/library/api/workspaces/ws_a/")
self.assertEqual(response.status_code, 204)
cascade.assert_called_once_with(lib)
def test_unowned_library_get_still_looks_absent(self):
"""Ownership must stay opaque on reads — 404, not 409."""
from library.models import Library
lib = self._library("someone_else")
with patch("neomodel.sync_.match.NodeSet.get", return_value=lib):
response = self.client.get("/library/api/workspaces/ws_a/")
self.assertEqual(response.status_code, 404)

View File

@@ -460,7 +460,11 @@ def collection_delete(request, uid):
if request.method == "POST": if request.method == "POST":
name = col.name name = col.name
col.delete() # Shared cascade so Items/Chunks/Images go too — a bare
# col.delete() would leak them.
from .services.library_delete import delete_collection_cascade
delete_collection_cascade(col)
messages.success(request, f'Collection "{name}" deleted.') messages.success(request, f'Collection "{name}" deleted.')
return redirect("library:library-list") return redirect("library:library-list")
return render( return render(
@@ -648,7 +652,11 @@ def item_delete(request, uid):
if request.method == "POST": if request.method == "POST":
title = item.title title = item.title
item.delete() # Shared cascade so Chunks/Images/embeddings go too — a bare
# item.delete() would leak them.
from .services.library_delete import delete_item_cascade
delete_item_cascade(item.uid)
messages.success(request, f'Item "{title}" deleted.') messages.success(request, f'Item "{title}" deleted.')
return redirect("library:library-list") return redirect("library:library-list")
return render(request, "library/item_confirm_delete.html", {"item": item}) return render(request, "library/item_confirm_delete.html", {"item": item})