Closes #16681: Introduce render_config permission for configuration rendering (#20555)
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled

* 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>
This commit is contained in:
Jason Novinger 2025-10-21 08:26:06 -05:00 committed by GitHub
parent 87505e0bb9
commit 5bbab7eb47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 190 additions and 8 deletions

View File

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

View File

@ -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') self.add_permissions('dcim.render_config_device', 'dcim.view_device')
url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/' 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

View File

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

View File

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

View File

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

View File

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

View File

@ -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') self.add_permissions(
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/' '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