Fixes #22439: Enforce view permissions for Custom Links (#22469)

Filter custom links by the current user's view permissions before rendering
them on object detail views and table columns.
This commit is contained in:
Arthur Hanson 2026-06-19 00:38:47 -07:00 committed by GitHub
parent 83439ba00f
commit 626e1ef1f8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 151 additions and 2 deletions

View File

@ -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 ''

View File

@ -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))

View File

@ -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

View File

@ -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):
"""