From 5bbab7eb47410cbac4629b6525ddaa290711a38d Mon Sep 17 00:00:00 2001 From: Jason Novinger Date: Tue, 21 Oct 2025 08:26:06 -0500 Subject: [PATCH] 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 --- docs/features/configuration-rendering.md | 7 +++ netbox/dcim/tests/test_api.py | 54 ++++++++++++++++++++++-- netbox/extras/api/mixins.py | 13 ++++++ netbox/extras/api/views.py | 11 ++++- netbox/extras/tests/test_api.py | 45 +++++++++++++++++++- netbox/netbox/api/authentication.py | 14 ++++++ netbox/virtualization/tests/test_api.py | 54 +++++++++++++++++++++++- 7 files changed, 190 insertions(+), 8 deletions(-) diff --git a/docs/features/configuration-rendering.md b/docs/features/configuration-rendering.md index 44cacc684..7cf197f54 100644 --- a/docs/features/configuration-rendering.md +++ b/docs/features/configuration-rendering.md @@ -90,3 +90,10 @@ http://netbox:8000/api/extras/config-templates/123/render/ \ "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. diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index d0a385887..c70b546e3 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -13,7 +13,8 @@ from ipam.choices import VLANQinQRoleChoices from ipam.models import ASN, RIR, VLAN, VRF from netbox.api.serializers import GenericObjectSerializer 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 virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices @@ -1306,7 +1307,6 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): } user_permissions = ( 'dcim.view_site', 'dcim.view_rack', 'dcim.view_location', 'dcim.view_devicerole', 'dcim.view_devicetype', - 'extras.view_configtemplate', ) @classmethod @@ -1486,12 +1486,58 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): device.config_template = configtemplate 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) self.assertHttpStatus(response, status.HTTP_200_OK) 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): model = Module diff --git a/netbox/extras/api/mixins.py b/netbox/extras/api/mixins.py index aafdf32d4..ac4617bb8 100644 --- a/netbox/extras/api/mixins.py +++ b/netbox/extras/api/mixins.py @@ -4,6 +4,7 @@ from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.status import HTTP_400_BAD_REQUEST +from netbox.api.authentication import TokenWritePermission from netbox.api.renderers import TextRenderer 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. """ + + 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]) def render_config(self, request, pk): """ 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() + object_type = instance._meta.model_name configtemplate = instance.get_config_template() if not configtemplate: diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index f333d5dbf..e1caf6248 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -16,7 +16,7 @@ from rq import Worker from extras import filtersets from extras.jobs import ScriptJob 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.metadata import ContentTypeMetadata from netbox.api.renderers import TextRenderer @@ -238,13 +238,22 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo serializer_class = serializers.ConfigTemplateSerializer 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]) def render(self, request, pk): """ 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. """ + # 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() + context = request.data return self.render_configtemplate(request, configtemplate, context) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index d635916e4..92cdd88bd 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -3,6 +3,7 @@ import datetime from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.timezone import make_aware, now +from rest_framework import status from core.choices import ManagedFileRootPathChoices from core.events import * @@ -11,7 +12,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca from extras.choices import * from extras.models import * 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 @@ -854,6 +856,47 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase): ) 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): diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index daa512ee0..7dbc38598 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -164,6 +164,20 @@ class TokenPermissions(DjangoObjectPermissions): 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): """ Returns True if the user is authenticated or LOGIN_REQUIRED is False. diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index e07f4dc06..56f9132ab 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -12,6 +12,8 @@ from extras.choices import CustomFieldTypeChoices from extras.models import ConfigTemplate, CustomField from ipam.choices import VLANQinQRoleChoices from ipam.models import Prefix, VLAN, VRF +from users.constants import TOKEN_PREFIX +from users.models import Token from utilities.testing import ( APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine, disable_logging, ) @@ -281,12 +283,60 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): vm.config_template = configtemplate 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) self.assertHttpStatus(response, status.HTTP_200_OK) 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): model = VMInterface