Explorar o código

Return a 403 when attempting to alter non-permitted objects

Jeremy Stretch hai 2 semanas
pai
achega
d384136045

+ 23 - 0
netbox/core/api/schema.py

@@ -204,6 +204,13 @@ class NetBoxAutoSchema(AutoSchema):
                         "modified: a bulk update is an all-or-none operation."
                         "modified: a bulk update is an all-or-none operation."
                     ),
                     ),
                 ),
                 ),
+                '403': OpenApiResponse(
+                    response=BulkOperationErrorSerializer,
+                    description=_(
+                        "The requesting user is not permitted to apply one or more of the "
+                        "modifications specified. No objects were modified."
+                    ),
+                ),
             }
             }
 
 
         if action == 'bulk_destroy':
         if action == 'bulk_destroy':
@@ -216,6 +223,13 @@ class NetBoxAutoSchema(AutoSchema):
                         "rule. No objects were deleted."
                         "rule. No objects were deleted."
                     ),
                     ),
                 ),
                 ),
+                '403': OpenApiResponse(
+                    response=BulkOperationErrorSerializer,
+                    description=_(
+                        "The requesting user is not permitted to delete one or more of the objects "
+                        "specified. No objects were deleted."
+                    ),
+                ),
                 '409': OpenApiResponse(
                 '409': OpenApiResponse(
                     response=BulkOperationErrorSerializer,
                     response=BulkOperationErrorSerializer,
                     description=_(
                     description=_(
@@ -244,6 +258,15 @@ class NetBoxAutoSchema(AutoSchema):
                         "were created: a bulk creation is an all-or-none operation."
                         "were created: a bulk creation is an all-or-none operation."
                     ),
                     ),
                 ),
                 ),
+                # A 403 always carries a `detail`, and BulkOperationError's `errors` is optional, so
+                # the one component covers both the single-object and the bulk shape here.
+                '403': OpenApiResponse(
+                    response=BulkOperationErrorSerializer,
+                    description=_(
+                        "The requesting user is not permitted to create one or more of the objects "
+                        "specified. No objects were created."
+                    ),
+                ),
             }
             }
 
 
         return {}
         return {}

+ 18 - 2
netbox/core/tests/test_openapi_schema.py

@@ -154,8 +154,8 @@ class OpenAPISchemaTestCase(TestCase):
 
 
     def test_bulk_delete_documents_error_responses(self):
     def test_bulk_delete_documents_error_responses(self):
         """
         """
-        Bulk delete operations should document both the 400 (unresolvable request) and the 409
-        (dependency or protection rule) responses.
+        Bulk delete operations should document the 400 (unresolvable request or protection rule), the
+        403 (not permitted) and the 409 (dependent object) responses.
 
 
         Refs: #20054
         Refs: #20054
         """
         """
@@ -164,8 +164,23 @@ class OpenAPISchemaTestCase(TestCase):
         for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
         for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
             with self.subTest(path=path):
             with self.subTest(path=path):
                 self.assertEqual(self._get_response_schema(path, 'delete', '400'), ref)
                 self.assertEqual(self._get_response_schema(path, 'delete', '400'), ref)
+                self.assertEqual(self._get_response_schema(path, 'delete', '403'), ref)
                 self.assertEqual(self._get_response_schema(path, 'delete', '409'), ref)
                 self.assertEqual(self._get_response_schema(path, 'delete', '409'), ref)
 
 
+    def test_bulk_write_operations_document_forbidden_response(self):
+        """
+        Every bulk write should document the 403 returned when an object-level permission refuses one
+        of the objects specified.
+
+        Refs: #20054
+        """
+        ref = {'$ref': '#/components/schemas/BulkOperationError'}
+
+        for path in ('/api/dcim/sites/', '/api/ipam/prefixes/', '/api/users/users/'):
+            for method in ('post', 'put', 'patch', 'delete'):
+                with self.subTest(path=path, method=method):
+                    self.assertEqual(self._get_response_schema(path, method, '403'), ref)
+
     def test_create_documents_error_response_for_either_shape(self):
     def test_create_documents_error_response_for_either_shape(self):
         """
         """
         A POST to a list endpoint accepts either a single object or a list, so its 400 response
         A POST to a list endpoint accepts either a single object or a list, so its 400 response
@@ -196,6 +211,7 @@ class OpenAPISchemaTestCase(TestCase):
             with self.subTest(method=method):
             with self.subTest(method=method):
                 responses = self.schema['paths'][path][method]['responses']
                 responses = self.schema['paths'][path][method]['responses']
                 self.assertNotIn('409', responses)
                 self.assertNotIn('409', responses)
+                self.assertNotIn('403', responses)
                 for code, response in responses.items():
                 for code, response in responses.items():
                     schema = response.get('content', {}).get('application/json', {}).get('schema', {})
                     schema = response.get('content', {}).get('application/json', {}).get('schema', {})
                     self.assertNotEqual(
                     self.assertNotEqual(

+ 106 - 0
netbox/dcim/tests/test_api.py

@@ -25,6 +25,7 @@ from utilities.testing import (
     create_test_device,
     create_test_device,
     create_test_nat_ip_pair,
     create_test_nat_ip_pair,
     disable_logging,
     disable_logging,
+    disable_warnings,
 )
 )
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
 from wireless.choices import WirelessChannelChoices
 from wireless.choices import WirelessChannelChoices
@@ -739,6 +740,111 @@ class SiteTestCase(APIViewTestCases.APIViewTestCase):
         self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted')
         self.assertTrue(Site.objects.filter(pk=site1.pk).exists(), 'Site 1 should not have been deleted')
         self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted')
         self.assertTrue(Site.objects.filter(pk=site2.pk).exists(), 'Site 2 should not have been deleted')
 
 
+    def test_bulk_update_objects_permission_constraint(self):
+        """
+        PATCH a set of objects where the update would move one of them outside the requesting user's
+        object-level permissions. The offending object must be named, rather than the whole batch
+        failing with an opaque 403, and nothing may be modified.
+        """
+        site1 = Site.objects.get(slug='site-1')
+        site2 = Site.objects.get(slug='site-2')
+        Site.objects.filter(pk__in=(site1.pk, site2.pk)).update(status=SiteStatusChoices.STATUS_ACTIVE)
+
+        # Only active sites may be changed, so setting Site 2's status to "planned" saves the object
+        # and then fails _validate_objects(), which perform_update() reports as PermissionDenied.
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['change'],
+            constraints={'status': SiteStatusChoices.STATUS_ACTIVE},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+        data = [
+            {'id': site1.pk, 'description': 'Permitted'},
+            {'id': site2.pk, 'status': SiteStatusChoices.STATUS_PLANNED},
+        ]
+        with disable_warnings('django.request'):
+            response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
+
+        # Still a 403, as the single-object endpoint returns, but now correlated
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        self.assertIn('detail', response.data)
+        self.assertEqual([e['id'] for e in response.data['errors']], [site2.pk])
+        self.assertIsInstance(response.data['errors'][0]['errors']['__all__'], list)
+
+        # Neither site may have been modified, including the permitted one
+        site1.refresh_from_db()
+        site2.refresh_from_db()
+        self.assertEqual(site1.description, '', 'Site 1 should not have been updated')
+        self.assertEqual(site2.status, SiteStatusChoices.STATUS_ACTIVE, 'Site 2 should not have been updated')
+
+    def test_bulk_update_objects_permission_constraint_and_validation_error(self):
+        """
+        PATCH a set of objects where one entry is invalid and another is refused by object-level
+        permissions. Both must be reported, and the authorization failure must determine the status
+        code: it is the failure which would remain were the invalid entry corrected.
+        """
+        site1 = Site.objects.get(slug='site-1')
+        site2 = Site.objects.get(slug='site-2')
+        Site.objects.filter(pk__in=(site1.pk, site2.pk)).update(status=SiteStatusChoices.STATUS_ACTIVE)
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['change'],
+            constraints={'status': SiteStatusChoices.STATUS_ACTIVE},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+        data = [
+            {'id': site1.pk, 'status': 'not-a-valid-status'},  # Fails validation (400)
+            {'id': site2.pk, 'status': SiteStatusChoices.STATUS_PLANNED},  # Not permitted (403)
+        ]
+        with disable_warnings('django.request'):
+            response = self.client.patch(self._get_list_url(), data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        self.assertEqual([e['id'] for e in response.data['errors']], [site1.pk, site2.pk])
+
+        site1.refresh_from_db()
+        site2.refresh_from_db()
+        self.assertEqual(site1.status, SiteStatusChoices.STATUS_ACTIVE)
+        self.assertEqual(site2.status, SiteStatusChoices.STATUS_ACTIVE)
+
+    def test_bulk_create_objects_permission_constraint(self):
+        """
+        POST a set of objects where one falls outside the requesting user's object-level permissions.
+        The offending object must be correlated by its position, and nothing may be created.
+        """
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['add'],
+            constraints={'status': SiteStatusChoices.STATUS_ACTIVE},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+        initial_count = self._get_queryset().count()
+        data = [
+            {'name': 'Site 20', 'slug': 'site-20', 'status': SiteStatusChoices.STATUS_ACTIVE},
+            {'name': 'Site 21', 'slug': 'site-21', 'status': SiteStatusChoices.STATUS_PLANNED},
+        ]
+        with disable_warnings('django.request'):
+            response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        self.assertEqual([e['index'] for e in response.data['errors']], [1])
+        self.assertIsInstance(response.data['errors'][0]['errors']['__all__'], list)
+
+        self.assertEqual(
+            self._get_queryset().count(), initial_count,
+            'No objects should be created when any sibling is not permitted',
+        )
+
     def test_bulk_delete_objects_conflict_and_abort_request(self):
     def test_bulk_delete_objects_conflict_and_abort_request(self):
         """
         """
         DELETE a set of objects where one is blocked by a dependent object and another by a
         DELETE a set of objects where one is blocked by a dependent object and another by a

+ 1 - 1
netbox/netbox/api/serializers/bulk.py

@@ -61,7 +61,7 @@ class BulkOperationErrorSerializer(serializers.Serializer):
     responsible for it.
     responsible for it.
     """
     """
     detail = serializers.CharField(
     detail = serializers.CharField(
-        help_text=_('A summary of the failure, e.g. "1 of 3 objects failed validation."')
+        help_text=_('A summary of the failure, e.g. "1 of 3 objects could not be updated."')
     )
     )
     errors = BulkOperationEntryErrorSerializer(
     errors = BulkOperationEntryErrorSerializer(
         many=True,
         many=True,

+ 103 - 23
netbox/netbox/api/viewsets/mixins.py

@@ -2,7 +2,7 @@ import warnings
 from collections import Counter
 from collections import Counter
 from contextlib import contextmanager
 from contextlib import contextmanager
 
 
-from django.core.exceptions import ObjectDoesNotExist
+from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
 from django.db import router, transaction
 from django.db import router, transaction
 from django.db.models import ProtectedError, RestrictedError
 from django.db.models import ProtectedError, RestrictedError
 from django.http import Http404
 from django.http import Http404
@@ -24,6 +24,7 @@ from utilities.request import copy_safe_request
 from utilities.rqworker import any_workers_for_queue
 from utilities.rqworker import any_workers_for_queue
 
 
 __all__ = (
 __all__ = (
+    'BULK_ERROR_STATUSES',
     'BackgroundOperationMixin',
     'BackgroundOperationMixin',
     'BulkCreateModelMixin',
     'BulkCreateModelMixin',
     'BulkDestroyModelMixin',
     'BulkDestroyModelMixin',
@@ -37,8 +38,44 @@ __all__ = (
     'get_invalid_entries_response',
     'get_invalid_entries_response',
     'get_missing_objects_response',
     'get_missing_objects_response',
     'get_non_list_response',
     'get_non_list_response',
+    'resolve_bulk_error_status',
 )
 )
 
 
+# The status codes with which a failed bulk operation may be reported, in order of precedence: where
+# the per-object failures within one batch imply more than one of these, the earliest applies, being
+# the one which would still stand were the others corrected. An authorization failure thus outranks a
+# conflict with the current state of the database, which in turn outranks a rejection of the request.
+BULK_ERROR_STATUSES = (
+    status.HTTP_403_FORBIDDEN,
+    status.HTTP_409_CONFLICT,
+    status.HTTP_400_BAD_REQUEST,
+)
+
+# Reported for an object whose write was undone because the object it produced falls outside the
+# queryset permitted to the requesting user (see ObjectValidationMixin._validate_objects). Which
+# constraint was violated is deliberately not disclosed, consistent with the single-object endpoints.
+PERMISSION_DENIED_MESSAGE = _("You do not have permission to perform this action on this object.")
+
+
+def resolve_bulk_error_status(error_statuses):
+    """
+    Return the single status code with which to report a bulk operation whose per-object failures
+    imply the given ones, or None if there were no failures.
+
+    :param error_statuses: The set of status codes implied by the failures within one batch, each
+        drawn from BULK_ERROR_STATUSES (which documents how they are ranked).
+    """
+    if not error_statuses:
+        return None
+
+    for error_status in BULK_ERROR_STATUSES:
+        if error_status in error_statuses:
+            return error_status
+
+    # A code with no defined precedence (a subclass may report its own) is not silently ranked;
+    # fall back to the generic client error.
+    return status.HTTP_400_BAD_REQUEST
+
 
 
 def get_non_list_response(data):
 def get_non_list_response(data):
     """
     """
@@ -357,18 +394,18 @@ class BulkCreateModelMixin:
     ]
     ]
     """
     """
     def bulk_create(self, request, *args, **kwargs):
     def bulk_create(self, request, *args, **kwargs):
-        created_pks, errors = self.perform_bulk_create(request.data)
+        created_pks, errors, error_status = self.perform_bulk_create(request.data)
 
 
         if errors:
         if errors:
             return Response(
             return Response(
                 {
                 {
-                    'detail': _('{failed_count} of {total} objects failed validation.').format(
+                    'detail': _('{failed_count} of {total} objects could not be created.').format(
                         failed_count=len(errors),
                         failed_count=len(errors),
                         total=len(request.data),
                         total=len(request.data),
                     ),
                     ),
                     'errors': errors,
                     'errors': errors,
                 },
                 },
-                status=status.HTTP_400_BAD_REQUEST,
+                status=error_status,
             )
             )
 
 
         # Re-fetch the new objects to serialize them with their related objects prefetched. Order by PK
         # Re-fetch the new objects to serialize them with their related objects prefetched. Order by PK
@@ -380,8 +417,16 @@ class BulkCreateModelMixin:
         return Response(serializer.data, status=status.HTTP_201_CREATED)
         return Response(serializer.data, status=status.HTTP_201_CREATED)
 
 
     def perform_bulk_create(self, data):
     def perform_bulk_create(self, data):
+        """
+        Validate and create each of the given objects, rolling the entire batch back if any one of
+        them could not be created.
+
+        Returns the PKs of the objects created, the per-object errors, and the status code with
+        which to report them (None if there were none). See resolve_bulk_error_status().
+        """
         created_pks = []
         created_pks = []
         errors = []
         errors = []
+        error_statuses = set()
         using = router.db_for_write(self.queryset.model)
         using = router.db_for_write(self.queryset.model)
         with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
         with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
             # Validate and save each object in turn, rather than validating the entire batch up front,
             # Validate and save each object in turn, rather than validating the entire batch up front,
@@ -404,10 +449,12 @@ class BulkCreateModelMixin:
                             ],
                             ],
                         },
                         },
                     })
                     })
+                    error_statuses.add(status.HTTP_400_BAD_REQUEST)
                     continue
                     continue
                 serializer = self.get_serializer(data=item)
                 serializer = self.get_serializer(data=item)
                 if not serializer.is_valid():
                 if not serializer.is_valid():
                     errors.append({'index': i, 'errors': serializer.errors})
                     errors.append({'index': i, 'errors': serializer.errors})
+                    error_statuses.add(status.HTTP_400_BAD_REQUEST)
                     continue
                     continue
                 try:
                 try:
                     # Provisionally create even when a prior item failed, so subsequent
                     # Provisionally create even when a prior item failed, so subsequent
@@ -421,11 +468,19 @@ class BulkCreateModelMixin:
                     # and the remaining objects in the batch can still be evaluated. The message is
                     # and the remaining objects in the batch can still be evaluated. The message is
                     # coerced to a string because a few receivers pass an exception rather than text.
                     # coerced to a string because a few receivers pass an exception rather than text.
                     errors.append({'index': i, 'errors': {'__all__': [str(e.message)]}})
                     errors.append({'index': i, 'errors': {'__all__': [str(e.message)]}})
+                    error_statuses.add(status.HTTP_400_BAD_REQUEST)
+                except PermissionDenied:
+                    # Raised by perform_create() when the object it saved falls outside the queryset
+                    # permitted to the requesting user. Reported per object so that the offending
+                    # entry is named, but still as a 403, which is what the single-object endpoint
+                    # returns for the same rejection.
+                    errors.append({'index': i, 'errors': {'__all__': [PERMISSION_DENIED_MESSAGE]}})
+                    error_statuses.add(status.HTTP_403_FORBIDDEN)
                 else:
                 else:
                     created_pks.append(serializer.instance.pk)
                     created_pks.append(serializer.instance.pk)
             if errors:
             if errors:
                 transaction.set_rollback(True)
                 transaction.set_rollback(True)
-        return created_pks, errors
+        return created_pks, errors, resolve_bulk_error_status(error_statuses)
 
 
 
 
 # TODO: Remove this in NetBox v5.0
 # TODO: Remove this in NetBox v5.0
@@ -516,18 +571,21 @@ class BulkUpdateModelMixin:
             for object_id, item in zip(object_ids, request.data, strict=True)
             for object_id, item in zip(object_ids, request.data, strict=True)
         }
         }
 
 
-        object_pks, errors = self.perform_bulk_update(qs, update_data, partial=partial)
+        object_pks, errors, error_status = self.perform_bulk_update(qs, update_data, partial=partial)
 
 
         if errors:
         if errors:
             return Response(
             return Response(
                 {
                 {
-                    'detail': _('{failed_count} of {total} objects failed validation.').format(
+                    'detail': _('{failed_count} of {total} objects could not be updated.').format(
                         failed_count=len(errors),
                         failed_count=len(errors),
+                        # Every object named was matched and attempted, the duplicate and missing-ID
+                        # checks above having rejected the batch otherwise, so this equals the number
+                        # of objects submitted.
                         total=len(object_pks) + len(errors),
                         total=len(object_pks) + len(errors),
                     ),
                     ),
                     'errors': errors,
                     'errors': errors,
                 },
                 },
-                status=status.HTTP_400_BAD_REQUEST,
+                status=error_status,
             )
             )
 
 
         # Prefetch related objects for all updated instances
         # Prefetch related objects for all updated instances
@@ -537,8 +595,16 @@ class BulkUpdateModelMixin:
         return Response(serializer.data, status=status.HTTP_200_OK)
         return Response(serializer.data, status=status.HTTP_200_OK)
 
 
     def perform_bulk_update(self, objects, update_data, partial):
     def perform_bulk_update(self, objects, update_data, partial):
+        """
+        Validate and apply the given attributes to each of the given objects, rolling the entire
+        batch back if any one of them could not be updated.
+
+        Returns the PKs of the objects updated, the per-object errors, and the status code with
+        which to report them (None if there were none). See resolve_bulk_error_status().
+        """
         updated_pks = []
         updated_pks = []
         errors = []
         errors = []
+        error_statuses = set()
         using = router.db_for_write(self.queryset.model)
         using = router.db_for_write(self.queryset.model)
         with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
         with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
             # Validate and save each object in turn so subsequent validations see the DB
             # Validate and save each object in turn so subsequent validations see the DB
@@ -551,6 +617,7 @@ class BulkUpdateModelMixin:
                 serializer = self.get_serializer(obj, data=data, partial=partial)
                 serializer = self.get_serializer(obj, data=data, partial=partial)
                 if not serializer.is_valid():
                 if not serializer.is_valid():
                     errors.append({'id': obj.pk, 'errors': serializer.errors})
                     errors.append({'id': obj.pk, 'errors': serializer.errors})
+                    error_statuses.add(status.HTTP_400_BAD_REQUEST)
                     continue
                     continue
                 try:
                 try:
                     self.perform_update(serializer)
                     self.perform_update(serializer)
@@ -561,11 +628,20 @@ class BulkUpdateModelMixin:
                     # and the remaining objects in the batch can still be evaluated. The message is
                     # and the remaining objects in the batch can still be evaluated. The message is
                     # coerced to a string because a few receivers pass an exception rather than text.
                     # coerced to a string because a few receivers pass an exception rather than text.
                     errors.append({'id': obj.pk, 'errors': {'__all__': [str(e.message)]}})
                     errors.append({'id': obj.pk, 'errors': {'__all__': [str(e.message)]}})
+                    error_statuses.add(status.HTTP_400_BAD_REQUEST)
+                except PermissionDenied:
+                    # Raised by perform_update() when the object, as modified, falls outside the
+                    # queryset permitted to the requesting user -- so unlike the check made before
+                    # the batch begins (see get_missing_objects_response), this depends on the
+                    # attributes submitted. Reported per object so that the offending entry is
+                    # named, but still as a 403, as the single-object endpoint returns.
+                    errors.append({'id': obj.pk, 'errors': {'__all__': [PERMISSION_DENIED_MESSAGE]}})
+                    error_statuses.add(status.HTTP_403_FORBIDDEN)
                 else:
                 else:
                     updated_pks.append(obj.pk)
                     updated_pks.append(obj.pk)
             if errors:
             if errors:
                 transaction.set_rollback(True)
                 transaction.set_rollback(True)
-        return updated_pks, errors
+        return updated_pks, errors, resolve_bulk_error_status(error_statuses)
 
 
     def get_bulk_update_serializer_class(self, *, partial=False):
     def get_bulk_update_serializer_class(self, *, partial=False):
         return get_bulk_update_serializer_class(
         return get_bulk_update_serializer_class(
@@ -637,14 +713,9 @@ class BulkDestroyModelMixin:
             o['id']: o.get('changelog_message') for o in serializer.validated_data
             o['id']: o.get('changelog_message') for o in serializer.validated_data
         }
         }
 
 
-        errors, total, has_conflict = self.perform_bulk_destroy(qs, changelog_messages)
+        errors, total, error_status = self.perform_bulk_destroy(qs, changelog_messages)
 
 
         if errors:
         if errors:
-            # A dependency conflict reports 409, as it is a conflict with the current state of the
-            # database; every other failure reports 400, as it is a rejection of the request. This
-            # matches the single-object endpoint, where dispatch() maps the same two exception
-            # classes to the same two status codes. Where a batch hit both, the conflict takes
-            # precedence: it is the failure which would remain were the request itself corrected.
             return Response(
             return Response(
                 {
                 {
                     'detail': _('{failed_count} of {total} objects could not be deleted.').format(
                     'detail': _('{failed_count} of {total} objects could not be deleted.').format(
@@ -653,7 +724,7 @@ class BulkDestroyModelMixin:
                     ),
                     ),
                     'errors': errors,
                     'errors': errors,
                 },
                 },
-                status=status.HTTP_409_CONFLICT if has_conflict else status.HTTP_400_BAD_REQUEST,
+                status=error_status,
             )
             )
 
 
         return Response(status=status.HTTP_204_NO_CONTENT)
         return Response(status=status.HTTP_204_NO_CONTENT)
@@ -663,15 +734,18 @@ class BulkDestroyModelMixin:
         Attempt to delete each of the given objects, rolling the entire batch back if any one of
         Attempt to delete each of the given objects, rolling the entire batch back if any one of
         them could not be deleted.
         them could not be deleted.
 
 
-        Returns the per-object errors, the number of objects processed, and whether any of the
-        failures was a conflict with the current state of the database (a dependent object) rather
-        than a rejection of the request (a protection rule, or any other signal receiver raising
-        AbortRequest). The caller uses the last of these to select a status code.
+        Returns the per-object errors, the number of objects processed, and the status code with
+        which to report the errors (None if there were none). A dependency conflict yields a 409, as
+        it is a conflict with the current state of the database, whereas a protection rule (or any
+        other signal receiver raising AbortRequest) yields a 400, being a rejection of the request:
+        this matches the single-object endpoint, where dispatch() maps the same exception classes to
+        the same status codes. See resolve_bulk_error_status() for how a batch hitting more than one
+        of these is resolved.
         """
         """
         changelog_messages = changelog_messages or {}
         changelog_messages = changelog_messages or {}
         errors = []
         errors = []
         total = 0
         total = 0
-        has_conflict = False
+        error_statuses = set()
         using = router.db_for_write(self.queryset.model)
         using = router.db_for_write(self.queryset.model)
         with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
         with transaction.atomic(using=using), discard_events_on_rollback(self, using=using):
             for obj in objects:
             for obj in objects:
@@ -683,7 +757,7 @@ class BulkDestroyModelMixin:
                 try:
                 try:
                     self.perform_destroy(obj)
                     self.perform_destroy(obj)
                 except (ProtectedError, RestrictedError) as e:
                 except (ProtectedError, RestrictedError) as e:
-                    has_conflict = True
+                    error_statuses.add(status.HTTP_409_CONFLICT)
                     protected = list(
                     protected = list(
                         e.protected_objects if isinstance(e, ProtectedError) else e.restricted_objects
                         e.protected_objects if isinstance(e, ProtectedError) else e.restricted_objects
                     )
                     )
@@ -709,9 +783,15 @@ class BulkDestroyModelMixin:
                     # is rolled back to that savepoint and the remaining objects in the batch can
                     # is rolled back to that savepoint and the remaining objects in the batch can
                     # still be evaluated.
                     # still be evaluated.
                     errors.append({'id': pk, 'errors': {'__all__': [str(e.message)]}})
                     errors.append({'id': pk, 'errors': {'__all__': [str(e.message)]}})
+                    error_statuses.add(status.HTTP_400_BAD_REQUEST)
+                except PermissionDenied:
+                    # Raised by perform_destroy() when the object falls outside the queryset
+                    # permitted to the requesting user (reachable via the If-Match re-check).
+                    errors.append({'id': pk, 'errors': {'__all__': [PERMISSION_DENIED_MESSAGE]}})
+                    error_statuses.add(status.HTTP_403_FORBIDDEN)
             if errors:
             if errors:
                 transaction.set_rollback(True)
                 transaction.set_rollback(True)
-        return errors, total, has_conflict
+        return errors, total, resolve_bulk_error_status(error_statuses)
 
 
 
 
 class ObjectValidationMixin:
 class ObjectValidationMixin: