Repository: netbox-community/netbox

Issue:
Extend customvalidators with user request information
### NetBox version

v.3.6.5

### Feature type

Change to existing functionality

### Proposed functionality

Extend customvalidators with an additional variable - request user details

### Use case

It would be great it customvalidators had a way to check what users are making the request. in some situations, it can make sense to allow one user/usergroup/API integration the ability to skip a specific validation.

for example we wish to use an ipaddress validator to make sure rfc 1918 ip's don't end up in the global vrf, however there is one particular api integration that is going to take quite awhile to update to support vrf lookup's, in the meantime we want to stop any other rfc 1918 ip's getting added to the global vrf. being able to write the validator with an exclusion for a particular user would be great.

I understand that sometimes the supplied user would also need to be None/False as changes from nbshell wouldn't have an attached user. 

### Database changes

_No response_

### External dependencies

_No response_


Discussion:
some examples of integrations that right now don't have vrf support so it would be great to be able to exclude the related user from an rfc1918 validator
plugin: netbox-proxmox
external integration: xoa (it uses the API & has no concept of a vrf)
Thanks! I support that and suggest extending the concept to the new `PROTECTION_RULES` feature to be introduced with version 3.7 (#14097).
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened issues may receive direct feedback. **Do not** attempt to circumvent this process by "bumping" the issue; doing so will result in its immediate closure and you may be barred from participating in any future discussions. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
Historically this has not been possible as custom validators are processed via the `post_clean` signal, and signal handlers have no direct access to the request being processed. However, I believe this is now feasible with the somewhat recent introduce of the [`current_request`](https://github.com/netbox-community/netbox/blob/51b2bcf264723f1752438b0448049ba50f9feb87/netbox/netbox/context.py#L9) context variable, which can be referenced fro inside the signal receiver.

Also, the `CustomValidator` class will need to be extended to support the inclusion of request context in addition to the object itself.

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,7 +1,8 @@
+import importlib
 import logging
 
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
+from django.core.exceptions import ImproperlyConfigured, ValidationError
 from django.db.models.fields.reverse_related import ManyToManyRel
 from django.db.models.signals import m2m_changed, post_save, pre_delete
 from django.dispatch import receiver, Signal
@@ -13,7 +14,6 @@
 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 run_validators
 from netbox.config import get_config
 from netbox.context import current_request, events_queue
 from netbox.models.features import ChangeLoggingMixin
@@ -22,6 +22,30 @@
 from .choices import ObjectChangeActionChoices
 from .events import enqueue_object, get_snapshots, serialize_for_event
 from .models import CustomField, ObjectChange, TaggedItem
+from .validators import CustomValidator
+
+
+def run_validators(instance, validators):
+    """
+    Run the provided iterable of validators for the instance.
+    """
+    request = current_request.get()
+    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)
+
+        elif not issubclass(validator.__class__, CustomValidator):
+            raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}")
+
+        validator(instance, request)
+
 
 #
 # Change logging/webhooks
diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py
--- a/netbox/extras/validators.py
+++ b/netbox/extras/validators.py
@@ -1,4 +1,5 @@
-import importlib
+import inspect
+import operator
 
 from django.core import validators
 from django.core.exceptions import ValidationError
@@ -74,6 +75,8 @@ class CustomValidator:
 
     :param validation_rules: A dictionary mapping object attributes to validation rules
     """
+    REQUEST_TOKEN = 'request'
+
     VALIDATORS = {
         'eq': IsEqualValidator,
         'neq': IsNotEqualValidator,
@@ -88,25 +91,56 @@ class CustomValidator:
 
     def __init__(self, validation_rules=None):
         self.validation_rules = validation_rules or {}
-        assert type(self.validation_rules) is dict, "Validation rules must be passed as a dictionary"
+        if type(self.validation_rules) is not dict:
+            raise ValueError(_("Validation rules must be passed as a dictionary"))
 
-    def __call__(self, instance):
-        # Validate instance attributes per validation rules
-        for attr_name, rules in self.validation_rules.items():
-            attr = self._getattr(instance, attr_name)
+    def __call__(self, instance, request=None):
+        """
+        Validate the instance and (optional) request against the validation rule(s).
+        """
+        for attr_path, rules in self.validation_rules.items():
+
+            # The rule applies to the current request
+            if attr_path.split('.')[0] == self.REQUEST_TOKEN:
+                # Skip if no request has been provided (we can't validate)
+                if request is None:
+                    continue
+                attr = self._get_request_attr(request, attr_path)
+            # The rule applies to the instance
+            else:
+                attr = self._get_instance_attr(instance, attr_path)
+
+            # Validate the attribute's value against each of the rules defined for it
             for descriptor, value in rules.items():
                 validator = self.get_validator(descriptor, value)
                 try:
                     validator(attr)
                 except ValidationError as exc:
-                    # Re-package the raised ValidationError to associate it with the specific attr
-                    raise ValidationError({attr_name: exc})
+                    raise ValidationError(
+                        _("Custom validation failed for {attribute}: {exception}").format(
+                            attribute=attr_path, exception=exc
+                        )
+                    )
 
         # Execute custom validation logic (if any)
-        self.validate(instance)
+        # TODO: Remove in v4.1
+        # Inspect the validate() method, which may have been overridden, to determine
+        # whether we should pass the request (maintains backward compatibility for pre-v4.0)
+        if 'request' in inspect.signature(self.validate).parameters:
+            self.validate(instance, request)
+        else:
+            self.validate(instance)
 
     @staticmethod
-    def _getattr(instance, name):
+    def _get_request_attr(request, name):
+        name = name.split('.', maxsplit=1)[1]  # Remove token
+        try:
+            return operator.attrgetter(name)(request)
+        except AttributeError:
+            raise ValidationError(_('Invalid attribute "{name}" for request').format(name=name))
+
+    @staticmethod
+    def _get_instance_attr(instance, name):
         # Attempt to resolve many-to-many fields to their stored values
         m2m_fields = [f.name for f in instance._meta.local_many_to_many]
         if name in m2m_fields:
@@ -137,7 +171,7 @@ def get_validator(self, descriptor, value):
         validator_cls = self.VALIDATORS.get(descriptor)
         return validator_cls(value)
 
-    def validate(self, instance):
+    def validate(self, instance, request):
         """
         Custom validation method, to be overridden by the user. Validation failures should
         raise a ValidationError exception.
@@ -151,21 +185,3 @@ 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:
diff --git a/netbox/extras/tests/test_customvalidation.py b/netbox/extras/tests/test_customvalidation.py
--- a/netbox/extras/tests/test_customvalidation.py
+++ b/netbox/extras/tests/test_customvalidation.py
@@ -7,7 +7,9 @@
 from dcim.choices import SiteStatusChoices
 from dcim.models import Site
 from extras.validators import CustomValidator
+from users.models import User
 from utilities.exceptions import AbortRequest
+from utilities.utils import NetBoxFakeRequest
 
 
 class MyValidator(CustomValidator):
@@ -79,6 +81,13 @@ def validate(self, instance):
     }
 })
 
+
+request_validator = CustomValidator({
+    'request.user.username': {
+        'eq': 'Bob'
+    }
+})
+
 custom_validator = MyValidator()
 
 
@@ -154,6 +163,28 @@ def test_custom_invalid(self):
     def test_custom_valid(self):
         Site(name='foo', slug='foo').clean()
 
+    @override_settings(CUSTOM_VALIDATORS={'dcim.site': [request_validator]})
+    def test_request_validation(self):
+        alice = User.objects.create(username='Alice')
+        bob = User.objects.create(username='Bob')
+        request = NetBoxFakeRequest({
+            'META': {},
+            'POST': {},
+            'GET': {},
+            'FILES': {},
+            'user': alice,
+            'path': '',
+        })
+        site = Site(name='abc', slug='abc')
+
+        # Attempt to create the Site as Alice
+        with self.assertRaises(ValidationError):
+            request_validator(site, request)
+
+        # Creating the Site as Bob should succeed
+        request.user = bob
+        request_validator(site, request)
+
 
 class CustomValidatorConfigTest(TestCase):