Fixes #20929: Require render_config permission for UI config rendering (#20975)

* Closes #20929: Require render_config permission for UI config rendering

- Modified `ObjectRenderConfigView.has_permission()` to require both view and render_config permissions
- Added `remove_permissions()` test helper to remove permissions from existing ObjectPermission objects
- Added regression tests for Device and VirtualMachine render-config permission enforcement

The `render_config` permission action was introduced in #16681 for API endpoints. This extends PR_7604_description
to the UI render-config tabs, preventing users from viewing rendered configurations without explicit permission.

* Address PR feedback

* Address PR feedback
This commit is contained in:
Jason Novinger
2025-12-16 07:09:25 -06:00
committed by GitHub
parent 875e3e7979
commit a364ee832d
5 changed files with 58 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ from core.models import ObjectType
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.models import ConfigTemplate
from ipam.models import ASN, RIR, VLAN, VRF from ipam.models import ASN, RIR, VLAN, VRF
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices
from tenancy.models import Tenant from tenancy.models import Tenant
@@ -2339,6 +2340,28 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
url = reverse('dcim:device_inventory', kwargs={'pk': device.pk}) url = reverse('dcim:device_inventory', kwargs={'pk': device.pk})
self.assertHttpStatus(self.client.get(url), 200) self.assertHttpStatus(self.client.get(url), 200)
def test_device_renderconfig(self):
configtemplate = ConfigTemplate.objects.create(
name='Test Config Template',
template_code='Config for device {{ device.name }}'
)
device = Device.objects.first()
device.config_template = configtemplate
device.save()
url = reverse('dcim:device_render-config', kwargs={'pk': device.pk})
# User with only view permission should NOT be able to render config
self.add_permissions('dcim.view_device')
self.assertHttpStatus(self.client.get(url), 403)
# With render_config permission added should be able to render config
self.add_permissions('dcim.render_config_device')
self.assertHttpStatus(self.client.get(url), 200)
# With view permission removed should NOT be able to render config
self.remove_permissions('dcim.view_device')
self.assertHttpStatus(self.client.get(url), 403)
class ModuleTestCase( class ModuleTestCase(
# Module does not support bulk renaming (no name field) or # Module does not support bulk renaming (no name field) or

View File

@@ -2682,6 +2682,7 @@ class DeviceConfigContextView(ObjectConfigContextView):
class DeviceRenderConfigView(ObjectRenderConfigView): class DeviceRenderConfigView(ObjectRenderConfigView):
queryset = Device.objects.all() queryset = Device.objects.all()
base_template = 'dcim/device/base.html' base_template = 'dcim/device/base.html'
additional_permissions = ['dcim.render_config_device']
tab = ViewTab( tab = ViewTab(
label=_('Render Config'), label=_('Render Config'),
weight=2100, weight=2100,

View File

@@ -67,6 +67,16 @@ class TestCase(_TestCase):
obj_perm.users.add(self.user) obj_perm.users.add(self.user)
obj_perm.object_types.add(object_type) obj_perm.object_types.add(object_type)
def remove_permissions(self, *names):
"""
Remove a set of permissions from the test user. Accepts permission names in the form <app>.<action>_<model>.
"""
for name in names:
object_type, action = resolve_permission_type(name)
ObjectPermission.objects.filter(
actions__contains=[action], object_types=object_type, users=self.user
).delete()
# #
# Custom assertions # Custom assertions
# #

View File

@@ -4,6 +4,7 @@ from django.urls import reverse
from dcim.choices import InterfaceModeChoices from dcim.choices import InterfaceModeChoices
from dcim.models import DeviceRole, Platform, Site from dcim.models import DeviceRole, Platform, Site
from extras.models import ConfigTemplate
from ipam.models import VLAN, VRF from ipam.models import VLAN, VRF
from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine
from virtualization.choices import * from virtualization.choices import *
@@ -326,6 +327,28 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
url = reverse('virtualization:virtualmachine_interfaces', kwargs={'pk': virtualmachine.pk}) url = reverse('virtualization:virtualmachine_interfaces', kwargs={'pk': virtualmachine.pk})
self.assertHttpStatus(self.client.get(url), 200) self.assertHttpStatus(self.client.get(url), 200)
def test_virtualmachine_renderconfig(self):
configtemplate = ConfigTemplate.objects.create(
name='Test Config Template',
template_code='Config for VM {{ virtualmachine.name }}'
)
vm = VirtualMachine.objects.first()
vm.config_template = configtemplate
vm.save()
url = reverse('virtualization:virtualmachine_render-config', kwargs={'pk': vm.pk})
# User with only view permission should NOT be able to render config
self.add_permissions('virtualization.view_virtualmachine')
self.assertHttpStatus(self.client.get(url), 403)
# With render_config permission added should be able to render config
self.add_permissions('virtualization.render_config_virtualmachine')
self.assertHttpStatus(self.client.get(url), 200)
# With view permission removed should NOT be able to render config
self.remove_permissions('virtualization.view_virtualmachine')
self.assertHttpStatus(self.client.get(url), 403)
class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = VMInterface model = VMInterface

View File

@@ -405,6 +405,7 @@ class VirtualMachineConfigContextView(ObjectConfigContextView):
class VirtualMachineRenderConfigView(ObjectRenderConfigView): class VirtualMachineRenderConfigView(ObjectRenderConfigView):
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.all()
base_template = 'virtualization/virtualmachine/base.html' base_template = 'virtualization/virtualmachine/base.html'
additional_permissions = ['virtualization.render_config_virtualmachine']
tab = ViewTab( tab = ViewTab(
label=_('Render Config'), label=_('Render Config'),
weight=2100, weight=2100,