diff --git a/src/documents/tests/test_tag_hierarchy.py b/src/documents/tests/test_tag_hierarchy.py index 5384fd187a..b7416c1424 100644 --- a/src/documents/tests/test_tag_hierarchy.py +++ b/src/documents/tests/test_tag_hierarchy.py @@ -12,11 +12,73 @@ from documents.models import WorkflowTrigger from documents.serialisers import TagSerializer from documents.signals.handlers import run_workflows from paperless_testing.dirs import DirectoriesMixin +from paperless_testing.factories import DocumentFactory +from paperless_testing.factories import TagFactory from paperless_testing.factories import UserFactory from paperless_testing.permissions import grant_global +from paperless_testing.permissions import grant_object class TestTagHierarchyPermissions(APITestCase): + def test_delete_checks_descendant_permissions(self) -> None: + for mode in ("detail", "bulk", "all"): + for ownership in ("owned", "unowned", "private", "shared", "superuser"): + with self.subTest(mode=mode, ownership=ownership): + requester = UserFactory(superuser=ownership == "superuser") + grant_global(requester, "view_tag", "delete_tag") + self.client.force_authenticate(user=requester) + if ownership == "owned": + descendant_owner = requester + elif ownership == "unowned": + descendant_owner = None + else: + descendant_owner = UserFactory() + parent = TagFactory(owner=requester) + child = TagFactory(tn_parent=parent) + grandchild = TagFactory( + owner=descendant_owner, + tn_parent=child, + ) + if ownership == "shared": + grant_object(requester, grandchild, "delete_tag") + document = DocumentFactory() + document.tags.add(grandchild) + ids = [parent.pk, child.pk, grandchild.pk] + + if mode == "detail": + response = self.client.delete(f"/api/tags/{parent.pk}/") + else: + selection = ( + {"all": True, "filters": {"id__in": str(parent.pk)}} + if mode == "all" + else {"objects": [parent.pk]} + ) + response = self.client.post( + "/api/bulk_edit_objects/", + {"object_type": "tags", "operation": "delete", **selection}, + format="json", + ) + + # Detail deletion accepts explicit delete grants; bulk + # deletion requires ownership (or an unowned object). + allowed = ownership in ("owned", "unowned", "superuser") or ( + ownership == "shared" and mode == "detail" + ) + expected_status = status.HTTP_403_FORBIDDEN + if allowed: + expected_status = ( + status.HTTP_204_NO_CONTENT + if mode == "detail" + else status.HTTP_200_OK + ) + assert response.status_code == expected_status + assert Tag.objects.filter(pk__in=ids).count() == ( + 0 if allowed else 3 + ) + assert document.tags.filter(pk=grandchild.pk).exists() == ( + not allowed + ) + def test_children_only_include_visible_tags(self) -> None: owner = UserFactory(username="owner") requester = UserFactory(username="requester") diff --git a/src/documents/views.py b/src/documents/views.py index aeed498cbb..ded4ee5d03 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -576,6 +576,15 @@ class CorrespondentViewSet( return super().retrieve(request, *args, **kwargs) +def _with_tag_descendants(tags: QuerySet[Tag]) -> QuerySet[Tag]: + """Include every tag that deleting the selected tags will affect.""" + tag_ids = set() + for tag in tags: + tag_ids.add(tag.pk) + tag_ids.update(tag.get_descendants_pks()) + return Tag.objects.filter(pk__in=tag_ids) + + @extend_schema_view(**generate_object_with_permissions_schema(TagSerializer)) class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]): model = Tag @@ -597,6 +606,14 @@ class TagViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[Tag]): filterset_class = TagFilterSet ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count") + def perform_destroy(self, instance): + tags = _with_tag_descendants(Tag.objects.filter(pk=instance.pk)) + if tags.exclude( + pk__in=permitted_object_ids(self.request.user, Tag, "delete_tag"), + ).exists(): + raise PermissionDenied(_("Insufficient permissions.")) + super().perform_destroy(instance) + def get_serializer_context(self): context = super().get_serializer_context() context["document_count_filter"] = self.get_document_count_filter() @@ -5075,6 +5092,9 @@ class BulkEditObjectsView(PassUserMixin): pk__in=object_ids, ) + if object_type == "tags" and operation == "delete": + objs = _with_tag_descendants(objs) + if not user.is_superuser: perm = f"documents.{perm_codename}" # Limited to the owner (or unowned), same as documents, see BulkEditView