API permission tweaks (#12886)

* API permission tweaks

- Explicitly require admin role for MachineSettingList
- Permission check for barcode generation

* Adjust existing unit test

* Update API version
This commit is contained in:
Oliver
2026-09-19 17:58:00 +10:00
committed by GitHub
parent 2f9064a5c2
commit 2845a59207
8 changed files with 61 additions and 3 deletions
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 547 INVENTREE_API_VERSION = 548
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
v548 -> 2026-09-19 : https://github.com/inventree/InvenTree/pull/12886
- Fix API permissions for multiple endpoints
v547 -> 2026-09-12 : https://github.com/inventree/InvenTree/pull/12850 v547 -> 2026-09-12 : https://github.com/inventree/InvenTree/pull/12850
- Added more details to API token management - Added more details to API token management
+14 -1
View File
@@ -79,7 +79,11 @@ class MachineSettingList(APIView):
- GET: return all settings for a machine config - GET: return all settings for a machine config
""" """
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope] permission_classes = [
InvenTree.permissions.IsAuthenticatedOrReadScope,
InvenTree.permissions.RolePermission,
]
role_required = 'admin.view'
@extend_schema( @extend_schema(
responses={200: MachineSerializers.MachineSettingSerializer(many=True)} responses={200: MachineSerializers.MachineSettingSerializer(many=True)}
@@ -118,6 +122,15 @@ class MachineSettingDetail(RetrieveUpdateAPI):
queryset = MachineSetting.objects.all() queryset = MachineSetting.objects.all()
serializer_class = MachineSerializers.MachineSettingSerializer serializer_class = MachineSerializers.MachineSettingSerializer
def get_permission_model(self):
"""Return the model to check for role permissions.
Note: MachineSettingSerializer only assigns Meta.model on the
instance (not the class), so the default class-level lookup
cannot find it and RolePermission would otherwise fail open.
"""
return MachineSetting
def get_object(self): def get_object(self):
"""Lookup machine setting object, based on the URL.""" """Lookup machine setting object, based on the URL."""
pk = self.kwargs['pk'] pk = self.kwargs['pk']
@@ -255,6 +255,11 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
[(s['config_type'], s['key']) for s in response.data], [(s['config_type'], s['key']) for s in response.data],
) )
# A user without 'admin.view' cannot read or write machine settings
self.clearRoles()
self.get(machine_setting_url, expected_code=403)
self.patch(machine_setting_url, {'value': 'x'}, expected_code=403)
def test_machine_settings_list(self): def test_machine_settings_list(self):
"""Test machine settings list API endpoint.""" """Test machine settings list API endpoint."""
machine = MachineConfig.objects.create( machine = MachineConfig.objects.create(
@@ -278,6 +283,10 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
for key in ['api_url', 'pk', 'typ', 'key']: for key in ['api_url', 'pk', 'typ', 'key']:
self.assertIn(key, item) self.assertIn(key, item)
# A user without 'admin.view' cannot read machine settings
self.clearRoles()
self.get(url, expected_code=403)
def test_machine_restart(self): def test_machine_restart(self):
"""Test machine restart API endpoint.""" """Test machine restart API endpoint."""
machine = MachineConfig.objects.create( machine = MachineConfig.objects.create(
@@ -260,6 +260,9 @@ class BarcodeGenerate(CreateAPIView):
except model_cls.DoesNotExist: except model_cls.DoesNotExist:
raise ValidationError({'error': _('Model instance not found')}) raise ValidationError({'error': _('Model instance not found')})
if not check_user_permission(request.user, model_cls, 'view'):
raise PermissionDenied()
barcode_data = plugin.base.barcodes.helper.generate_barcode(model_instance) barcode_data = plugin.base.barcodes.helper.generate_barcode(model_instance)
return Response({'barcode': barcode_data}, status=status.HTTP_200_OK) return Response({'barcode': barcode_data}, status=status.HTTP_200_OK)
@@ -181,6 +181,11 @@ class BarcodeAPITest(InvenTreeAPITestCase):
data = self.generateBarcode('stockitem', item.pk, expected_code=200).data data = self.generateBarcode('stockitem', item.pk, expected_code=200).data
self.assertEqual(data['barcode'], 'INV-SI522') self.assertEqual(data['barcode'], 'INV-SI522')
# A user without 'stock.view' cannot generate a barcode for a stock item
# they cannot otherwise access
self.clearRoles()
self.generateBarcode('stockitem', item.pk, expected_code=403)
def test_barcode_generation_invalid(self): def test_barcode_generation_invalid(self):
"""Test barcode generation for invalid model/pk.""" """Test barcode generation for invalid model/pk."""
self.generateBarcode('invalidmodel', 1, expected_code=400) self.generateBarcode('invalidmodel', 1, expected_code=400)
@@ -14,6 +14,8 @@ class SampleValidatorPluginTest(InvenTreeAPITestCase, InvenTreeTestCase):
fixtures = ['part', 'category', 'location', 'build', 'stock'] fixtures = ['part', 'category', 'location', 'build', 'stock']
roles = ['part.view', 'build.view']
def setUp(self): def setUp(self):
"""Set up the test environment.""" """Set up the test environment."""
super().setUp() super().setUp()
+14 -1
View File
@@ -5,7 +5,7 @@ from datetime import timedelta
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction from django.db import transaction
from django.db.models import F, Q from django.db.models import F, Model, Q
from django.urls import include, path from django.urls import include, path
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@@ -14,6 +14,7 @@ from django_filters.rest_framework.filterset import FilterSet
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema, extend_schema_field from drf_spectacular.utils import extend_schema, extend_schema_field
from rest_framework import status from rest_framework import status
from rest_framework.exceptions import PermissionDenied
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@@ -73,6 +74,7 @@ from stock.models import (
StockLocationType, StockLocationType,
) )
from stock.status_codes import StockHistoryCode, StockStatus from stock.status_codes import StockHistoryCode, StockStatus
from users.permissions import check_user_permission
class GenerateBatchCode(GenericAPIView): class GenerateBatchCode(GenericAPIView):
@@ -86,6 +88,12 @@ class GenerateBatchCode(GenericAPIView):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
for value in serializer.validated_data.values():
if isinstance(value, Model) and not check_user_permission(
request.user, value.__class__, 'view'
):
raise PermissionDenied()
data = {'batch_code': generate_batch_code(**serializer.validated_data)} data = {'batch_code': generate_batch_code(**serializer.validated_data)}
return Response(data, status=status.HTTP_201_CREATED) return Response(data, status=status.HTTP_201_CREATED)
@@ -102,6 +110,11 @@ class GenerateSerialNumber(GenericAPIView):
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
part = serializer.validated_data.get('part')
if part and not check_user_permission(request.user, part.__class__, 'view'):
raise PermissionDenied()
data = {'serial_number': generate_serial_number(**serializer.validated_data)} data = {'serial_number': generate_serial_number(**serializer.validated_data)}
return Response(data, status=status.HTTP_201_CREATED) return Response(data, status=status.HTTP_201_CREATED)
+10
View File
@@ -1294,6 +1294,11 @@ class StockItemListTest(StockAPITestCase):
response = self.post(url, {'item': 1, 'quantity': 2}) response = self.post(url, {'item': 1, 'quantity': 2})
self.assertEqual(response.data['batch_code'], '1') self.assertEqual(response.data['batch_code'], '1')
# A user without 'stock.view' cannot use this endpoint to read the batch template
# rendering of a stock item they cannot otherwise access
self.clearRoles()
self.post(url, {'item': 1}, expected_code=403)
def test_serial_generate_api(self): def test_serial_generate_api(self):
"""Test helper API for serial management.""" """Test helper API for serial management."""
url = reverse('api-generate-serial-number') url = reverse('api-generate-serial-number')
@@ -1317,6 +1322,11 @@ class StockItemListTest(StockAPITestCase):
response.data['quantity'], ['Quantity must be greater than zero'] response.data['quantity'], ['Quantity must be greater than zero']
) )
# A user without 'part.view' cannot use this endpoint to read serial numbers
# for a part they cannot otherwise access
self.clearRoles()
self.post(url, {'part': 1, 'quantity': 1}, expected_code=403)
def test_child_items(self): def test_child_items(self):
"""Test that the 'child_items' annotation works as expected.""" """Test that the 'child_items' annotation works as expected."""
# Create a trackable part # Create a trackable part