From e81e192d2e30e7be66aea92955a2828151e488a1 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Fri, 12 Dec 2025 13:11:53 -0600 Subject: [PATCH 1/3] 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. --- netbox/dcim/tests/test_views.py | 24 +++++++++++++++++++++ netbox/extras/views.py | 9 ++++++++ netbox/utilities/testing/base.py | 12 +++++++++++ netbox/virtualization/tests/test_views.py | 26 ++++++++++++++++++++++- 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 52b02d982..373ad4b20 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,6 +11,7 @@ from core.models import ObjectType from dcim.choices import * from dcim.constants import * from dcim.models import * +from extras.models import ConfigTemplate from ipam.models import ASN, RIR, VLAN, VRF from netbox.choices import CSVDelimiterChoices, ImportFormatChoices, WeightUnitChoices from tenancy.models import Tenant @@ -2339,6 +2340,29 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('dcim:device_inventory', kwargs={'pk': device.pk}) self.assertHttpStatus(self.client.get(url), 200) + @tag('regression') # #20929 + def test_device_renderconfig_permission(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( # Module does not support bulk renaming (no name field) or diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3c1fc395d..34b2f0a47 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -27,6 +27,7 @@ from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import htmx_partial, htmx_maybe_redirect_current_page from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.permissions import get_permission_for_model from utilities.query import count_related from utilities.querydict import normalize_querydict from utilities.request import copy_safe_request @@ -1082,6 +1083,14 @@ class ObjectRenderConfigView(generic.ObjectView): base_template = None template_name = 'extras/object_render_config.html' + def has_permission(self): + if super().has_permission(): # enforce base required permission + perm = get_permission_for_model(self.queryset.model, 'render_config') + if self.request.user.has_perm(perm): + self.queryset = self.queryset.restrict(self.request.user, 'render_config') + return True + return False + def get(self, request, **kwargs): instance = self.get_object(**kwargs) context = self.get_extra_context(request, instance) diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 1a0c3f46b..d6dc93d80 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -67,6 +67,18 @@ class TestCase(_TestCase): obj_perm.users.add(self.user) 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 ._. + """ + for name in names: + object_type, action = resolve_permission_type(name) + obj_perms = ObjectPermission.objects.filter( + actions__contains=[action], object_types=object_type, users=self.user + ) + for obj_perm in obj_perms: + obj_perm.users.remove(self.user) + # # Custom assertions # diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 1f5caae66..86624e255 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,9 +1,10 @@ from django.contrib.contenttypes.models import ContentType -from django.test import override_settings +from django.test import override_settings, tag from django.urls import reverse from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site +from extras.models import ConfigTemplate from ipam.models import VLAN, VRF from utilities.testing import ViewTestCases, create_tags, create_test_device, create_test_virtualmachine from virtualization.choices import * @@ -326,6 +327,29 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('virtualization:virtualmachine_interfaces', kwargs={'pk': virtualmachine.pk}) self.assertHttpStatus(self.client.get(url), 200) + @tag('regression') # #20929 + def test_virtualmachine_renderconfig_permission(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): model = VMInterface From 4cf0a1045c266a41a009b54861bd92cae95fb048 Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 15 Dec 2025 14:57:52 -0600 Subject: [PATCH 2/3] Address PR feedback --- netbox/dcim/tests/test_views.py | 3 +-- netbox/utilities/testing/base.py | 6 ++---- netbox/virtualization/tests/test_views.py | 5 ++--- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 373ad4b20..5a17b01c9 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2340,8 +2340,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('dcim:device_inventory', kwargs={'pk': device.pk}) self.assertHttpStatus(self.client.get(url), 200) - @tag('regression') # #20929 - def test_device_renderconfig_permission(self): + def test_device_renderconfig(self): configtemplate = ConfigTemplate.objects.create( name='Test Config Template', template_code='Config for device {{ device.name }}' diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index d6dc93d80..cf771b906 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -73,11 +73,9 @@ class TestCase(_TestCase): """ for name in names: object_type, action = resolve_permission_type(name) - obj_perms = ObjectPermission.objects.filter( + ObjectPermission.objects.filter( actions__contains=[action], object_types=object_type, users=self.user - ) - for obj_perm in obj_perms: - obj_perm.users.remove(self.user) + ).delete() # # Custom assertions diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 86624e255..bf029929f 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -1,5 +1,5 @@ from django.contrib.contenttypes.models import ContentType -from django.test import override_settings, tag +from django.test import override_settings from django.urls import reverse from dcim.choices import InterfaceModeChoices @@ -327,8 +327,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('virtualization:virtualmachine_interfaces', kwargs={'pk': virtualmachine.pk}) self.assertHttpStatus(self.client.get(url), 200) - @tag('regression') # #20929 - def test_virtualmachine_renderconfig_permission(self): + def test_virtualmachine_renderconfig(self): configtemplate = ConfigTemplate.objects.create( name='Test Config Template', template_code='Config for VM {{ virtualmachine.name }}' From 68ec84ff70b33ba5e573a9d3f38e14d602c11a3b Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Mon, 15 Dec 2025 15:08:19 -0600 Subject: [PATCH 3/3] Address PR feedback --- netbox/dcim/views.py | 1 + netbox/extras/views.py | 9 --------- netbox/virtualization/views.py | 1 + 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c6d9a889f..ba9365c83 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2682,6 +2682,7 @@ class DeviceConfigContextView(ObjectConfigContextView): class DeviceRenderConfigView(ObjectRenderConfigView): queryset = Device.objects.all() base_template = 'dcim/device/base.html' + additional_permissions = ['dcim.render_config_device'] tab = ViewTab( label=_('Render Config'), weight=2100, diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 34b2f0a47..3c1fc395d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -27,7 +27,6 @@ from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import htmx_partial, htmx_maybe_redirect_current_page from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.permissions import get_permission_for_model from utilities.query import count_related from utilities.querydict import normalize_querydict from utilities.request import copy_safe_request @@ -1083,14 +1082,6 @@ class ObjectRenderConfigView(generic.ObjectView): base_template = None template_name = 'extras/object_render_config.html' - def has_permission(self): - if super().has_permission(): # enforce base required permission - perm = get_permission_for_model(self.queryset.model, 'render_config') - if self.request.user.has_perm(perm): - self.queryset = self.queryset.restrict(self.request.user, 'render_config') - return True - return False - def get(self, request, **kwargs): instance = self.get_object(**kwargs) context = self.get_extra_context(request, instance) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index a7aa80f10..b7aca4d73 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -405,6 +405,7 @@ class VirtualMachineConfigContextView(ObjectConfigContextView): class VirtualMachineRenderConfigView(ObjectRenderConfigView): queryset = VirtualMachine.objects.all() base_template = 'virtualization/virtualmachine/base.html' + additional_permissions = ['virtualization.render_config_virtualmachine'] tab = ViewTab( label=_('Render Config'), weight=2100,