Repository: netbox-community/netbox

Issue:
Prevent race condition when using edit forms in GUI
### NetBox version

v3.4.4

### Feature type

Change to existing functionality

### Proposed functionality

In edit forms for primary models, when confirming/saving your changes, add a new validation check that the `last_updated` time has not changed from when you first started editing the object.

If the `last_updated` time is captured at the time the edit form is loaded, and check again when the `Save` button is clicked, we can check that the timestamp is the same as before, before finally saving the new change. Otherwise, a validation error is raised.

### Use case

It's possible for two users to be editing an object in the GUI at the same time and overwrite each other's changes. Here's the scenario:

1. Bob creates a Site.
2. Joe clicks “Edit” on that Site and has the Edit Site form open.
3. Bob also clicks Edit on the same Site and makes some changes, clicks “Save”.
4. Joe make changes on his Edit Form and clicks save.
5. Bob’s changes are no longer present, and Joe’s change is - based on outdated data.

### Database changes

N/A

### External dependencies

N/A


Discussion:
Typical Joe. I've been saying we ought to fire that guy.

This is a great idea, and we should be able to implement it pretty seamlessly for (nearly?) every model form.
For APIs, I've used the ETag/If-Match header method to support this in the past. For server-rendered pages, I suspect you could use something similar via form fields. Ideally, a similar method would be used for changes made via UI, REST API, and GraphQL (if that supports mutation).
> For APIs, I've used the ETag/If-Match header method to support this in the past. For server-rendered pages, I suspect you could use something similar via form fields. Ideally, a similar method would be used for changes made via UI, REST API, and GraphQL (if that supports mutation).

I'd be hesitant to have this validation (using this particular logic) happen in the API. The additional query to lookup the "in between" `last_updated` value would likely slow down response times (which would be compounded in high request volume environments). In the GUI you're working at "human speed" so the likelihood of the issue manifesting goes up, but API work tends to have very fast turn around.
My previous implementation was using the API for a SPA UI, so it already had to make a request to load the object for the view anyway. Depending on how the REST API is implemented here, you should be able to just issue a HEAD request to get the ETag, without having to retrieve the full resource. Those values could potentially leverage the redis cache, which should make the extra request negligible. However, if you are using only the API to update resources, then you should only need to grab the value once, then cache the ETag from the response each time a change is made, to use in the next request. If you are expecting a mix of UI and API changes on the same resource, then you're just going to run into the same potential for a race condition if you don't use the same safeguard in both places. It would also be dangerous in that circumstance to not already be retrieving the resource to validate current state before making the change via the API. This solution should actually be faster in that scenario, because if your request succeeds with the cached ETag value, then there is no need to make that additional request, as the current state has already been validated.
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).

Resolving patch:
diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py
--- a/netbox/netbox/forms/base.py
+++ b/netbox/netbox/forms/base.py
@@ -6,7 +6,7 @@
 from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
 from extras.forms.mixins import CustomFieldsMixin, SavedFiltersMixin
 from extras.models import CustomField, Tag
-from utilities.forms import BootstrapMixin, CSVModelForm
+from utilities.forms import BootstrapMixin, CSVModelForm, CheckLastUpdatedMixin
 from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
 
 __all__ = (
@@ -17,7 +17,7 @@
 )
 
 
-class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
+class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin, forms.ModelForm):
     """
     Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields.
 
diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py
--- a/netbox/utilities/forms/mixins.py
+++ b/netbox/utilities/forms/mixins.py
@@ -1,9 +1,13 @@
+import time
+
 from django import forms
+from django.utils.translation import gettext_lazy as _
 
 from .widgets import APISelect, APISelectMultiple, ClearableFileInput
 
 __all__ = (
     'BootstrapMixin',
+    'CheckLastUpdatedMixin',
 )
 
 
@@ -11,7 +15,6 @@ class BootstrapMixin:
     """
     Add the base Bootstrap CSS classes to form elements.
     """
-
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
@@ -60,3 +63,41 @@ def is_valid(self):
                     field.widget.attrs['class'] = f'{css} is-invalid'
 
         return is_valid
+
+
+class CheckLastUpdatedMixin(forms.Form):
+    """
+    Checks whether the object being saved has been updated since the form was initialized. If so, validation fails.
+    This prevents a user from inadvertently overwriting any changes made to the object between when the form was
+    initialized and when it was submitted.
+
+    This validation does not apply to newly created objects, or if the `_init_time` field is not present in the form
+    data.
+    """
+    _init_time = forms.DecimalField(
+        initial=time.time,
+        required=False,
+        widget=forms.HiddenInput()
+    )
+
+    def clean(self):
+        super().clean()
+
+        # Skip for absent or newly created instances
+        if not self.instance or not self.instance.pk:
+            return
+
+        # Skip if a form init time has not been specified
+        if not (form_init_time := self.cleaned_data.get('_init_time')):
+            return
+
+        # Skip if the object does not have a last_updated value
+        if not (last_updated := getattr(self.instance, 'last_updated', None)):
+            return
+
+        # Check that the submitted initialization time is not earlier than the object's modification time
+        if form_init_time < last_updated.timestamp():
+            raise forms.ValidationError(_(
+                "This object has been modified since the form was rendered. Please consult the object's change "
+                "log for details."
+            ))


Test patch: