Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/features/configuration-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
54 changes: 50 additions & 4 deletions netbox/dcim/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1486,12 +1486,58 @@ def test_render_config(self):
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
Expand Down
13 changes: 13 additions & 0 deletions netbox/extras/api/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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

Expand Down Expand Up @@ -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:
Expand Down
11 changes: 10 additions & 1 deletion netbox/extras/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
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
Expand Down Expand Up @@ -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)
Expand Down
45 changes: 44 additions & 1 deletion netbox/extras/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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 *
Expand All @@ -11,7 +12,8 @@
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


Expand Down Expand Up @@ -854,6 +856,47 @@ def setUpTestData(cls):
)
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):

Expand Down
14 changes: 14 additions & 0 deletions netbox/netbox/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,20 @@ def has_object_permission(self, 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):
"""
Returns True if the user is authenticated or LOGIN_REQUIRED is False.
Expand Down
54 changes: 52 additions & 2 deletions netbox/virtualization/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
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,
)
Expand Down Expand Up @@ -281,12 +283,60 @@ def test_render_config(self):
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
Expand Down