From 445fa3e125a04df2342251e39460483883ab71ba Mon Sep 17 00:00:00 2001 From: Oliver Date: Tue, 13 Oct 2026 22:59:17 +1100 Subject: [PATCH] Check user permissions before locating stock (#13000) * Check user permissions before locating stock * Update unit test --- .../InvenTree/plugin/base/locate/api.py | 28 ++++++++++++--- .../plugin/base/locate/test_locate.py | 35 +++++++++++++++++++ .../samples/locate/test_locate_sample.py | 7 ++++ 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/backend/InvenTree/plugin/base/locate/api.py b/src/backend/InvenTree/plugin/base/locate/api.py index 406884c465..957d45ca69 100644 --- a/src/backend/InvenTree/plugin/base/locate/api.py +++ b/src/backend/InvenTree/plugin/base/locate/api.py @@ -1,7 +1,12 @@ """API for location plugins.""" from rest_framework import serializers -from rest_framework.exceptions import NotFound, ParseError, ValidationError +from rest_framework.exceptions import ( + NotFound, + ParseError, + PermissionDenied, + ValidationError, +) from rest_framework.generics import GenericAPIView from rest_framework.response import Response @@ -11,6 +16,7 @@ from InvenTree.tasks import offload_task from plugin import PluginMixinEnum, registry from plugin.registry import call_plugin_function from stock.models import StockItem, StockLocation +from users.permissions import check_user_permission class LocatePluginSerializer(serializers.Serializer): @@ -58,6 +64,12 @@ class LocatePluginView(GenericAPIView): # StockItem takes priority if item_pk: + # Check permissions *before* lookup, to prevent existence leaks + if not check_user_permission(request.user, StockItem, 'view'): + raise PermissionDenied( + 'User does not have permission to view stock items' + ) + try: StockItem.objects.get(pk=item_pk) @@ -76,10 +88,16 @@ class LocatePluginView(GenericAPIView): except (ValueError, StockItem.DoesNotExist): raise NotFound(f"StockItem matching PK '{item_pk}' not found") except Exception: - log_error('locate_stock_item', plugin=plugin.slug) - return ValidationError('Error locating stock item') + log_error('locate_stock_item', plugin=plugin) + raise ValidationError('Error locating stock item') elif location_pk: + # Check permissions *before* lookup, to prevent existence leaks + if not check_user_permission(request.user, StockLocation, 'view'): + raise PermissionDenied( + 'User does not have permission to view stock locations' + ) + try: StockLocation.objects.get(pk=location_pk) @@ -98,7 +116,7 @@ class LocatePluginView(GenericAPIView): except (ValueError, StockLocation.DoesNotExist): raise NotFound(f"StockLocation matching PK '{location_pk}' not found") except Exception: - log_error('locate_stock_location', plugin=plugin.slug) - return ValidationError('Error locating stock location') + log_error('locate_stock_location', plugin=plugin) + raise ValidationError('Error locating stock location') else: raise ParseError("Must supply either 'item' or 'location' parameter") diff --git a/src/backend/InvenTree/plugin/base/locate/test_locate.py b/src/backend/InvenTree/plugin/base/locate/test_locate.py index 23277d845a..6744d2520c 100644 --- a/src/backend/InvenTree/plugin/base/locate/test_locate.py +++ b/src/backend/InvenTree/plugin/base/locate/test_locate.py @@ -20,6 +20,8 @@ class LocatePluginTests(InvenTreeAPITestCase): fixtures = ['category', 'part', 'location', 'stock'] + roles = ['stock.view', 'stock_location.view'] + def test_installed(self): """Test that a locate plugin is actually installed.""" plugins = registry.with_mixin(PluginMixinEnum.LOCATE) @@ -69,6 +71,39 @@ class LocatePluginTests(InvenTreeAPITestCase): f"StockLocation matching PK '{pk}' not found", str(response.data) ) + def test_locate_permissions(self): + """Test that the locate endpoint enforces role permissions. + + A user without the required view permission must be denied, + for both existing and non-existent targets (no existence oracle). + """ + url = reverse('api-locate-plugin') + + item = StockItem.objects.get(pk=1) + item.set_metadata('located', False) + + self.clearRoles() + + for pk in [1, 99999]: + self.post(url, {'plugin': 'samplelocate', 'item': pk}, expected_code=403) + self.post( + url, {'plugin': 'samplelocate', 'location': pk}, expected_code=403 + ) + + # The plugin must not have been triggered + item.refresh_from_db() + self.assertFalse(item.metadata['located']) + + # Item permission does not grant location access (and vice versa) + self.assignRole('stock.view') + self.post(url, {'plugin': 'samplelocate', 'item': 1}, expected_code=200) + self.post(url, {'plugin': 'samplelocate', 'location': 1}, expected_code=403) + + self.clearRoles() + self.assignRole('stock_location.view') + self.post(url, {'plugin': 'samplelocate', 'item': 1}, expected_code=403) + self.post(url, {'plugin': 'samplelocate', 'location': 1}, expected_code=200) + def test_locate_item(self): """Test that the plugin correctly 'locates' a StockItem. diff --git a/src/backend/InvenTree/plugin/samples/locate/test_locate_sample.py b/src/backend/InvenTree/plugin/samples/locate/test_locate_sample.py index 023e203539..4bcb86a97a 100644 --- a/src/backend/InvenTree/plugin/samples/locate/test_locate_sample.py +++ b/src/backend/InvenTree/plugin/samples/locate/test_locate_sample.py @@ -32,6 +32,11 @@ class SampleLocatePluginTests(InvenTreeAPITestCase): # Right plugin - no search item self.post(url, {'plugin': 'samplelocate'}, expected_code=400) + # No permissions + self.post(url, {'plugin': 'samplelocate', 'item': 1}, expected_code=403) + + self.assignRole('stock.view') + # Right plugin - wrong reference self.post(url, {'plugin': 'samplelocate', 'item': 999}, expected_code=404) @@ -39,6 +44,8 @@ class SampleLocatePluginTests(InvenTreeAPITestCase): self.post(url, {'plugin': 'samplelocate', 'item': 1}, expected_code=200) # Right plugin - wrong reference + self.post(url, {'plugin': 'samplelocate', 'location': 999}, expected_code=403) + self.assignRole('stock_location.view') self.post(url, {'plugin': 'samplelocate', 'location': 999}, expected_code=404) # Right plugin - right reference