diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 8b6cac98d..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 @@ -1485,8 +1486,8 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): device.config_template = configtemplate device.save() - self.add_permissions('dcim.render_config_device', 'dcim.view_device', 'extras.view_configtemplate') - 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}') @@ -1502,10 +1503,41 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): device.save() # No permissions added - user has no render_config permission - 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) 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 0716788f7..7a15364f7 100644 --- a/netbox/extras/api/mixins.py +++ b/netbox/extras/api/mixins.py @@ -1,7 +1,5 @@ -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 @@ -91,10 +89,6 @@ class RenderConfigMixin(ConfigTemplateRenderMixin): 'error': f'No config template found for this {object_type}.' }, status=HTTP_400_BAD_REQUEST) - # Check view permission for ConfigTemplate - if not request.user.has_perm('extras.view_configtemplate', obj=configtemplate): - raise PermissionDenied(_("This user does not have permission to view this configuration template.")) - # Compile context data context_data = instance.get_config_context() context_data.update(request.data) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index ea55668ca..92cdd88bd 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -12,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 @@ -859,7 +860,7 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase): configtemplate = ConfigTemplate.objects.first() self.add_permissions('extras.render_configtemplate', 'extras.view_configtemplate') - url = reverse('extras-api:configtemplate-detail', kwargs={'pk': configtemplate.pk}) + 'render/' + 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') @@ -868,10 +869,34 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase): configtemplate = ConfigTemplate.objects.first() # No permissions added - user has no render permission - url = reverse('extras-api:configtemplate-detail', kwargs={'pk': configtemplate.pk}) + 'render/' + 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 7397f99ba..7dbc38598 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -169,10 +169,13 @@ 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 + 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): diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 4c4d9be4d..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, ) @@ -282,10 +284,9 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): vm.save() self.add_permissions( - 'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine', - 'extras.view_configtemplate' + 'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine' ) - url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/' + 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}') @@ -301,10 +302,41 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): vm.save() # No permissions added - user has no render_config permission - url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/' + 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