(backend) detach restricted folders by deleting their shortcut

Deleting a shortcut removes the entry from the containing folder
without trashing anything. An owner excluded from the target acts
on the container only and can neither destroy, declassify nor read
it: the folder keeps its accesses, stays restricted, and surfaces
in its members' top-level listing.
This commit is contained in:
Nicolas Clerc
2026-08-13 17:53:37 +02:00
parent 07c4ba831d
commit d4c69a08ca
4 changed files with 86 additions and 1 deletions
+4 -1
View File
@@ -689,7 +689,10 @@ class ItemViewSet(
def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
if instance.type == models.ItemTypeChoices.SHORTCUT:
instance.detach()
else:
instance.soft_delete()
def perform_update(self, serializer):
"""Override to check if a file is renamed in order to rename file on storage."""
+14
View File
@@ -1683,6 +1683,20 @@ class Item(TreeModel, BaseModel):
self.link_reach = None
self.save(update_fields=["link_reach"])
def detach(self):
"""Delete this shortcut row, leaving its restricted target untouched."""
if self.type != ItemTypeChoices.SHORTCUT:
raise ValidationError(
{
"type": ValidationError(
_("Only shortcuts can be detached"),
code="item_detach_not_a_shortcut",
)
}
)
self._meta.model.objects.filter(pk=self.pk).delete()
@transaction.atomic
def unrestrict(self):
"""Lift restriction and reattach the folder at its shortcut location."""
@@ -124,6 +124,49 @@ def test_api_items_shortcuts_non_shortcut_target_is_none():
assert response.json()["target"] is None
def test_api_items_shortcuts_delete_detaches_the_target():
"""Deleting a shortcut detaches the restricted folder without touching it."""
parent_owner = factories.UserFactory()
owner = factories.UserFactory()
parent = factories.ItemFactory(
type=models.ItemTypeChoices.FOLDER,
users=[(parent_owner, "owner")],
)
folder = _create_restricted_folder(parent, owner)
shortcut = folder.shortcut
client = APIClient()
client.force_login(parent_owner)
response = client.delete(f"/api/v1.0/items/{shortcut.id!s}/")
assert response.status_code == 204
assert not models.Item.objects.filter(pk=shortcut.pk).exists()
folder.refresh_from_db()
assert folder.is_restricted is True
assert folder.deleted_at is None
assert models.ItemAccess.objects.filter(item=folder, user=owner, role="owner").exists()
def test_api_items_shortcuts_delete_forbidden_for_reader():
"""A reader of the containing folder cannot detach a shortcut."""
reader = factories.UserFactory()
parent = factories.ItemFactory(
type=models.ItemTypeChoices.FOLDER,
users=[(reader, "reader")],
)
folder = _create_restricted_folder(parent, factories.UserFactory())
shortcut = folder.shortcut
client = APIClient()
client.force_login(reader)
response = client.delete(f"/api/v1.0/items/{shortcut.id!s}/")
assert response.status_code == 403
assert models.Item.objects.filter(pk=shortcut.pk).exists()
def test_api_items_shortcuts_children_list_constant_queries():
"""The number of queries does not grow with the number of shortcuts listed."""
parent_owner = factories.UserFactory()
@@ -63,6 +63,31 @@ def test_models_items_shortcuts_move_rejects_own_target_subtree():
shortcut.move(folder)
def test_models_items_shortcuts_detach_deletes_the_row():
"""Detaching a shortcut deletes its row and leaves the target untouched."""
user = factories.UserFactory()
parent = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER)
folder = factories.ItemFactory(parent=parent, type=models.ItemTypeChoices.FOLDER)
folder = folder.restrict(user)
shortcut = folder.shortcut
shortcut.detach()
assert not models.Item.objects.filter(pk=shortcut.pk).exists()
folder.refresh_from_db()
assert folder.is_restricted is True
assert str(folder.path) == str(folder.id)
assert models.ItemAccess.objects.filter(item=folder, user=user, role="owner").exists()
def test_models_items_shortcuts_detach_rejects_other_types():
"""Only shortcuts can be detached."""
folder = factories.ItemFactory(type=models.ItemTypeChoices.FOLDER)
with pytest.raises(ValidationError, match="Only shortcuts can be detached"):
folder.detach()
def test_models_items_shortcuts_item_factory_never_generates_shortcuts():
"""The generic item factory should only draw folder and file types."""
types = {factories.ItemFactory().type for _ in range(20)}