Skip to content

Commit 5bbab7e

Browse files
Closes #16681: Introduce render_config permission for configuration rendering (#20555)
* Closes #16681: Introduce render_config permission for configuration rendering Add a new custom permission action `render_config` for rendering device and virtual machine configurations via the REST API. This allows users to render configurations without requiring the `add` permission. Changes: - Add permission check to RenderConfigMixin.render_config() for devices and VMs - Update API tests to use render_config permission instead of add - Add tests verifying permission enforcement (403 without render_config) - Document new permission requirement in configuration-rendering.md Note: Currently requires both render_config AND add permissions due to the automatic POST='add' filter in BaseViewSet.initial(). Removing the add requirement will be addressed in a follow-up commit. * Correct permission denied message and enable translation * Remove add permission requirement for render_config endpoint Remove the add permission requirement from the render-config API endpoint while maintaining token write_enabled enforcement as specified in #16681. Changes: - Add TokenWritePermission class to check token write ability without requiring specific model permissions - Override get_permissions() in RenderConfigMixin to use TokenWritePermission instead of TokenPermissions for render_config action - Replace queryset restriction: use render_config instead of add - Remove add permissions from tests - render_config permission now sufficient - Update tests to expect 404 when permission denied (NetBox standard pattern) Per #16681: 'requirement for write permission makes sense for API calls (because we're accepting and processing arbitrary user data), the specific permission for creating devices does not' * Add render_config permission to ConfigTemplate render endpoint Extend render_config permission requirement to the ConfigTemplate render endpoint per issue comments. Changes: - Add TokenWritePermission check via get_permissions() override in ConfigTemplateViewSet - Restrict queryset to render_config permission in render() method - Add explicit render_config permission check - Add tests for ConfigTemplate.render() with and without permission - Update documentation to include ConfigTemplate endpoint * Address PR feedback on render_config permissions Remove redundant permission checks, add view permission enforcement via chained restrict() calls, and rename ConfigTemplate permission action from render_config to render for consistency. * Address second round of PR feedback on render_config permissions - Remove ConfigTemplate view permission check from render_config endpoint - Add sanity check to TokenWritePermission for non-token auth - Use named URL patterns instead of string concatenation in tests - Remove extras.view_configtemplate from test permissions - Add token write_enabled enforcement tests for all render endpoints * Misc cleanup --------- Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
1 parent 87505e0 commit 5bbab7e

File tree

7 files changed

+190
-8
lines changed

7 files changed

+190
-8
lines changed

docs/features/configuration-rendering.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,10 @@ http://netbox:8000/api/extras/config-templates/123/render/ \
9090
"bar": 123
9191
}'
9292
```
93+
94+
!!! note "Permissions"
95+
Rendering configuration templates via the REST API requires appropriate permissions for the relevant object type:
96+
97+
* To render a device's configuration via `/api/dcim/devices/{id}/render-config/`, assign a permission for "DCIM > Device" with the `render_config` action.
98+
* To render a virtual machine's configuration via `/api/virtualization/virtual-machines/{id}/render-config/`, assign a permission for "Virtualization > Virtual Machine" with the `render_config` action.
99+
* To render a config template directly via `/api/extras/config-templates/{id}/render/`, assign a permission for "Extras > Config Template" with the `render` action.

netbox/dcim/tests/test_api.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
from ipam.models import ASN, RIR, VLAN, VRF
1414
from netbox.api.serializers import GenericObjectSerializer
1515
from tenancy.models import Tenant
16-
from users.models import User
16+
from users.constants import TOKEN_PREFIX
17+
from users.models import Token, User
1718
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
1819
from virtualization.models import Cluster, ClusterType
1920
from wireless.choices import WirelessChannelChoices
@@ -1306,7 +1307,6 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
13061307
}
13071308
user_permissions = (
13081309
'dcim.view_site', 'dcim.view_rack', 'dcim.view_location', 'dcim.view_devicerole', 'dcim.view_devicetype',
1309-
'extras.view_configtemplate',
13101310
)
13111311

13121312
@classmethod
@@ -1486,12 +1486,58 @@ def test_render_config(self):
14861486
device.config_template = configtemplate
14871487
device.save()
14881488

1489-
self.add_permissions('dcim.add_device')
1490-
url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/'
1489+
self.add_permissions('dcim.render_config_device', 'dcim.view_device')
1490+
url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
14911491
response = self.client.post(url, {}, format='json', **self.header)
14921492
self.assertHttpStatus(response, status.HTTP_200_OK)
14931493
self.assertEqual(response.data['content'], f'Config for device {device.name}')
14941494

1495+
def test_render_config_without_permission(self):
1496+
configtemplate = ConfigTemplate.objects.create(
1497+
name='Config Template 1',
1498+
template_code='Config for device {{ device.name }}'
1499+
)
1500+
1501+
device = Device.objects.first()
1502+
device.config_template = configtemplate
1503+
device.save()
1504+
1505+
# No permissions added - user has no render_config permission
1506+
url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
1507+
response = self.client.post(url, {}, format='json', **self.header)
1508+
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
1509+
1510+
def test_render_config_token_write_enabled(self):
1511+
configtemplate = ConfigTemplate.objects.create(
1512+
name='Config Template 1',
1513+
template_code='Config for device {{ device.name }}'
1514+
)
1515+
1516+
device = Device.objects.first()
1517+
device.config_template = configtemplate
1518+
device.save()
1519+
1520+
self.add_permissions('dcim.render_config_device', 'dcim.view_device')
1521+
url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
1522+
1523+
# Request without token auth should fail with PermissionDenied
1524+
response = self.client.post(url, {}, format='json')
1525+
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
1526+
1527+
# Create token with write_enabled=False
1528+
token = Token.objects.create(version=2, user=self.user, write_enabled=False)
1529+
token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
1530+
1531+
# Request with write-disabled token should fail
1532+
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
1533+
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
1534+
1535+
# Enable write and retry
1536+
token.write_enabled = True
1537+
token.save()
1538+
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
1539+
self.assertHttpStatus(response, status.HTTP_200_OK)
1540+
14951541

14961542
class ModuleTest(APIViewTestCases.APIViewTestCase):
14971543
model = Module

netbox/extras/api/mixins.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from rest_framework.response import Response
55
from rest_framework.status import HTTP_400_BAD_REQUEST
66

7+
from netbox.api.authentication import TokenWritePermission
78
from netbox.api.renderers import TextRenderer
89
from .serializers import ConfigTemplateSerializer
910

@@ -64,12 +65,24 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
6465
"""
6566
Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned.
6667
"""
68+
69+
def get_permissions(self):
70+
# For render_config action, check only token write ability (not model permissions)
71+
if self.action == 'render_config':
72+
return [TokenWritePermission()]
73+
return super().get_permissions()
74+
6775
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
6876
def render_config(self, request, pk):
6977
"""
7078
Resolve and render the preferred ConfigTemplate for this Device.
7179
"""
80+
# Override restrict() on the default queryset to enforce the render_config & view actions
81+
self.queryset = self.queryset.model.objects.restrict(request.user, 'render_config').restrict(
82+
request.user, 'view'
83+
)
7284
instance = self.get_object()
85+
7386
object_type = instance._meta.model_name
7487
configtemplate = instance.get_config_template()
7588
if not configtemplate:

netbox/extras/api/views.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from extras import filtersets
1717
from extras.jobs import ScriptJob
1818
from extras.models import *
19-
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
19+
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired, TokenWritePermission
2020
from netbox.api.features import SyncedDataMixin
2121
from netbox.api.metadata import ContentTypeMetadata
2222
from netbox.api.renderers import TextRenderer
@@ -238,13 +238,22 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
238238
serializer_class = serializers.ConfigTemplateSerializer
239239
filterset_class = filtersets.ConfigTemplateFilterSet
240240

241+
def get_permissions(self):
242+
# For render action, check only token write ability (not model permissions)
243+
if self.action == 'render':
244+
return [TokenWritePermission()]
245+
return super().get_permissions()
246+
241247
@action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer])
242248
def render(self, request, pk):
243249
"""
244250
Render a ConfigTemplate using the context data provided (if any). If the client requests "text/plain" data,
245251
return the raw rendered content, rather than serialized JSON.
246252
"""
253+
# Override restrict() on the default queryset to enforce the render & view actions
254+
self.queryset = self.queryset.model.objects.restrict(request.user, 'render').restrict(request.user, 'view')
247255
configtemplate = self.get_object()
256+
248257
context = request.data
249258

250259
return self.render_configtemplate(request, configtemplate, context)

netbox/extras/tests/test_api.py

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.contrib.contenttypes.models import ContentType
44
from django.urls import reverse
55
from django.utils.timezone import make_aware, now
6+
from rest_framework import status
67

78
from core.choices import ManagedFileRootPathChoices
89
from core.events import *
@@ -11,7 +12,8 @@
1112
from extras.choices import *
1213
from extras.models import *
1314
from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
14-
from users.models import Group, User
15+
from users.constants import TOKEN_PREFIX
16+
from users.models import Group, Token, User
1517
from utilities.testing import APITestCase, APIViewTestCases
1618

1719

@@ -854,6 +856,47 @@ def setUpTestData(cls):
854856
)
855857
ConfigTemplate.objects.bulk_create(config_templates)
856858

859+
def test_render(self):
860+
configtemplate = ConfigTemplate.objects.first()
861+
862+
self.add_permissions('extras.render_configtemplate', 'extras.view_configtemplate')
863+
url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
864+
response = self.client.post(url, {'foo': 'bar'}, format='json', **self.header)
865+
self.assertHttpStatus(response, status.HTTP_200_OK)
866+
self.assertEqual(response.data['content'], 'Foo: bar')
867+
868+
def test_render_without_permission(self):
869+
configtemplate = ConfigTemplate.objects.first()
870+
871+
# No permissions added - user has no render permission
872+
url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
873+
response = self.client.post(url, {'foo': 'bar'}, format='json', **self.header)
874+
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
875+
876+
def test_render_token_write_enabled(self):
877+
configtemplate = ConfigTemplate.objects.first()
878+
879+
self.add_permissions('extras.render_configtemplate', 'extras.view_configtemplate')
880+
url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
881+
882+
# Request without token auth should fail with PermissionDenied
883+
response = self.client.post(url, {'foo': 'bar'}, format='json')
884+
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
885+
886+
# Create token with write_enabled=False
887+
token = Token.objects.create(version=2, user=self.user, write_enabled=False)
888+
token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
889+
890+
# Request with write-disabled token should fail
891+
response = self.client.post(url, {'foo': 'bar'}, format='json', HTTP_AUTHORIZATION=token_header)
892+
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
893+
894+
# Enable write and retry
895+
token.write_enabled = True
896+
token.save()
897+
response = self.client.post(url, {'foo': 'bar'}, format='json', HTTP_AUTHORIZATION=token_header)
898+
self.assertHttpStatus(response, status.HTTP_200_OK)
899+
857900

858901
class ScriptTest(APITestCase):
859902

netbox/netbox/api/authentication.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ def has_object_permission(self, request, view, obj):
164164
return super().has_object_permission(request, view, obj)
165165

166166

167+
class TokenWritePermission(BasePermission):
168+
"""
169+
Verify the token has write_enabled for unsafe methods, without requiring specific model permissions.
170+
Used for custom actions that accept user data but don't map to standard CRUD operations.
171+
"""
172+
173+
def has_permission(self, request, view):
174+
if not isinstance(request.auth, Token):
175+
raise exceptions.PermissionDenied(
176+
"TokenWritePermission requires token authentication."
177+
)
178+
return bool(request.method in SAFE_METHODS or request.auth.write_enabled)
179+
180+
167181
class IsAuthenticatedOrLoginNotRequired(BasePermission):
168182
"""
169183
Returns True if the user is authenticated or LOGIN_REQUIRED is False.

netbox/virtualization/tests/test_api.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from extras.models import ConfigTemplate, CustomField
1313
from ipam.choices import VLANQinQRoleChoices
1414
from ipam.models import Prefix, VLAN, VRF
15+
from users.constants import TOKEN_PREFIX
16+
from users.models import Token
1517
from utilities.testing import (
1618
APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine, disable_logging,
1719
)
@@ -281,12 +283,60 @@ def test_render_config(self):
281283
vm.config_template = configtemplate
282284
vm.save()
283285

284-
self.add_permissions('virtualization.add_virtualmachine')
285-
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/'
286+
self.add_permissions(
287+
'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine'
288+
)
289+
url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
286290
response = self.client.post(url, {}, format='json', **self.header)
287291
self.assertHttpStatus(response, status.HTTP_200_OK)
288292
self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
289293

294+
def test_render_config_without_permission(self):
295+
configtemplate = ConfigTemplate.objects.create(
296+
name='Config Template 1',
297+
template_code='Config for virtual machine {{ virtualmachine.name }}'
298+
)
299+
300+
vm = VirtualMachine.objects.first()
301+
vm.config_template = configtemplate
302+
vm.save()
303+
304+
# No permissions added - user has no render_config permission
305+
url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
306+
response = self.client.post(url, {}, format='json', **self.header)
307+
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
308+
309+
def test_render_config_token_write_enabled(self):
310+
configtemplate = ConfigTemplate.objects.create(
311+
name='Config Template 1',
312+
template_code='Config for virtual machine {{ virtualmachine.name }}'
313+
)
314+
315+
vm = VirtualMachine.objects.first()
316+
vm.config_template = configtemplate
317+
vm.save()
318+
319+
self.add_permissions('virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine')
320+
url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
321+
322+
# Request without token auth should fail with PermissionDenied
323+
response = self.client.post(url, {}, format='json')
324+
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
325+
326+
# Create token with write_enabled=False
327+
token = Token.objects.create(version=2, user=self.user, write_enabled=False)
328+
token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
329+
330+
# Request with write-disabled token should fail
331+
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
332+
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
333+
334+
# Enable write and retry
335+
token.write_enabled = True
336+
token.save()
337+
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
338+
self.assertHttpStatus(response, status.HTTP_200_OK)
339+
290340

291341
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
292342
model = VMInterface

0 commit comments

Comments
 (0)