Jelajahi Sumber

Fixes #23611: Require component template permissions only when importing components

Arthur 9 jam lalu
induk
melakukan
ec84434ab1

+ 52 - 0
netbox/dcim/tests/test_views.py

@@ -1270,6 +1270,58 @@ inventory-items:
         ii1 = InventoryItemTemplate.objects.first()
         self.assertEqual(ii1.name, 'Inventory Item 1')
 
+    def test_import_objects_without_component_permissions(self):
+        """
+        Importing a device type requires only add_devicetype unless component templates are included.
+        """
+        Manufacturer.objects.create(name='Generic', slug='generic')
+        self.add_permissions('dcim.view_manufacturer', 'dcim.add_devicetype')
+        url = reverse('dcim:devicetype_bulk_import')
+
+        self.assertHttpStatus(self.client.get(url), 200)
+
+        import_data = "manufacturer: Generic\nmodel: TEST-2000\nslug: test-2000\nu_height: 1\n"
+        response = self.client.post(url, data={'data': import_data, 'format': 'yaml'})
+        self.assertHttpStatus(response, 302)
+        self.assertTrue(DeviceType.objects.filter(model='TEST-2000').exists())
+
+        # Including component templates requires the corresponding add permission
+        import_data = (
+            "manufacturer: Generic\nmodel: TEST-3000\nslug: test-3000\nu_height: 1\n"
+            "console-ports:\n  - name: Console Port 1\n    type: de-9\n"
+        )
+        response = self.client.post(url, data={'data': import_data, 'format': 'yaml'})
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'dcim.add_consoleporttemplate')
+        self.assertFalse(DeviceType.objects.filter(model='TEST-3000').exists())
+        self.assertFalse(ConsolePortTemplate.objects.exists())
+
+    def test_import_objects_component_object_permissions(self):
+        """
+        Object-level constraints on component template permissions are enforced during import.
+        """
+        Manufacturer.objects.create(name='Generic', slug='generic')
+        self.add_permissions('dcim.view_manufacturer', 'dcim.add_devicetype')
+        obj_perm = ObjectPermission(name='Test permission', actions=['add'], constraints={'name': 'Console Port 1'})
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(ConsolePortTemplate))
+        url = reverse('dcim:devicetype_bulk_import')
+
+        import_data = (
+            "manufacturer: Generic\nmodel: TEST-2000\nslug: test-2000\nu_height: 1\n"
+            "console-ports:\n  - name: Console Port 2\n    type: de-9\n"
+        )
+        response = self.client.post(url, data={'data': import_data, 'format': 'yaml'})
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, 'permissions violation')
+        self.assertFalse(DeviceType.objects.filter(model='TEST-2000').exists())
+
+        import_data = import_data.replace('Console Port 2', 'Console Port 1')
+        response = self.client.post(url, data={'data': import_data, 'format': 'yaml'})
+        self.assertHttpStatus(response, 302)
+        self.assertEqual(DeviceType.objects.get(model='TEST-2000').consoleporttemplates.count(), 1)
+
     def test_bulk_yaml_export_module_bay_types_query_count_is_constant(self):
         """Query count shouldn't scale with bay count -- module_bay_types is prefetched."""
         manufacturer = Manufacturer.objects.create(name='Export Query Manufacturer', slug='export-query-mfr')

+ 0 - 28
netbox/dcim/views.py

@@ -1678,21 +1678,6 @@ class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
 
 @register_model_view(DeviceType, 'bulk_import', path='import', detail=False)
 class DeviceTypeImportView(generic.BulkImportView):
-    additional_permissions = [
-        'dcim.add_devicetype',
-        'dcim.add_consoleporttemplate',
-        'dcim.add_consoleserverporttemplate',
-        'dcim.add_powerporttemplate',
-        'dcim.add_poweroutlettemplate',
-        'dcim.add_coolingintaketemplate',
-        'dcim.add_coolingoutflowtemplate',
-        'dcim.add_interfacetemplate',
-        'dcim.add_frontporttemplate',
-        'dcim.add_rearporttemplate',
-        'dcim.add_modulebaytemplate',
-        'dcim.add_devicebaytemplate',
-        'dcim.add_inventoryitemtemplate',
-    ]
     queryset = DeviceType.objects.all()
     model_form = forms.DeviceTypeImportForm
     related_object_forms = {
@@ -2146,19 +2131,6 @@ class ModuleTypeModuleBaysView(ModuleTypeComponentsView):
 
 @register_model_view(ModuleType, 'bulk_import', path='import', detail=False)
 class ModuleTypeImportView(generic.BulkImportView):
-    additional_permissions = [
-        'dcim.add_moduletype',
-        'dcim.add_consoleporttemplate',
-        'dcim.add_consoleserverporttemplate',
-        'dcim.add_powerporttemplate',
-        'dcim.add_poweroutlettemplate',
-        'dcim.add_coolingintaketemplate',
-        'dcim.add_coolingoutflowtemplate',
-        'dcim.add_interfacetemplate',
-        'dcim.add_frontporttemplate',
-        'dcim.add_rearporttemplate',
-        'dcim.add_modulebaytemplate',
-    ]
     queryset = ModuleType.objects.all()
     model_form = forms.ModuleTypeImportForm
     related_object_forms = {

+ 18 - 4
netbox/netbox/views/generic/bulk_views.py

@@ -8,7 +8,7 @@ from types import SimpleNamespace
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
-from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
+from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ValidationError
 from django.db import IntegrityError, router, transaction
 from django.db.models import ManyToManyField, ProtectedError, RestrictedError
 from django.db.models.fields.reverse_related import ManyToManyRel
@@ -38,6 +38,7 @@ from utilities.jobs import is_background_request, process_request_as_job
 from utilities.permissions import get_permission_for_model
 from utilities.query import reapply_model_ordering
 from utilities.querydict import normalize_querydict
+from utilities.querysets import RestrictedQuerySet
 from utilities.request import safe_for_redirect
 from utilities.string import title
 from utilities.tables import get_table_configs
@@ -540,6 +541,18 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                     )
                 )
 
+            # Require permission to create each type of related object being imported
+            model = related_object_form.Meta.model
+            restricted = isinstance(model.objects.all(), RestrictedQuerySet)
+            permission = get_permission_for_model(model, 'add')
+            if related_objects and restricted and not request.user.has_perm(permission):
+                raise ValidationError(
+                    self._compile_form_errors(
+                        {field_name: [_("Permission {permission} is required.").format(permission=permission)]},
+                        index=parent_idx
+                    )
+                )
+
             related_obj_pks = []
             for i, rel_obj_data in enumerate(related_objects, start=1):
                 if not isinstance(rel_obj_data, dict):
@@ -567,9 +580,10 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
                     )
 
             # Enforce object-level permissions on related objects
-            model = related_object_form.Meta.model
-            if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks):
-                raise ObjectDoesNotExist
+            if restricted and related_obj_pks:
+                permitted = model.objects.restrict(request.user, 'add').filter(pk__in=related_obj_pks)
+                if permitted.count() != len(related_obj_pks):
+                    raise PermissionsViolation()
 
         if is_background_request(request):
             request.job.logger.info(f'{_action} {obj}')