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.
This commit is contained in:
Jason Novinger
2025-12-12 13:11:53 -06:00
parent 3140060f21
commit e81e192d2e
4 changed files with 70 additions and 1 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,29 @@ 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)
@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( class ModuleTestCase(
# Module does not support bulk renaming (no name field) or # Module does not support bulk renaming (no name field) or

View File

@@ -27,6 +27,7 @@ from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import htmx_partial, htmx_maybe_redirect_current_page from utilities.htmx import htmx_partial, htmx_maybe_redirect_current_page
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model
from utilities.query import count_related from utilities.query import count_related
from utilities.querydict import normalize_querydict from utilities.querydict import normalize_querydict
from utilities.request import copy_safe_request from utilities.request import copy_safe_request
@@ -1082,6 +1083,14 @@ class ObjectRenderConfigView(generic.ObjectView):
base_template = None base_template = None
template_name = 'extras/object_render_config.html' 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): def get(self, request, **kwargs):
instance = self.get_object(**kwargs) instance = self.get_object(**kwargs)
context = self.get_extra_context(request, instance) context = self.get_extra_context(request, instance)

View File

@@ -67,6 +67,18 @@ 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)
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 # Custom assertions
# #

View File

@@ -1,9 +1,10 @@
from django.contrib.contenttypes.models import ContentType 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 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,29 @@ 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)
@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): class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = VMInterface model = VMInterface