Browse Source

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>
Jason Novinger 3 months ago
parent
commit
5bbab7eb47

+ 7 - 0
docs/features/configuration-rendering.md

@@ -90,3 +90,10 @@ http://netbox:8000/api/extras/config-templates/123/render/ \
   "bar": 123
   "bar": 123
 }'
 }'
 ```
 ```
+
+!!! note "Permissions"
+    Rendering configuration templates via the REST API requires appropriate permissions for the relevant object type:
+
+    * To render a device's configuration via `/api/dcim/devices/{id}/render-config/`, assign a permission for "DCIM > Device" with the `render_config` action.
+    * 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.
+    * To render a config template directly via `/api/extras/config-templates/{id}/render/`, assign a permission for "Extras > Config Template" with the `render` action.

+ 50 - 4
netbox/dcim/tests/test_api.py

@@ -13,7 +13,8 @@ from ipam.choices import VLANQinQRoleChoices
 from ipam.models import ASN, RIR, VLAN, VRF
 from ipam.models import ASN, RIR, VLAN, VRF
 from netbox.api.serializers import GenericObjectSerializer
 from netbox.api.serializers import GenericObjectSerializer
 from tenancy.models import Tenant
 from tenancy.models import Tenant
-from users.models import User
+from users.constants import TOKEN_PREFIX
+from users.models import Token, User
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
 from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
 from virtualization.models import Cluster, ClusterType
 from virtualization.models import Cluster, ClusterType
 from wireless.choices import WirelessChannelChoices
 from wireless.choices import WirelessChannelChoices
@@ -1306,7 +1307,6 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
     }
     }
     user_permissions = (
     user_permissions = (
         'dcim.view_site', 'dcim.view_rack', 'dcim.view_location', 'dcim.view_devicerole', 'dcim.view_devicetype',
         'dcim.view_site', 'dcim.view_rack', 'dcim.view_location', 'dcim.view_devicerole', 'dcim.view_devicetype',
-        'extras.view_configtemplate',
     )
     )
 
 
     @classmethod
     @classmethod
@@ -1486,12 +1486,58 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
         device.config_template = configtemplate
         device.config_template = configtemplate
         device.save()
         device.save()
 
 
-        self.add_permissions('dcim.add_device')
-        url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/'
+        self.add_permissions('dcim.render_config_device', 'dcim.view_device')
+        url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
         response = self.client.post(url, {}, format='json', **self.header)
         response = self.client.post(url, {}, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data['content'], f'Config for device {device.name}')
         self.assertEqual(response.data['content'], f'Config for device {device.name}')
 
 
+    def test_render_config_without_permission(self):
+        configtemplate = ConfigTemplate.objects.create(
+            name='Config Template 1',
+            template_code='Config for device {{ device.name }}'
+        )
+
+        device = Device.objects.first()
+        device.config_template = configtemplate
+        device.save()
+
+        # No permissions added - user has no render_config permission
+        url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
+        response = self.client.post(url, {}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+
+    def test_render_config_token_write_enabled(self):
+        configtemplate = ConfigTemplate.objects.create(
+            name='Config Template 1',
+            template_code='Config for device {{ device.name }}'
+        )
+
+        device = Device.objects.first()
+        device.config_template = configtemplate
+        device.save()
+
+        self.add_permissions('dcim.render_config_device', 'dcim.view_device')
+        url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
+
+        # Request without token auth should fail with PermissionDenied
+        response = self.client.post(url, {}, format='json')
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+        # Create token with write_enabled=False
+        token = Token.objects.create(version=2, user=self.user, write_enabled=False)
+        token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
+
+        # Request with write-disabled token should fail
+        response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+        # Enable write and retry
+        token.write_enabled = True
+        token.save()
+        response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
 
 
 class ModuleTest(APIViewTestCases.APIViewTestCase):
 class ModuleTest(APIViewTestCases.APIViewTestCase):
     model = Module
     model = Module

+ 13 - 0
netbox/extras/api/mixins.py

@@ -4,6 +4,7 @@ from rest_framework.renderers import JSONRenderer
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.status import HTTP_400_BAD_REQUEST
 from rest_framework.status import HTTP_400_BAD_REQUEST
 
 
+from netbox.api.authentication import TokenWritePermission
 from netbox.api.renderers import TextRenderer
 from netbox.api.renderers import TextRenderer
 from .serializers import ConfigTemplateSerializer
 from .serializers import ConfigTemplateSerializer
 
 
@@ -64,12 +65,24 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
     """
     """
     Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned.
     Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned.
     """
     """
+
+    def get_permissions(self):
+        # For render_config action, check only token write ability (not model permissions)
+        if self.action == 'render_config':
+            return [TokenWritePermission()]
+        return super().get_permissions()
+
     @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
     @action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
     def render_config(self, request, pk):
     def render_config(self, request, pk):
         """
         """
         Resolve and render the preferred ConfigTemplate for this Device.
         Resolve and render the preferred ConfigTemplate for this Device.
         """
         """
+        # Override restrict() on the default queryset to enforce the render_config & view actions
+        self.queryset = self.queryset.model.objects.restrict(request.user, 'render_config').restrict(
+            request.user, 'view'
+        )
         instance = self.get_object()
         instance = self.get_object()
+
         object_type = instance._meta.model_name
         object_type = instance._meta.model_name
         configtemplate = instance.get_config_template()
         configtemplate = instance.get_config_template()
         if not configtemplate:
         if not configtemplate:

+ 10 - 1
netbox/extras/api/views.py

@@ -16,7 +16,7 @@ from rq import Worker
 from extras import filtersets
 from extras import filtersets
 from extras.jobs import ScriptJob
 from extras.jobs import ScriptJob
 from extras.models import *
 from extras.models import *
-from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
+from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired, TokenWritePermission
 from netbox.api.features import SyncedDataMixin
 from netbox.api.features import SyncedDataMixin
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.renderers import TextRenderer
 from netbox.api.renderers import TextRenderer
@@ -238,13 +238,22 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
     serializer_class = serializers.ConfigTemplateSerializer
     serializer_class = serializers.ConfigTemplateSerializer
     filterset_class = filtersets.ConfigTemplateFilterSet
     filterset_class = filtersets.ConfigTemplateFilterSet
 
 
+    def get_permissions(self):
+        # For render action, check only token write ability (not model permissions)
+        if self.action == 'render':
+            return [TokenWritePermission()]
+        return super().get_permissions()
+
     @action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer])
     @action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer])
     def render(self, request, pk):
     def render(self, request, pk):
         """
         """
         Render a ConfigTemplate using the context data provided (if any). If the client requests "text/plain" data,
         Render a ConfigTemplate using the context data provided (if any). If the client requests "text/plain" data,
         return the raw rendered content, rather than serialized JSON.
         return the raw rendered content, rather than serialized JSON.
         """
         """
+        # Override restrict() on the default queryset to enforce the render & view actions
+        self.queryset = self.queryset.model.objects.restrict(request.user, 'render').restrict(request.user, 'view')
         configtemplate = self.get_object()
         configtemplate = self.get_object()
+
         context = request.data
         context = request.data
 
 
         return self.render_configtemplate(request, configtemplate, context)
         return self.render_configtemplate(request, configtemplate, context)

+ 44 - 1
netbox/extras/tests/test_api.py

@@ -3,6 +3,7 @@ import datetime
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.timezone import make_aware, now
 from django.utils.timezone import make_aware, now
+from rest_framework import status
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
 from core.events import *
 from core.events import *
@@ -11,7 +12,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
 from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
-from users.models import Group, User
+from users.constants import TOKEN_PREFIX
+from users.models import Group, Token, User
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
 
 
 
 
@@ -854,6 +856,47 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
         )
         )
         ConfigTemplate.objects.bulk_create(config_templates)
         ConfigTemplate.objects.bulk_create(config_templates)
 
 
+    def test_render(self):
+        configtemplate = ConfigTemplate.objects.first()
+
+        self.add_permissions('extras.render_configtemplate', 'extras.view_configtemplate')
+        url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
+        response = self.client.post(url, {'foo': 'bar'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(response.data['content'], 'Foo: bar')
+
+    def test_render_without_permission(self):
+        configtemplate = ConfigTemplate.objects.first()
+
+        # No permissions added - user has no render permission
+        url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
+        response = self.client.post(url, {'foo': 'bar'}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+
+    def test_render_token_write_enabled(self):
+        configtemplate = ConfigTemplate.objects.first()
+
+        self.add_permissions('extras.render_configtemplate', 'extras.view_configtemplate')
+        url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
+
+        # Request without token auth should fail with PermissionDenied
+        response = self.client.post(url, {'foo': 'bar'}, format='json')
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+        # Create token with write_enabled=False
+        token = Token.objects.create(version=2, user=self.user, write_enabled=False)
+        token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
+
+        # Request with write-disabled token should fail
+        response = self.client.post(url, {'foo': 'bar'}, format='json', HTTP_AUTHORIZATION=token_header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+        # Enable write and retry
+        token.write_enabled = True
+        token.save()
+        response = self.client.post(url, {'foo': 'bar'}, format='json', HTTP_AUTHORIZATION=token_header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
 
 
 class ScriptTest(APITestCase):
 class ScriptTest(APITestCase):
 
 

+ 14 - 0
netbox/netbox/api/authentication.py

@@ -164,6 +164,20 @@ class TokenPermissions(DjangoObjectPermissions):
         return super().has_object_permission(request, view, obj)
         return super().has_object_permission(request, view, obj)
 
 
 
 
+class TokenWritePermission(BasePermission):
+    """
+    Verify the token has write_enabled for unsafe methods, without requiring specific model permissions.
+    Used for custom actions that accept user data but don't map to standard CRUD operations.
+    """
+
+    def has_permission(self, request, view):
+        if not isinstance(request.auth, Token):
+            raise exceptions.PermissionDenied(
+                "TokenWritePermission requires token authentication."
+            )
+        return bool(request.method in SAFE_METHODS or request.auth.write_enabled)
+
+
 class IsAuthenticatedOrLoginNotRequired(BasePermission):
 class IsAuthenticatedOrLoginNotRequired(BasePermission):
     """
     """
     Returns True if the user is authenticated or LOGIN_REQUIRED is False.
     Returns True if the user is authenticated or LOGIN_REQUIRED is False.

+ 52 - 2
netbox/virtualization/tests/test_api.py

@@ -12,6 +12,8 @@ from extras.choices import CustomFieldTypeChoices
 from extras.models import ConfigTemplate, CustomField
 from extras.models import ConfigTemplate, CustomField
 from ipam.choices import VLANQinQRoleChoices
 from ipam.choices import VLANQinQRoleChoices
 from ipam.models import Prefix, VLAN, VRF
 from ipam.models import Prefix, VLAN, VRF
+from users.constants import TOKEN_PREFIX
+from users.models import Token
 from utilities.testing import (
 from utilities.testing import (
     APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine, disable_logging,
     APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine, disable_logging,
 )
 )
@@ -281,12 +283,60 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
         vm.config_template = configtemplate
         vm.config_template = configtemplate
         vm.save()
         vm.save()
 
 
-        self.add_permissions('virtualization.add_virtualmachine')
-        url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/'
+        self.add_permissions(
+            'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine'
+        )
+        url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
         response = self.client.post(url, {}, format='json', **self.header)
         response = self.client.post(url, {}, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
         self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
 
 
+    def test_render_config_without_permission(self):
+        configtemplate = ConfigTemplate.objects.create(
+            name='Config Template 1',
+            template_code='Config for virtual machine {{ virtualmachine.name }}'
+        )
+
+        vm = VirtualMachine.objects.first()
+        vm.config_template = configtemplate
+        vm.save()
+
+        # No permissions added - user has no render_config permission
+        url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
+        response = self.client.post(url, {}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
+
+    def test_render_config_token_write_enabled(self):
+        configtemplate = ConfigTemplate.objects.create(
+            name='Config Template 1',
+            template_code='Config for virtual machine {{ virtualmachine.name }}'
+        )
+
+        vm = VirtualMachine.objects.first()
+        vm.config_template = configtemplate
+        vm.save()
+
+        self.add_permissions('virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine')
+        url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
+
+        # Request without token auth should fail with PermissionDenied
+        response = self.client.post(url, {}, format='json')
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+        # Create token with write_enabled=False
+        token = Token.objects.create(version=2, user=self.user, write_enabled=False)
+        token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
+
+        # Request with write-disabled token should fail
+        response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+        # Enable write and retry
+        token.write_enabled = True
+        token.save()
+        response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
 
 
 class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
 class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
     model = VMInterface
     model = VMInterface