Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 the `render_config` permission 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_config` action
17 changes: 16 additions & 1 deletion netbox/dcim/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1486,12 +1486,27 @@ def test_render_config(self):
device.config_template = configtemplate
device.save()

self.add_permissions('dcim.add_device')
self.add_permissions('dcim.render_config_device')
url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/'
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-detail', kwargs={'pk': device.pk}) + 'render-config/'
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)


class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module
Expand Down
18 changes: 18 additions & 0 deletions netbox/extras/api/mixins.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from django.utils.translation import gettext_lazy as _
from jinja2.exceptions import TemplateError
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
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 utilities.permissions import get_permission_for_model
from .serializers import ConfigTemplateSerializer

__all__ = (
Expand Down Expand Up @@ -64,12 +68,26 @@ 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.
"""
self.queryset = self.queryset.model.objects.all().restrict(request.user, 'render_config')
instance = self.get_object()

# Check render_config permission
perm = get_permission_for_model(instance, 'render_config')
if not request.user.has_perm(perm, obj=instance):
raise PermissionDenied(_("This user does not have permission to render configurations for this object."))

object_type = instance._meta.model_name
configtemplate = instance.get_config_template()
if not configtemplate:
Expand Down
17 changes: 16 additions & 1 deletion netbox/extras/api/views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from django_rq.queues import get_connection
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import status
Expand All @@ -16,12 +17,13 @@
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
from netbox.api.viewsets import BaseViewSet, NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
from utilities.permissions import get_permission_for_model
from utilities.request import copy_safe_request
from . import serializers
from .mixins import ConfigTemplateRenderMixin
Expand Down Expand Up @@ -238,13 +240,26 @@ 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.
"""
self.queryset = self.queryset.model.objects.all().restrict(request.user, 'render_config')
configtemplate = self.get_object()

# Check render_config permission
perm = get_permission_for_model(configtemplate, 'render_config')
if not request.user.has_perm(perm, obj=configtemplate):
raise PermissionDenied(_("This user does not have permission to render configuration templates."))

context = request.data

return self.render_configtemplate(request, configtemplate, context)
Expand Down
18 changes: 18 additions & 0 deletions 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 Down Expand Up @@ -854,6 +855,23 @@ def setUpTestData(cls):
)
ConfigTemplate.objects.bulk_create(config_templates)

def test_render(self):
configtemplate = ConfigTemplate.objects.first()

self.add_permissions('extras.render_config_configtemplate')
url = reverse('extras-api:configtemplate-detail', kwargs={'pk': configtemplate.pk}) + 'render/'
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_config permission
url = reverse('extras-api:configtemplate-detail', kwargs={'pk': configtemplate.pk}) + 'render/'
response = self.client.post(url, {'foo': 'bar'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)


class ScriptTest(APITestCase):

Expand Down
11 changes: 11 additions & 0 deletions netbox/netbox/api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,17 @@ 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 isinstance(request.auth, Token):
return request.method in SAFE_METHODS or request.auth.write_enabled
return True


class IsAuthenticatedOrLoginNotRequired(BasePermission):
"""
Returns True if the user is authenticated or LOGIN_REQUIRED is False.
Expand Down
17 changes: 16 additions & 1 deletion netbox/virtualization/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,12 +281,27 @@ def test_render_config(self):
vm.config_template = configtemplate
vm.save()

self.add_permissions('virtualization.add_virtualmachine')
self.add_permissions('virtualization.render_config_virtualmachine')
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/'
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-detail', kwargs={'pk': vm.pk}) + 'render-config/'
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)


class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface
Expand Down