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

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.utils import reconcile_port_mappings
+from netbox.context import current_request
 from utilities.forms import GenericObjectFormMixin
 from utilities.forms.fields import (
     CSVContentTypeField,
@@ -157,16 +158,20 @@ class FrontPortFormMixin(forms.Form):
 
     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 = [
             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)
         ]
 
+        # 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 = []
-        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):
                 pair_id = f'{rear_port.pk}:{i}'
                 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(
             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 = {
             'device': self.device.pk,
@@ -1656,7 +1656,7 @@ class ChannelizedBulkCreateTestCase(ViewTestCase):
         parent = InterfaceTemplate.objects.create(
             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 = {
             '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):
     model = ConsolePortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2428,6 +2429,7 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ConsoleServerPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2460,6 +2462,7 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie
 
 class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2498,6 +2501,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerOutletTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_powerporttemplate')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2540,6 +2544,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InterfaceTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2580,7 +2585,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
         module_type = ModuleType.objects.create(manufacturer=device_type.manufacturer, model='Module Type 1')
         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(
             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):
     model = FrontPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_rearporttemplate')
     validation_excluded_fields = ('name', 'label', 'rear_port')
 
     @classmethod
@@ -2653,6 +2659,7 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = RearPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2687,6 +2694,7 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
 
 class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ModuleBayTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2768,6 +2776,7 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = DeviceBayTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2803,6 +2812,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InventoryItemTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_manufacturer')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -3718,6 +3728,7 @@ class ModuleTestCase(
 
 class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsolePort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -3834,6 +3845,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsoleServerPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -3904,6 +3916,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -3980,6 +3993,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerOutlet
+    bulk_create_permissions = ('dcim.view_device', 'dcim.view_powerport', 'extras.view_tag', 'users.view_owner')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -4066,6 +4080,10 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     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')
 
     @classmethod
@@ -4284,6 +4302,49 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
             [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):
         """
         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
         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'}
         response = self.client.post(self._get_url('add'), data=post_data(data))
@@ -4478,6 +4542,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort
+    bulk_create_permissions = ('dcim.view_device', 'dcim.view_rearport', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label', 'rear_port')
 
     @classmethod
@@ -4600,7 +4665,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             RearPort(device=device, name='Rear Port 8', positions=2),
         )
         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({
             'device': device.pk,
@@ -4627,6 +4692,50 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
                 [(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):
         self.add_permissions(
             'dcim.view_frontport',
@@ -4648,6 +4757,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = RearPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -4721,6 +4831,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ModuleBay
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -4874,6 +4985,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = DeviceBay
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -4927,6 +5039,9 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = InventoryItem
+    bulk_create_permissions = (
+        'dcim.view_device', 'dcim.view_inventoryitemrole', 'dcim.view_manufacturer', 'extras.view_tag',
+    )
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -6040,6 +6155,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = CoolingIntakeTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -6084,6 +6200,7 @@ class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
 
 class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = CoolingOutflowTemplate
+    bulk_create_permissions = ('dcim.view_coolingintaketemplate', 'dcim.view_devicetype')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -6129,6 +6246,7 @@ class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTe
 
 class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = CoolingIntake
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -6194,6 +6312,7 @@ class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class CoolingOutflowTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = CoolingOutflow
+    bulk_create_permissions = ('dcim.view_coolingintake', 'dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @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.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 netaddr import IPNetwork
 
@@ -989,6 +989,50 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for objectchange in objectchanges:
             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):
         self.add_permissions('ipam.view_prefix')
         prefixes = (
@@ -2460,7 +2504,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
     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')
         initial_count = VLAN.objects.count()
@@ -2488,7 +2532,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             )
 
     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')
         initial_count = VLAN.objects.count()
@@ -2507,7 +2551,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertFalse(VLAN.objects.filter(group=group, vid=110).exists())
 
     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(
             name='Restricted VLAN Group',
@@ -2533,7 +2577,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
     def test_bulk_add_vlans_pattern_shapes(self):
         """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
         # 10 & 20 would otherwise collide with the multiple-values case via the (group, vid) constraint.
         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.
         group = VLANGroup.objects.create(name='Test Group', slug='test-group')
         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():
             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
             # 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))
+            restrict_form_fields(model_form, request.user)
 
             # Validate each new object independently.
             if model_form.is_valid():
@@ -416,6 +417,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
 
         form = self.form()
         model_form = self.model_form(initial=initial)
+        restrict_form_fields(model_form, request.user)
 
         # HTMX partial: only re-render the model form fields
         if htmx_partial(request):
@@ -430,6 +432,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
         model = self.queryset.model
         form = self.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
         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)
 
         form = self.form(data=data, initial=initial_data)
+        restrict_form_fields(form, request.user)
 
         return form
 
@@ -597,6 +598,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
                         data.setlist(k, v)
 
                 component_form = self.model_form(data)
+                restrict_form_fields(component_form, request.user)
 
                 if component_form.is_valid():
                     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_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_data = {}
+        bulk_create_permissions = ()
         validation_excluded_fields = []
 
         def test_create_multiple_objects_without_permission(self):
@@ -578,6 +580,7 @@ class ViewTestCases:
             obj_perm.save()
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+            self.add_permissions(*self.bulk_create_permissions)
 
             # Bulk create objects
             response = self.client.post(**request)
@@ -602,6 +605,7 @@ class ViewTestCases:
             obj_perm.save()
             obj_perm.users.add(self.user)
             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
             self.assertHttpStatus(self.client.post(**request), 200)
@@ -635,6 +639,7 @@ class ViewTestCases:
             obj_perm.save()
             obj_perm.users.add(self.user)
             obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+            self.add_permissions(*self.bulk_create_permissions)
 
             response = self.client.post(
                 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):
     model = VMInterface
+    bulk_create_permissions = (
+        'extras.view_tag', 'ipam.view_vlan', 'ipam.view_vrf', 'virtualization.view_virtualmachine',
+        'virtualization.view_vminterface',
+    )
     validation_excluded_fields = ('name',)
 
     @classmethod
@@ -741,7 +745,10 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         Submitting the VMInterface form with a mac_address string creates a MACAddress
         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'}
         response = self.client.post(self._get_url('add'), data=post_data(data))
@@ -792,6 +799,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class VirtualDiskTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = VirtualDisk
+    bulk_create_permissions = ('extras.view_tag', 'virtualization.view_virtualmachine')
     validation_excluded_fields = ('name',)
 
     @classmethod