Closes #16681: Introduce render_config permission for configuration rendering (#20555)
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
7 changed files with 190 additions and 8 deletions
+13
View File
@@ -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:
+10 -1
View File
@@ -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)
+44 -1
View File
@@ -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):