Преглед изворни кода

Fixes #23585: Enforce view permissions in component and bulk add forms (#23602)

The component add views and the prefix, IP address and VLAN bulk add
views did not restrict form fields to objects the user may view, so
hidden parents and related objects were accepted and their names were
rendered. Front port forms now also list only rear ports the user may
view.

Fixes #23585
Martin Hauser пре 10 часа
родитељ
комит
09f76d6932

+ 8 - 3
netbox/dcim/forms/mixins.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 
 
 from dcim.constants import LOCATION_SCOPE_TYPES
 from dcim.constants import LOCATION_SCOPE_TYPES
 from dcim.utils import reconcile_port_mappings
 from dcim.utils import reconcile_port_mappings
+from netbox.context import current_request
 from utilities.forms import GenericObjectFormMixin
 from utilities.forms import GenericObjectFormMixin
 from utilities.forms.fields import (
 from utilities.forms.fields import (
     CSVContentTypeField,
     CSVContentTypeField,
@@ -157,16 +158,20 @@ class FrontPortFormMixin(forms.Form):
 
 
     def _get_rear_port_choices(self, parent_filter, front_port):
     def _get_rear_port_choices(self, parent_filter, front_port):
         """
         """
-        Return a list of choices representing each available rear port & position pair on the parent object (identified
-        by a Q filter), excluding those assigned to the specified instance.
+        Return rear port position choices for the parent, excluding positions assigned to other front ports.
         """
         """
         occupied_rear_port_positions = [
         occupied_rear_port_positions = [
             f'{mapping.rear_port_id}:{mapping.rear_port_position}'
             f'{mapping.rear_port_id}:{mapping.rear_port_position}'
             for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
             for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
         ]
         ]
 
 
+        # rear_ports is a plain choice field, so restrict_form_fields() cannot narrow it
+        rear_ports = self.rear_port_model.objects.filter(parent_filter)
+        if request := current_request.get():
+            rear_ports = rear_ports.restrict(request.user, 'view')
+
         choices = []
         choices = []
-        for rear_port in self.rear_port_model.objects.filter(parent_filter):
+        for rear_port in rear_ports:
             for i in range(1, rear_port.positions + 1):
             for i in range(1, rear_port.positions + 1):
                 pair_id = f'{rear_port.pk}:{i}'
                 pair_id = f'{rear_port.pk}:{i}'
                 if pair_id not in occupied_rear_port_positions:
                 if pair_id not in occupied_rear_port_positions:

+ 2 - 2
netbox/dcim/tests/test_channelization.py

@@ -1633,7 +1633,7 @@ class ChannelizedBulkCreateTestCase(ViewTestCase):
         parent = Interface.objects.create(
         parent = Interface.objects.create(
             device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
             device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
         )
         )
-        self.add_permissions('dcim.add_interface', 'dcim.view_interface')
+        self.add_permissions('dcim.add_interface', 'dcim.view_interface', 'dcim.view_device')
 
 
         request_data = {
         request_data = {
             'device': self.device.pk,
             'device': self.device.pk,
@@ -1656,7 +1656,7 @@ class ChannelizedBulkCreateTestCase(ViewTestCase):
         parent = InterfaceTemplate.objects.create(
         parent = InterfaceTemplate.objects.create(
             device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
             device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
         )
         )
-        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate')
+        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate', 'dcim.view_devicetype')
 
 
         request_data = {
         request_data = {
             'device_type': self.device_type.pk,
             'device_type': self.device_type.pk,

+ 122 - 3
netbox/dcim/tests/test_views.py

@@ -2395,6 +2395,7 @@ class ModuleBayTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
 class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ConsolePortTemplate
     model = ConsolePortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2428,6 +2429,7 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 
 class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ConsoleServerPortTemplate
     model = ConsoleServerPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2460,6 +2462,7 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie
 
 
 class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerPortTemplate
     model = PowerPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2498,6 +2501,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerOutletTemplate
     model = PowerOutletTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_powerporttemplate')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2540,6 +2544,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 
 class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InterfaceTemplate
     model = InterfaceTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2580,7 +2585,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
         module_type = ModuleType.objects.create(manufacturer=device_type.manufacturer, model='Module Type 1')
         module_type = ModuleType.objects.create(manufacturer=device_type.manufacturer, model='Module Type 1')
         return_url = reverse('dcim:moduletype_interfaces', kwargs={'pk': module_type.pk})
         return_url = reverse('dcim:moduletype_interfaces', kwargs={'pk': module_type.pk})
 
 
-        self.add_permissions('dcim.add_interfacetemplate')
+        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_moduletype')
 
 
         response = self.client.post(
         response = self.client.post(
             f"{self._get_url('add')}?device_type={device_type.pk}&return_url={return_url}",
             f"{self._get_url('add')}?device_type={device_type.pk}&return_url={return_url}",
@@ -2602,6 +2607,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = FrontPortTemplate
     model = FrontPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_rearporttemplate')
     validation_excluded_fields = ('name', 'label', 'rear_port')
     validation_excluded_fields = ('name', 'label', 'rear_port')
 
 
     @classmethod
     @classmethod
@@ -2653,6 +2659,7 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = RearPortTemplate
     model = RearPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2687,6 +2694,7 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
 
 
 class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ModuleBayTemplate
     model = ModuleBayTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2768,6 +2776,7 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = DeviceBayTemplate
     model = DeviceBayTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -2803,6 +2812,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 
 class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InventoryItemTemplate
     model = InventoryItemTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_manufacturer')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -3718,6 +3728,7 @@ class ModuleTestCase(
 
 
 class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsolePort
     model = ConsolePort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -3834,6 +3845,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsoleServerPort
     model = ConsoleServerPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -3904,6 +3916,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerPort
     model = PowerPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -3980,6 +3993,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerOutlet
     model = PowerOutlet
+    bulk_create_permissions = ('dcim.view_device', 'dcim.view_powerport', 'extras.view_tag', 'users.view_owner')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4066,6 +4080,10 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = Interface
     model = Interface
+    bulk_create_permissions = (
+        'dcim.view_device', 'dcim.view_interface', 'extras.view_tag', 'ipam.view_vlan', 'ipam.view_vrf',
+        'wireless.view_wirelesslan',
+    )
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4284,6 +4302,49 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
             [visible_device.pk]
             [visible_device.pk]
         )
         )
 
 
+    @tag('regression')  # Issue #23585
+    def test_add_interface_excludes_restricted_devices(self):
+        """
+        The interface form only displays and accepts devices the user can view.
+        """
+        visible_device = Device.objects.get(name='Device 1')
+        site = Site.objects.create(name='Site 2', slug='site-2')
+        hidden_device = create_test_device('Hidden Device', site=site)
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['view'],
+            constraints={'site__slug': 'site-1'},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Device))
+        self.add_permissions('dcim.add_interface')
+        url = reverse('dcim:interface_add')
+        data = {
+            'device': hidden_device.pk,
+            'name': 'Restricted Interface',
+            'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
+        }
+
+        # Creation rejects the hidden device
+        response = self.client.post(url, data=post_data(data))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('device', response.context['form'].errors)
+        self.assertNotContains(response, hidden_device.name)
+        self.assertFalse(Interface.objects.filter(name='Restricted Interface').exists())
+
+        # The form does not display the hidden device
+        response = self.client.get(url, {'device': hidden_device.pk})
+        self.assertHttpStatus(response, 200)
+        self.assertNotContains(response, hidden_device.name)
+
+        # A visible device is accepted
+        data['device'] = visible_device.pk
+        response = self.client.post(url, data=post_data(data))
+        self.assertHttpStatus(response, 302)
+        self.assertTrue(Interface.objects.filter(device=visible_device, name='Restricted Interface').exists())
+
     def test_rename_select_all_spans_pages(self):
     def test_rename_select_all_spans_pages(self):
         """
         """
         Tests the bulk rename functionality for interfaces spanning multiple pages in the UI.
         Tests the bulk rename functionality for interfaces spanning multiple pages in the UI.
@@ -4328,7 +4389,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         Submitting the Interface form with a mac_address string creates a MACAddress
         Submitting the Interface form with a mac_address string creates a MACAddress
         and sets it as primary in one request.
         and sets it as primary in one request.
         """
         """
-        self.add_permissions('dcim.add_interface', 'dcim.add_macaddress')
+        self.add_permissions(
+            'dcim.add_interface', 'dcim.add_macaddress', 'dcim.view_device', 'dcim.view_interface', 'extras.view_tag',
+            'ipam.view_vlan', 'ipam.view_vrf', 'wireless.view_wirelesslan',
+        )
 
 
         data = {**self.form_data, 'mac_address': 'AA:BB:CC:DD:EE:FF', 'changelog_message': 'test'}
         data = {**self.form_data, 'mac_address': 'AA:BB:CC:DD:EE:FF', 'changelog_message': 'test'}
         response = self.client.post(self._get_url('add'), data=post_data(data))
         response = self.client.post(self._get_url('add'), data=post_data(data))
@@ -4478,6 +4542,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort
     model = FrontPort
+    bulk_create_permissions = ('dcim.view_device', 'dcim.view_rearport', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label', 'rear_port')
     validation_excluded_fields = ('name', 'label', 'rear_port')
 
 
     @classmethod
     @classmethod
@@ -4600,7 +4665,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             RearPort(device=device, name='Rear Port 8', positions=2),
             RearPort(device=device, name='Rear Port 8', positions=2),
         )
         )
         RearPort.objects.bulk_create(rear_ports)
         RearPort.objects.bulk_create(rear_ports)
-        self.add_permissions('dcim.add_frontport')
+        self.add_permissions('dcim.add_frontport', 'dcim.view_device', 'dcim.view_rearport')
 
 
         response = self.client.post(self._get_url('add'), post_data({
         response = self.client.post(self._get_url('add'), post_data({
             'device': device.pk,
             'device': device.pk,
@@ -4627,6 +4692,50 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
                 [(1, rear_port.pk, 1), (2, rear_port.pk, 2)]
                 [(1, rear_port.pk, 1), (2, rear_port.pk, 2)]
             )
             )
 
 
+    @tag('regression')  # Issue #23585
+    def test_add_front_port_excludes_restricted_rear_ports(self):
+        """
+        The front port form only offers and accepts rear ports the user can view.
+        """
+        device = Device.objects.get(name='Device 1')
+        visible_rear_port = RearPort.objects.create(device=device, name='Visible Rear Port')
+        hidden_rear_port = RearPort.objects.create(device=device, name='Hidden Rear Port')
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['view'],
+            constraints={'pk': visible_rear_port.pk},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(RearPort))
+        self.add_permissions('dcim.add_frontport', 'dcim.view_device')
+        url = reverse('dcim:frontport_add')
+        data = {
+            'device': device.pk,
+            'name': 'Restricted Front Port',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 1,
+        }
+
+        # Creation rejects the hidden rear port
+        response = self.client.post(url, data=post_data({**data, 'rear_ports': [f'{hidden_rear_port.pk}:1']}))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('rear_ports', response.context['form'].errors)
+        self.assertFalse(FrontPort.objects.filter(name='Restricted Front Port').exists())
+
+        # The form offers only the visible rear port
+        response = self.client.get(url, {'device': device.pk})
+        self.assertContains(response, visible_rear_port.name)
+        self.assertNotContains(response, hidden_rear_port.name)
+
+        # A visible rear port is accepted
+        response = self.client.post(url, data=post_data({**data, 'rear_ports': [f'{visible_rear_port.pk}:1']}))
+        self.assertHttpStatus(response, 302)
+        self.assertTrue(
+            PortMapping.objects.filter(front_port__name='Restricted Front Port', rear_port=visible_rear_port).exists()
+        )
+
     def test_trace(self):
     def test_trace(self):
         self.add_permissions(
         self.add_permissions(
             'dcim.view_frontport',
             'dcim.view_frontport',
@@ -4648,6 +4757,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = RearPort
     model = RearPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4721,6 +4831,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ModuleBay
     model = ModuleBay
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4874,6 +4985,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = DeviceBay
     model = DeviceBay
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -4927,6 +5039,9 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = InventoryItem
     model = InventoryItem
+    bulk_create_permissions = (
+        'dcim.view_device', 'dcim.view_inventoryitemrole', 'dcim.view_manufacturer', 'extras.view_tag',
+    )
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -6040,6 +6155,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
 class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = CoolingIntakeTemplate
     model = CoolingIntakeTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -6084,6 +6200,7 @@ class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
 
 
 class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = CoolingOutflowTemplate
     model = CoolingOutflowTemplate
+    bulk_create_permissions = ('dcim.view_coolingintaketemplate', 'dcim.view_devicetype')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -6129,6 +6246,7 @@ class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTe
 
 
 class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = CoolingIntake
     model = CoolingIntake
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod
@@ -6194,6 +6312,7 @@ class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class CoolingOutflowTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class CoolingOutflowTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = CoolingOutflow
     model = CoolingOutflow
+    bulk_create_permissions = ('dcim.view_coolingintake', 'dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
     validation_excluded_fields = ('name', 'label')
 
 
     @classmethod
     @classmethod

+ 49 - 5
netbox/ipam/tests/test_views.py

@@ -3,7 +3,7 @@ from unittest.mock import patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.db.backends.postgresql.psycopg_any import NumericRange
 from django.db.backends.postgresql.psycopg_any import NumericRange
-from django.test import RequestFactory, override_settings
+from django.test import RequestFactory, override_settings, tag
 from django.urls import reverse
 from django.urls import reverse
 from netaddr import IPNetwork
 from netaddr import IPNetwork
 
 
@@ -989,6 +989,50 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for objectchange in objectchanges:
         for objectchange in objectchanges:
             self.assertEqual(objectchange.message, changelog_message)
             self.assertEqual(objectchange.message, changelog_message)
 
 
+    @tag('regression')  # Issue #23585
+    def test_bulk_add_prefixes_excludes_restricted_vrfs(self):
+        """
+        The prefix bulk-add form only displays and accepts VRFs the user can view.
+        """
+        visible_vrf = VRF.objects.get(name='VRF 1')
+        hidden_vrf = VRF.objects.get(name='VRF 2')
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['view'],
+            constraints={'pk': visible_vrf.pk},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(VRF))
+        self.add_permissions('ipam.add_prefix')
+        url = reverse('ipam:prefix_bulk_add')
+        data = {
+            'pattern': '198.18.[40-41].0/24',
+            'vrf': hidden_vrf.pk,
+            'status': PrefixStatusChoices.STATUS_ACTIVE,
+        }
+
+        # Creation rejects the hidden VRF
+        response = self.client.post(url, data)
+        self.assertHttpStatus(response, 200)
+        self.assertNotContains(response, hidden_vrf.name)
+        self.assertFalse(Prefix.objects.filter(prefix=IPNetwork('198.18.40.0/24')).exists())
+
+        # Neither the form nor its HTMX re-render displays the hidden VRF
+        for headers in ({}, {'HX-Request': 'true'}):
+            with self.subTest(headers=headers):
+                response = self.client.get(url, {'vrf': hidden_vrf.pk}, headers=headers)
+                self.assertHttpStatus(response, 200)
+                self.assertNotContains(response, hidden_vrf.name)
+
+        # A visible VRF is accepted
+        data['vrf'] = visible_vrf.pk
+        response = self.client.post(url, data)
+        self.assertHttpStatus(response, 302)
+        for i in (40, 41):
+            self.assertTrue(Prefix.objects.filter(prefix=IPNetwork(f'198.18.{i}.0/24'), vrf=visible_vrf).exists())
+
     def test_prefix_prefixes(self):
     def test_prefix_prefixes(self):
         self.add_permissions('ipam.view_prefix')
         self.add_permissions('ipam.view_prefix')
         prefixes = (
         prefixes = (
@@ -2460,7 +2504,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
     def test_bulk_add_vlans(self):
     def test_bulk_add_vlans(self):
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
 
 
         group = VLANGroup.objects.get(name='VLAN Group 1')
         group = VLANGroup.objects.get(name='VLAN Group 1')
         initial_count = VLAN.objects.count()
         initial_count = VLAN.objects.count()
@@ -2488,7 +2532,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             )
             )
 
 
     def test_bulk_add_vlans_rolls_back_on_duplicate_name(self):
     def test_bulk_add_vlans_rolls_back_on_duplicate_name(self):
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
 
 
         group = VLANGroup.objects.get(name='VLAN Group 1')
         group = VLANGroup.objects.get(name='VLAN Group 1')
         initial_count = VLAN.objects.count()
         initial_count = VLAN.objects.count()
@@ -2507,7 +2551,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertFalse(VLAN.objects.filter(group=group, vid=110).exists())
         self.assertFalse(VLAN.objects.filter(group=group, vid=110).exists())
 
 
     def test_bulk_add_vlans_rolls_back_when_any_id_outside_group_range(self):
     def test_bulk_add_vlans_rolls_back_when_any_id_outside_group_range(self):
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
 
 
         group = VLANGroup.objects.create(
         group = VLANGroup.objects.create(
             name='Restricted VLAN Group',
             name='Restricted VLAN Group',
@@ -2533,7 +2577,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
     def test_bulk_add_vlans_pattern_shapes(self):
     def test_bulk_add_vlans_pattern_shapes(self):
         """Single values, multiple values, ranges, and combinations create the expected VLANs."""
         """Single values, multiple values, ranges, and combinations create the expected VLANs."""
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
         # The combination runs against a second group: subTests share one transaction, and VIDs
         # The combination runs against a second group: subTests share one transaction, and VIDs
         # 10 & 20 would otherwise collide with the multiple-values case via the (group, vid) constraint.
         # 10 & 20 would otherwise collide with the multiple-values case via the (group, vid) constraint.
         cases = (
         cases = (

+ 1 - 1
netbox/netbox/tests/test_views.py

@@ -450,7 +450,7 @@ class RolledBackWriteEventQueueTestCase(DeleteAtomicityMixin, TestCase):
         # created events for the objects written before the abort.
         # created events for the objects written before the abort.
         group = VLANGroup.objects.create(name='Test Group', slug='test-group')
         group = VLANGroup.objects.create(name='Test Group', slug='test-group')
         VLAN.objects.create(group=group, vid=101, name='VLAN-101')
         VLAN.objects.create(group=group, vid=101, name='VLAN-101')
-        self.add_permissions('ipam.add_vlan', 'ipam.view_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlan', 'ipam.view_vlangroup')
 
 
         with self.assertNoEventsDispatched():
         with self.assertNoEventsDispatched():
             response = self.client.post(reverse('ipam:vlan_bulk_add'), data={
             response = self.client.post(reverse('ipam:vlan_bulk_add'), data={

+ 3 - 0
netbox/netbox/views/generic/bulk_views.py

@@ -376,6 +376,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
             # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
             # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
             # copy of the POST QueryDict so that we can update the target field value.
             # copy of the POST QueryDict so that we can update the target field value.
             model_form = self.model_form(self.get_model_form_data(form, request, value))
             model_form = self.model_form(self.get_model_form_data(form, request, value))
+            restrict_form_fields(model_form, request.user)
 
 
             # Validate each new object independently.
             # Validate each new object independently.
             if model_form.is_valid():
             if model_form.is_valid():
@@ -416,6 +417,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
 
 
         form = self.form()
         form = self.form()
         model_form = self.model_form(initial=initial)
         model_form = self.model_form(initial=initial)
+        restrict_form_fields(model_form, request.user)
 
 
         # HTMX partial: only re-render the model form fields
         # HTMX partial: only re-render the model form fields
         if htmx_partial(request):
         if htmx_partial(request):
@@ -430,6 +432,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
         model = self.queryset.model
         model = self.queryset.model
         form = self.form(request.POST)
         form = self.form(request.POST)
         model_form = self.model_form(request.POST)
         model_form = self.model_form(request.POST)
+        restrict_form_fields(model_form, request.user)
 
 
         # HTMX partial: only re-render the model form fields
         # HTMX partial: only re-render the model form fields
         if htmx_partial(request):
         if htmx_partial(request):

+ 2 - 0
netbox/netbox/views/generic/object_views.py

@@ -553,6 +553,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
         initial_data = normalize_querydict(request.GET)
         initial_data = normalize_querydict(request.GET)
 
 
         form = self.form(data=data, initial=initial_data)
         form = self.form(data=data, initial=initial_data)
+        restrict_form_fields(form, request.user)
 
 
         return form
         return form
 
 
@@ -597,6 +598,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         data.setlist(k, v)
                         data.setlist(k, v)
 
 
                 component_form = self.model_form(data)
                 component_form = self.model_form(data)
+                restrict_form_fields(component_form, request.user)
 
 
                 if component_form.is_valid():
                 if component_form.is_valid():
                     new_components.append(component_form)
                     new_components.append(component_form)

+ 5 - 0
netbox/utilities/testing/views.py

@@ -548,9 +548,11 @@ class ViewTestCases:
 
 
         :bulk_create_count: The number of objects expected to be created (default: 3).
         :bulk_create_count: The number of objects expected to be created (default: 3).
         :bulk_create_data: A dictionary of data to be used for bulk object creation.
         :bulk_create_data: A dictionary of data to be used for bulk object creation.
+        :bulk_create_permissions: View permissions for the related objects referenced by bulk_create_data.
         """
         """
         bulk_create_count = 3
         bulk_create_count = 3
         bulk_create_data = {}
         bulk_create_data = {}
+        bulk_create_permissions = ()
         validation_excluded_fields = []
         validation_excluded_fields = []
 
 
         def test_create_multiple_objects_without_permission(self):
         def test_create_multiple_objects_without_permission(self):
@@ -578,6 +580,7 @@ class ViewTestCases:
             obj_perm.save()
             obj_perm.save()
             obj_perm.users.add(self.user)
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+            self.add_permissions(*self.bulk_create_permissions)
 
 
             # Bulk create objects
             # Bulk create objects
             response = self.client.post(**request)
             response = self.client.post(**request)
@@ -602,6 +605,7 @@ class ViewTestCases:
             obj_perm.save()
             obj_perm.save()
             obj_perm.users.add(self.user)
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+            self.add_permissions(*self.bulk_create_permissions)
 
 
             # Attempt to make the request with unmet constraints
             # Attempt to make the request with unmet constraints
             self.assertHttpStatus(self.client.post(**request), 200)
             self.assertHttpStatus(self.client.post(**request), 200)
@@ -635,6 +639,7 @@ class ViewTestCases:
             obj_perm.save()
             obj_perm.save()
             obj_perm.users.add(self.user)
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+            self.add_permissions(*self.bulk_create_permissions)
 
 
             response = self.client.post(
             response = self.client.post(
                 path=self._get_url('add'),
                 path=self._get_url('add'),

+ 9 - 1
netbox/virtualization/tests/test_views.py

@@ -608,6 +608,10 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 
 class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = VMInterface
     model = VMInterface
+    bulk_create_permissions = (
+        'extras.view_tag', 'ipam.view_vlan', 'ipam.view_vrf', 'virtualization.view_virtualmachine',
+        'virtualization.view_vminterface',
+    )
     validation_excluded_fields = ('name',)
     validation_excluded_fields = ('name',)
 
 
     @classmethod
     @classmethod
@@ -741,7 +745,10 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         Submitting the VMInterface form with a mac_address string creates a MACAddress
         Submitting the VMInterface form with a mac_address string creates a MACAddress
         and sets it as primary in one request.
         and sets it as primary in one request.
         """
         """
-        self.add_permissions('virtualization.add_vminterface', 'dcim.add_macaddress')
+        self.add_permissions(
+            'virtualization.add_vminterface', 'dcim.add_macaddress', 'extras.view_tag', 'ipam.view_vlan',
+            'ipam.view_vrf', 'virtualization.view_virtualmachine', 'virtualization.view_vminterface',
+        )
 
 
         data = {**self.form_data, 'mac_address': 'AA:BB:CC:DD:EE:FF', 'changelog_message': 'test'}
         data = {**self.form_data, 'mac_address': 'AA:BB:CC:DD:EE:FF', 'changelog_message': 'test'}
         response = self.client.post(self._get_url('add'), data=post_data(data))
         response = self.client.post(self._get_url('add'), data=post_data(data))
@@ -792,6 +799,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 
 class VirtualDiskTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class VirtualDiskTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = VirtualDisk
     model = VirtualDisk
+    bulk_create_permissions = ('extras.view_tag', 'virtualization.view_virtualmachine')
     validation_excluded_fields = ('name',)
     validation_excluded_fields = ('name',)
 
 
     @classmethod
     @classmethod