diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 584c8b8ec..c72eae425 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -33,8 +33,9 @@ def custom_links(context, obj): """ Render all applicable links for the given object. """ + user = context['request'].user object_type = ObjectType.objects.get_for_model(obj) - custom_links = CustomLink.objects.filter(object_types=object_type, enabled=True) + custom_links = CustomLink.objects.restrict(user, 'view').filter(object_types=object_type, enabled=True) if not custom_links: return '' diff --git a/netbox/extras/tests/test_templatetags.py b/netbox/extras/tests/test_templatetags.py new file mode 100644 index 000000000..32ba19612 --- /dev/null +++ b/netbox/extras/tests/test_templatetags.py @@ -0,0 +1,75 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.context_processors import PermWrapper +from django.test import RequestFactory, TestCase + +from core.models import ObjectType +from dcim.models import Site +from extras.models import CustomLink +from extras.templatetags.custom_links import custom_links +from users.models import ObjectPermission + +User = get_user_model() + + +class CustomLinkTemplateTagTest(TestCase): + """ + The custom_links template tag must honor object-level permissions on CustomLink (see #22439). + """ + + @classmethod + def setUpTestData(cls): + cls.site = Site.objects.create(name='Site 1', slug='site-1') + cls.custom_link = CustomLink.objects.create( + name='Custom Link 1', + enabled=True, + weight=100, + new_window=False, + link_text='Link 1', + link_url='http://example.com/' + ) + cls.custom_link.object_types.set([ObjectType.objects.get_for_model(Site)]) + + def render(self, user): + request = RequestFactory().get('/') + request.user = user + context = { + 'request': request, + 'user': user, + 'perms': PermWrapper(user), + } + return custom_links(context, self.site) + + def test_no_permission_hides_link(self): + # A user without the view_customlink permission must not see the link. + user = User.objects.create_user(username='user1') + self.assertNotIn('Link 1', self.render(user)) + + def test_constrained_permission_hides_link(self): + # A user granted view_customlink via a constraint that excludes the link must not see it (#22439). + user = User.objects.create_user(username='user2') + permission = ObjectPermission.objects.create( + name='Constrained custom links', + actions=['view'], + constraints={'name': 'Some Other Link'} # Does not match Custom Link 1 + ) + permission.object_types.set([ObjectType.objects.get_for_model(CustomLink)]) + permission.users.set([user]) + + # Re-fetch to clear any cached permissions + user = User.objects.get(pk=user.pk) + self.assertNotIn('Link 1', self.render(user)) + + def test_permitted_link_is_shown(self): + # A user granted view_customlink covering the link must see it. + user = User.objects.create_user(username='user3') + permission = ObjectPermission.objects.create( + name='Custom links', + actions=['view'], + constraints={'name': 'Custom Link 1'} # Matches Custom Link 1 + ) + permission.object_types.set([ObjectType.objects.get_for_model(CustomLink)]) + permission.users.set([user]) + + # Re-fetch to clear any cached permissions + user = User.objects.get(pk=user.pk) + self.assertIn('Link 1', self.render(user)) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 8dd82c239..15434b0f2 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -184,7 +184,7 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): class CustomLinkRenderingTestCase(TestCase): - user_permissions = ['dcim.view_site'] + user_permissions = ['dcim.view_site', 'extras.view_customlink'] def test_view_object_with_custom_link(self): customlink = CustomLink( @@ -203,6 +203,47 @@ class CustomLinkRenderingTestCase(TestCase): self.assertEqual(response.status_code, 200) self.assertIn(f'FOO {site.name} BAR', str(response.content)) + def test_list_view_custom_link_column(self): + # A custom link column must render in the list view when the user can view the CustomLink. + customlink = CustomLink( + name='Test', + link_text='FOO {{ object.name }} BAR', + link_url='http://example.com/?site={{ object.slug }}', + new_window=False + ) + customlink.save() + customlink.object_types.set([ObjectType.objects.get_for_model(Site)]) + + site = Site(name='Test Site', slug='test-site') + site.save() + + # Custom link columns are hidden by default; explicitly include the column + response = self.client.get(f"{reverse('dcim:site_list')}?include_columns=cl_Test") + self.assertEqual(response.status_code, 200) + self.assertIn(f'FOO {site.name} BAR', str(response.content)) + + def test_list_view_custom_link_column_hidden_without_permission(self): + # A custom link column must be excluded from the list view when the user cannot view the + # CustomLink, even if explicitly requested (#22439). + customlink = CustomLink( + name='Test', + link_text='FOO {{ object.name }} BAR', + link_url='http://example.com/?site={{ object.slug }}', + new_window=False + ) + customlink.save() + customlink.object_types.set([ObjectType.objects.get_for_model(Site)]) + + site = Site(name='Test Site', slug='test-site') + site.save() + + # Revoke permission to view custom links + self.remove_permissions('extras.view_customlink') + + response = self.client.get(f"{reverse('dcim:site_list')}?include_columns=cl_Test") + self.assertEqual(response.status_code, 200) + self.assertNotIn(f'FOO {site.name} BAR', str(response.content)) + class SavedFilterTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = SavedFilter diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 7c7275f21..ead9e0e78 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -287,6 +287,38 @@ class NetBoxTable(BaseTable): super().__init__(*args, extra_columns=extra_columns, **kwargs) + def configure(self, request): + # Remove custom link columns referencing CustomLinks the user cannot view (#22439). + # These columns are added for all enabled CustomLinks in __init__(), before the request + # (and thus the user) is known, so object-level permissions are enforced here instead. + self._restrict_customlink_columns(request.user) + + super().configure(request) + + def _restrict_customlink_columns(self, user): + """ + Exclude any custom link columns which reference a CustomLink the user does not have + permission to view. + """ + customlinks = { + name: column.column.customlink + for name, column in self.columns.iteritems() + if isinstance(column.column, columns.CustomLinkColumn) + } + if not customlinks: + return + + permitted = set( + CustomLink.objects.restrict(user, 'view').filter( + pk__in=[cl.pk for cl in customlinks.values()] + ).values_list('pk', flat=True) + ) + excluded = tuple( + name for name, customlink in customlinks.items() if customlink.pk not in permitted + ) + if excluded: + self.exclude = (*self.exclude, *excluded) + @cached_property def htmx_url(self): """