diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index bf19bf35e..0c16a1e1c 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -34,6 +34,28 @@ HTTP_ACTIONS = { } +class ETagMixin: + """ + Adds ETag header support to ViewSets. Generates ETags from `last_updated` + (or `created` if unavailable). + """ + + @staticmethod + def _get_etag(obj): + """Return a quoted ETag string for the given object, or None.""" + if ts := getattr(obj, 'last_updated', None) or getattr(obj, 'created', None): + return f'"{ts.isoformat()}"' + return None + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance) + response = Response(serializer.data) + if etag := self._get_etag(instance): + response['ETag'] = etag + return response + + class BaseViewSet(GenericViewSet): """ Base class for all API ViewSets. This is responsible for the enforcement of object-based permissions. @@ -95,6 +117,7 @@ class BaseViewSet(GenericViewSet): class NetBoxReadOnlyModelViewSet( + ETagMixin, mixins.CustomFieldsMixin, mixins.ExportTemplatesMixin, drf_mixins.RetrieveModelMixin, @@ -105,6 +128,7 @@ class NetBoxReadOnlyModelViewSet( class NetBoxModelViewSet( + ETagMixin, mixins.BulkUpdateModelMixin, mixins.BulkDestroyModelMixin, mixins.ObjectValidationMixin, @@ -191,7 +215,14 @@ class NetBoxModelViewSet( serializer = self.get_serializer(qs, many=bulk_create) headers = self.get_success_headers(serializer.data) - return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + response = Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + # Add ETag for single-object creation only (bulk returns a list, no single ETag) + if not bulk_create: + if etag := self._get_etag(qs): + response['ETag'] = etag + + return response def perform_create(self, serializer): model = self.queryset.model @@ -211,6 +242,14 @@ class NetBoxModelViewSet( def update(self, request, *args, **kwargs): partial = kwargs.pop('partial', False) instance = self.get_object_with_snapshot() + + # Enforce If-Match precondition (RFC 9110 §13.1.1) + if (if_match := request.META.get('HTTP_IF_MATCH')) and if_match != '*': + current_etag = self._get_etag(instance) + provided = [e.strip() for e in if_match.split(',')] + if current_etag and current_etag not in provided: + return Response(status=status.HTTP_412_PRECONDITION_FAILED) + serializer = self.get_serializer(instance, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) self.perform_update(serializer) @@ -221,8 +260,12 @@ class NetBoxModelViewSet( # Re-serialize the instance(s) with prefetched data serializer = self.get_serializer(qs) + response = Response(serializer.data) - return Response(serializer.data) + if etag := self._get_etag(qs): + response['ETag'] = etag + + return response def perform_update(self, serializer): model = self.queryset.model diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 7ca8f332d..c2a83a8fb 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -114,7 +114,12 @@ class APIViewTestCases: # Try GET to permitted object url = self._get_detail_url(instance1) - self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK) + response = self.client.get(url, **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + + # Verify ETag header is present for objects with timestamps + if issubclass(self.model, ChangeLoggingMixin): + self.assertIn('ETag', response, "ETag header missing from detail response") # Try GET to non-permitted object url = self._get_detail_url(instance2) @@ -367,6 +372,46 @@ class APIViewTestCases: self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE) self.assertEqual(objectchange.message, data['changelog_message']) + def test_update_object_with_etag(self): + """ + PATCH an object using a valid If-Match ETag → expect 200. + PATCH again with the now-stale ETag → expect 412. + """ + if not issubclass(self.model, ChangeLoggingMixin): + self.skipTest("Model does not support ETags") + + self.add_permissions( + f'{self.model._meta.app_label}.view_{self.model._meta.model_name}', + f'{self.model._meta.app_label}.change_{self.model._meta.model_name}', + ) + instance = self._get_queryset().first() + url = self._get_detail_url(instance) + update_data = self.update_data or getattr(self, 'create_data')[0] + + # Fetch current ETag + get_response = self.client.get(url, **self.header) + self.assertHttpStatus(get_response, status.HTTP_200_OK) + etag = get_response.get('ETag') + self.assertIsNotNone(etag, "No ETag returned by GET") + + # PATCH with correct ETag → 200 + response = self.client.patch( + url, update_data, format='json', + **{**self.header, 'HTTP_IF_MATCH': etag} + ) + self.assertHttpStatus(response, status.HTTP_200_OK) + new_etag = response.get('ETag') + self.assertIsNotNone(new_etag) + self.assertNotEqual(etag, new_etag) # ETag must change after update + + # PATCH with the old (stale) ETag → 412 + with disable_warnings('django.request'): + response = self.client.patch( + url, update_data, format='json', + **{**self.header, 'HTTP_IF_MATCH': etag} + ) + self.assertHttpStatus(response, status.HTTP_412_PRECONDITION_FAILED) + def test_bulk_update_objects(self): """ PATCH a set of objects in a single request.