Repository: netbox-community/netbox

Issue:
Webhooks sent despite protection rule block
### Deployment Type

Self-hosted

### NetBox Version

v3.7.2

### Python Version

3.10

### Steps to Reproduce

1. Create a webhook that triggers on an IP Address delete
2. Create a custom protection rule that will block deletion if IP addresses. For example: ```{
    "ipam.ipaddress": [
        {
            "status": {
                "eq": "deprecated"
            }
        }
    ]
}```
3.  Go delete an IP address object that does not satisfy the protection rule
4. Observe the error message ` Deletion is prevented by a protection rule: {'status': ['Ensure this value is equal to deprecated.']}` (this is expected)
5. Observe the webhook firing for an ipaddress deletion (this is unexpected)

### Expected Behavior

Webhook should not fire on deletion if a protection rule prevented the deletion from happening.

### Observed Behavior

Webhook is sent sent with event `deleted` and a `snapshot.postchange` value of `null` despite the system blocking this deletion. This could have implications on webhook receivers processing the deletion when in fact the object remains in NetBox.


Discussion:
I can think in two ways to solve this Issue
1 Change handle_deleted_object event to post_delete. 
2 Join handle_deleted_object and run_delete_validators in a single pre_delete method. 
If I would fix that issue I would go for option 2.
I can make a fix for that issue, just let me know which path is the best. 
> 1. Change `handle_deleted_object` event to `post_delete`.

I don't think this is feasible, because the receiver function is also responsible for recording a change record. This must happen _before_ the object is deleted e.g. to retain its primary key.

> 2. Join `handle_deleted_object` and `run_delete_validators` in a single pre_delete method.

IMO this would be the preferred solution.

Resolving patch:
diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py
--- a/netbox/extras/signals.py
+++ b/netbox/extras/signals.py
@@ -1,4 +1,3 @@
-import importlib
 import logging
 
 from django.contrib.contenttypes.models import ContentType
@@ -13,7 +12,7 @@
 from extras.constants import EVENT_JOB_END, EVENT_JOB_START
 from extras.events import process_event_rules
 from extras.models import EventRule
-from extras.validators import CustomValidator
+from extras.validators import run_validators
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin
@@ -110,6 +109,18 @@ def handle_deleted_object(sender, instance, **kwargs):
     """
     Fires when an object is deleted.
     """
+    # Run any deletion protection rules for the object. Note that this must occur prior
+    # to queueing any events for the object being deleted, in case a validation error is
+    # raised, causing the deletion to fail.
+    model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
+    validators = get_config().PROTECTION_RULES.get(model_name, [])
+    try:
+        run_validators(instance, validators)
+    except ValidationError as e:
+        raise AbortRequest(
+            _("Deletion is prevented by a protection rule: {message}").format(message=e)
+        )
+
     # Get the current request, or bail if not set
     request = current_request.get()
     if request is None:
@@ -207,45 +218,17 @@ def handle_cf_deleted(instance, **kwargs):
 # Custom validation
 #
 
-def run_validators(instance, validators):
-
-    for validator in validators:
-
-        # Loading a validator class by dotted path
-        if type(validator) is str:
-            module, cls = validator.rsplit('.', 1)
-            validator = getattr(importlib.import_module(module), cls)()
-
-        # Constructing a new instance on the fly from a ruleset
-        elif type(validator) is dict:
-            validator = CustomValidator(validator)
-
-        validator(instance)
-
-
 @receiver(post_clean)
 def run_save_validators(sender, instance, **kwargs):
+    """
+    Run any custom validation rules for the model prior to calling save().
+    """
     model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
     validators = get_config().CUSTOM_VALIDATORS.get(model_name, [])
 
     run_validators(instance, validators)
 
 
-@receiver(pre_delete)
-def run_delete_validators(sender, instance, **kwargs):
-    model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
-    validators = get_config().PROTECTION_RULES.get(model_name, [])
-
-    try:
-        run_validators(instance, validators)
-    except ValidationError as e:
-        raise AbortRequest(
-            _("Deletion is prevented by a protection rule: {message}").format(
-                message=e
-            )
-        )
-
-
 #
 # Tags
 #
diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py
--- a/netbox/extras/validators.py
+++ b/netbox/extras/validators.py
@@ -1,3 +1,5 @@
+import importlib
+
 from django.core import validators
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
@@ -149,3 +151,21 @@ def fail(self, message, field=None):
         if field is not None:
             raise ValidationError({field: message})
         raise ValidationError(message)
+
+
+def run_validators(instance, validators):
+    """
+    Run the provided iterable of validators for the instance.
+    """
+    for validator in validators:
+
+        # Loading a validator class by dotted path
+        if type(validator) is str:
+            module, cls = validator.rsplit('.', 1)
+            validator = getattr(importlib.import_module(module), cls)()
+
+        # Constructing a new instance on the fly from a ruleset
+        elif type(validator) is dict:
+            validator = CustomValidator(validator)
+
+        validator(instance)


Test patch: