fix(ui): Suppress unauthorized embedded object tables

Add a `should_render()` hook to the `Panel` base class and override it
in `ObjectsTablePanel` to check the requesting user's view permission
for the panel's model. This prevents object detail pages from issuing
HTMX requests for related tables (e.g. locations, devices, image
attachments) that return 403 and disrupt the page.

Fixes #21893

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
This commit is contained in:
Martin Hauser 2026-04-15 23:37:38 +02:00 committed by Jeremy Stretch
parent a6cc0b671e
commit 850d4dd1ad
3 changed files with 103 additions and 3 deletions

View File

@ -194,6 +194,28 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description',
}
def test_get_object_with_only_site_view_permission_hides_unauthorized_embedded_panels(self):
site = self._get_queryset().first()
obj_perm = ObjectPermission(
name='Test permission',
actions=['view'],
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
response = self.client.get(site.get_absolute_url())
self.assertHttpStatus(response, 200)
for panel, url in (
('locations', reverse('dcim:location_list')),
('devices', reverse('dcim:device_list')),
('image attachments', reverse('extras:imageattachment_list')),
):
with self.subTest(panel=panel):
self.assertNotContains(response, url)
class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Location

View File

@ -1,4 +1,4 @@
from django.test import TestCase
from django.test import RequestFactory, TestCase
from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
from circuits.models import (
@ -8,9 +8,12 @@ from circuits.models import (
VirtualCircuitTermination,
VirtualCircuitType,
)
from core.models import ObjectType
from dcim.choices import InterfaceTypeChoices
from dcim.models import Interface
from dcim.models import Interface, Site
from netbox.ui import attrs
from netbox.ui.panels import ObjectsTablePanel
from users.models import ObjectPermission, User
from utilities.testing import create_test_device
from vpn.choices import (
AuthenticationAlgorithmChoices,
@ -213,3 +216,55 @@ class RelatedObjectListAttrTest(TestCase):
self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
self.assertNotIn('IKE Proposal 3', rendered)
self.assertIn('', rendered)
class ObjectsTablePanelTest(TestCase):
"""
Verify that ObjectsTablePanel.should_render() hides the panel when
the requesting user lacks view permission for the panel's model.
"""
@classmethod
def setUpTestData(cls):
cls.user = User.objects.create_user(username='test_user', password='test_password')
# Grant view permission only for Site
obj_perm = ObjectPermission.objects.create(
name='View sites only',
actions=['view'],
)
obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
obj_perm.users.add(cls.user)
def setUp(self):
self.factory = RequestFactory()
self.panel = ObjectsTablePanel(model='dcim.site')
self.panel_no_perm = ObjectsTablePanel(model='dcim.location')
def _make_context(self, user=None):
if user is None:
return {}
request = self.factory.get('/')
request.user = user
return {'request': request}
def test_should_render_without_request(self):
"""
Panel should render when no request is present in context.
"""
context = self.panel.get_context({})
self.assertTrue(self.panel.should_render(context))
def test_should_render_with_permission(self):
"""
Panel should render when the user has view permission for the panel's model.
"""
context = self.panel.get_context(self._make_context(self.user))
self.assertTrue(self.panel.should_render(context))
def test_should_not_render_without_permission(self):
"""
Panel should be hidden when the user lacks view permission for the panel's model.
"""
context = self.panel_no_perm.get_context(self._make_context(self.user))
self.assertFalse(self.panel_no_perm.should_render(context))

View File

@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs
from netbox.ui.actions import CopyContent
from utilities.data import resolve_attr_path
from utilities.permissions import get_permission_for_model
from utilities.querydict import dict_to_querydict
from utilities.string import title
from utilities.templatetags.plugins import _get_registered_content
@ -74,6 +75,15 @@ class Panel:
'panel_class': self.__class__.__name__,
}
def should_render(self, context):
"""
Determines whether the panel should render on the page. (Default: True)
Parameters:
context (dict): The panel's prepared context (the return value of get_context())
"""
return True
def render(self, context):
"""
Render the panel as HTML.
@ -81,7 +91,10 @@ class Panel:
Parameters:
context (dict): The template context
"""
return render_to_string(self.template_name, self.get_context(context))
ctx = self.get_context(context)
if not self.should_render(ctx):
return ''
return render_to_string(self.template_name, ctx, request=ctx.get('request'))
#
@ -314,6 +327,16 @@ class ObjectsTablePanel(Panel):
'url_params': dict_to_querydict(url_params),
}
def should_render(self, context):
"""
Hide the panel if the user does not have view permission for the panel's model.
"""
request = context.get('request')
if request is None:
return True
return request.user.has_perm(get_permission_for_model(self.model, 'view'))
class TemplatePanel(Panel):
"""