diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 359a1da26b..264f2dfc52 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,14 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 537 +INVENTREE_API_VERSION = 538 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v538 -> 2026-09-02 : https://github.com/inventree/InvenTree/pull/12763 + - Fix API permissions for the SelectionList endpoints + v537 -> 2026-08-31 : https://github.com/inventree/InvenTree/pull/11971 - Removes direct "notes" field from any models which previously supported markdown notes - Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 596fc40d5d..22aae6d491 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -1270,7 +1270,7 @@ class ParameterTemplateMixin: 'model_type' ) serializer_class = common.serializers.ParameterTemplateSerializer - permission_classes = [IsAuthenticatedOrReadScope] + permission_classes = [IsStaffOrReadOnlyScope] class ParameterTemplateList(ParameterTemplateMixin, DataExportViewMixin, ListCreateAPI): @@ -1444,7 +1444,7 @@ class SelectionListMixin(OutputOptionsMixin): queryset = common.models.SelectionList.objects.all() serializer_class = common.serializers.SelectionListSerializer - permission_classes = [IsAuthenticatedOrReadScope] + permission_classes = [IsStaffOrReadOnlyScope] def get_queryset(self): """Override the queryset method to include entry count.""" @@ -1464,7 +1464,7 @@ class EntryMixin: queryset = common.models.SelectionListEntry.objects.all() serializer_class = common.serializers.SelectionEntrySerializer - permission_classes = [IsAuthenticatedOrReadScope] + permission_classes = [IsStaffOrReadOnlyScope] lookup_url_kwarg = 'entrypk' def get_queryset(self): diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index d25a3181de..5000ff88ac 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -185,6 +185,38 @@ class ParameterAPITests(InvenTreeAPITestCase): self.assertIsNone(response.data['model_type']) self.assertFalse(response.data['enabled']) + def test_template_write_requires_staff(self): + """Non-staff users cannot create, edit or delete a ParameterTemplate.""" + template = common.models.ParameterTemplate.objects.create( + name='Staff Only Template', description='desc' + ) + detail_url = reverse( + 'api-parameter-template-detail', kwargs={'pk': template.pk} + ) + + self.user.is_staff = False + self.user.save() + + self.post( + reverse('api-parameter-template-list'), + {'name': 'Non-staff Template', 'description': 'desc'}, + expected_code=403, + ) + self.assertFalse( + common.models.ParameterTemplate.objects.filter( + name='Non-staff Template' + ).exists() + ) + + self.patch(detail_url, {'description': 'changed'}, expected_code=403) + template.refresh_from_db() + self.assertEqual(template.description, 'desc') + + self.delete(detail_url, expected_code=403) + self.assertTrue( + common.models.ParameterTemplate.objects.filter(pk=template.pk).exists() + ) + def test_template_filters(self): """Tests for API filters against ParameterTemplate endpoint.""" from company.models import Company @@ -2333,6 +2365,103 @@ class SelectionListLockedTest(InvenTreeAPITestCase): self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists()) +class SelectionListStaffPermissionAPITests(InvenTreeAPITestCase): + """Tests that non-staff users cannot create/edit/delete SelectionList catalog objects.""" + + def setUp(self): + """Create a SelectionList with one entry, owned by a staff user.""" + super().setUp() + + self.sel_list = SelectionList.objects.create(name='Test List') + self.entry = SelectionListEntry.objects.create( + list=self.sel_list, value='v1', label='Entry 1' + ) + + self.list_url = reverse('api-selectionlist-list') + self.detail_url = reverse( + 'api-selectionlist-detail', kwargs={'pk': self.sel_list.pk} + ) + self.entry_list_url = reverse( + 'api-selectionlistentry-list', kwargs={'pk': self.sel_list.pk} + ) + self.entry_detail_url = reverse( + 'api-selectionlistentry-detail', + kwargs={'pk': self.sel_list.pk, 'entrypk': self.entry.pk}, + ) + + def test_non_staff_cannot_create_list(self): + """A non-staff user cannot create a new SelectionList.""" + self.user.is_staff = False + self.user.save() + + self.post(self.list_url, {'name': 'Non-staff List'}, expected_code=403) + self.assertFalse(SelectionList.objects.filter(name='Non-staff List').exists()) + + def test_non_staff_cannot_edit_list(self): + """A non-staff user cannot update an existing SelectionList.""" + self.user.is_staff = False + self.user.save() + + self.patch(self.detail_url, {'name': 'Renamed'}, expected_code=403) + self.sel_list.refresh_from_db() + self.assertEqual(self.sel_list.name, 'Test List') + + def test_non_staff_cannot_delete_list(self): + """A non-staff user cannot delete a SelectionList.""" + self.user.is_staff = False + self.user.save() + + self.delete(self.detail_url, expected_code=403) + self.assertTrue(SelectionList.objects.filter(pk=self.sel_list.pk).exists()) + + def test_non_staff_cannot_create_entry(self): + """A non-staff user cannot create a new SelectionListEntry.""" + self.user.is_staff = False + self.user.save() + + self.post( + self.entry_list_url, + {'list': self.sel_list.pk, 'value': 'v2', 'label': 'Entry 2'}, + expected_code=403, + ) + self.assertFalse(SelectionListEntry.objects.filter(value='v2').exists()) + + def test_non_staff_cannot_edit_entry(self): + """A non-staff user cannot update an existing SelectionListEntry.""" + self.user.is_staff = False + self.user.save() + + self.patch(self.entry_detail_url, {'label': 'Changed'}, expected_code=403) + self.entry.refresh_from_db() + self.assertEqual(self.entry.label, 'Entry 1') + + def test_non_staff_cannot_delete_entry(self): + """A non-staff user cannot delete a SelectionListEntry.""" + self.user.is_staff = False + self.user.save() + + self.delete(self.entry_detail_url, expected_code=403) + self.assertTrue(SelectionListEntry.objects.filter(pk=self.entry.pk).exists()) + + def test_staff_can_still_manage_lists_and_entries(self): + """A staff user retains full create/edit/delete access.""" + self.user.is_staff = True + self.user.save() + + response = self.post(self.list_url, {'name': 'Staff List'}, expected_code=201) + pk = response.data['pk'] + + self.patch( + reverse('api-selectionlist-detail', kwargs={'pk': pk}), + {'name': 'Staff List Renamed'}, + expected_code=200, + ) + + self.delete( + reverse('api-selectionlist-detail', kwargs={'pk': pk}), expected_code=204 + ) + + class NotePermissionAPITests(InvenTreeAPITestCase): """Tests for Note API permission enforcement.