2
0
mirror of https://github.com/inventree/InvenTree.git synced 2026-07-04 06:00:38 +00:00

Permissions fix (#12168)

* Tighten API permissions

- Require authenticated user for NotFoundView
- Hide 'active_plugins' behind is_authenticated

* Patch permissions hole in GlobalSettingsPermissions

* Additional API unit tests

* Require auth for observability endpoint

* Add explicit permission for PluginAdminDetail

* Bump API version

* Update unit tests

* Revert changes
This commit is contained in:
Oliver
2026-06-15 22:06:49 +10:00
committed by GitHub
parent 3c17367e3c
commit 6c18e64020
7 changed files with 75 additions and 12 deletions
+5 -2
View File
@@ -15,7 +15,7 @@ from django.views.generic.base import RedirectView
import structlog import structlog
from django_q.models import OrmQ from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import serializers, viewsets from rest_framework import permissions, serializers, viewsets
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.request import clone_request from rest_framework.request import clone_request
from rest_framework.response import Response from rest_framework.response import Response
@@ -356,7 +356,10 @@ class InfoView(APIView):
class NotFoundView(APIView): class NotFoundView(APIView):
"""Simple JSON view when accessing an invalid API view.""" """Simple JSON view when accessing an invalid API view."""
permission_classes = [InvenTree.permissions.AllowAnyOrReadScope] permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.AllowAnyOrReadScope,
]
def not_found(self, request): def not_found(self, request):
"""Return a 404 error.""" """Return a 404 error."""
@@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 505 INVENTREE_API_VERSION = 506
"""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 = """
v506 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12168
- Reduce permissions scope for a number of API endpoints, to improve security and ensure that users only have access to the data they need
v505 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12165 v505 -> 2026-06-15 : https://github.com/inventree/InvenTree/pull/12165
- Allow parameters to be specified against the PartCategory model - Allow parameters to be specified against the PartCategory model
@@ -415,7 +415,6 @@ class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission):
"""Check that the requesting user is 'admin'.""" """Check that the requesting user is 'admin'."""
try: try:
user = request.user user = request.user
if request.method in permissions.SAFE_METHODS: if request.method in permissions.SAFE_METHODS:
return True return True
# Any other methods require staff access permissions # Any other methods require staff access permissions
@@ -612,6 +612,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
response = self.get( response = self.get(
url, headers={'Authorization': f'Token {token}'}, max_query_count=20 url, headers={'Authorization': f'Token {token}'}, max_query_count=20
) )
self.assertIsNotNone(data.get('active_plugins'))
self.assertGreater(len(response.json()['database']), 4) self.assertGreater(len(response.json()['database']), 4)
data = response.json() data = response.json()
+1 -1
View File
@@ -1405,7 +1405,7 @@ class ObservabilityEndSerializer(serializers.Serializer):
class ObservabilityEnd(CreateAPI): class ObservabilityEnd(CreateAPI):
"""Endpoint for observability tools.""" """Endpoint for observability tools."""
permission_classes = [AllowAnyOrReadScope] permission_classes = [IsAuthenticated, AllowAnyOrReadScope]
serializer_class = ObservabilityEndSerializer serializer_class = ObservabilityEndSerializer
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
+19 -7
View File
@@ -10,7 +10,7 @@ import django_filters.rest_framework.filters as rest_filters
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from django_filters.rest_framework.filterset import FilterSet from django_filters.rest_framework.filterset import FilterSet
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import status from rest_framework import permissions, status
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@@ -174,7 +174,10 @@ class PluginDetail(RetrieveDestroyAPI):
queryset = PluginConfig.objects.all() queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginConfigSerializer serializer_class = PluginSerializers.PluginConfigSerializer
permission_classes = [InvenTree.permissions.IsSuperuserOrReadOnlyOrScope] permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.IsSuperuserOrReadOnlyOrScope,
]
lookup_field = 'key' lookup_field = 'key'
lookup_url_kwarg = 'plugin' lookup_url_kwarg = 'plugin'
@@ -201,6 +204,7 @@ class PluginAdminDetail(RetrieveAPI):
queryset = PluginConfig.objects.all() queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginAdminDetailSerializer serializer_class = PluginSerializers.PluginAdminDetailSerializer
permission_classes = [InvenTree.permissions.IsAdminOrAdminScope]
lookup_field = 'key' lookup_field = 'key'
lookup_url_kwarg = 'plugin' lookup_url_kwarg = 'plugin'
@@ -292,7 +296,10 @@ class PluginSettingList(ListAPI):
queryset = PluginSetting.objects.all() queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer serializer_class = PluginSerializers.PluginSettingSerializer
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions] permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.GlobalSettingsPermissions,
]
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend]
@@ -365,7 +372,10 @@ class PluginAllSettingList(APIView):
- GET: return all settings for a plugin config - GET: return all settings for a plugin config
""" """
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions] permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.GlobalSettingsPermissions,
]
@extend_schema( @extend_schema(
responses={200: PluginSerializers.PluginSettingSerializer(many=True)} responses={200: PluginSerializers.PluginSettingSerializer(many=True)}
@@ -393,6 +403,11 @@ class PluginSettingDetail(RetrieveUpdateAPI):
queryset = PluginSetting.objects.all() queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer serializer_class = PluginSerializers.PluginSettingSerializer
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.GlobalSettingsPermissions,
]
def get_object(self): def get_object(self):
"""Lookup the plugin setting object, based on the URL. """Lookup the plugin setting object, based on the URL.
@@ -415,9 +430,6 @@ class PluginSettingDetail(RetrieveUpdateAPI):
setting_key, plugin=plugin.plugin_config() setting_key, plugin=plugin.plugin_config()
) )
# Staff permission required
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
class PluginUserSettingList(APIView): class PluginUserSettingList(APIView):
"""List endpoint for all user settings for a specific plugin. """List endpoint for all user settings for a specific plugin.
+45
View File
@@ -816,3 +816,48 @@ class PluginLockedSettingsTest(PluginMixin, InvenTreeAPITestCase):
self.assertFalse( self.assertFalse(
response.data['read_only'], msg=f'{key} should not be read_only' response.data['read_only'], msg=f'{key} should not be read_only'
) )
class PluginUnauthenticatedAccessTest(PluginMixin, InvenTreeAPITestCase):
"""Ensure plugin API endpoints reject unauthenticated requests.
Tests the four endpoints hardened on the permissions-fix branch:
- PluginDetail (api-plugin-detail)
- PluginSettingList (api-plugin-setting-list)
- PluginAllSettingList (api-plugin-settings)
- PluginSettingDetail (api-plugin-setting-detail)
"""
superuser = True
PLUGIN_SLUG = 'sample'
SETTING_KEY = 'API_KEY'
def setUp(self):
"""Activate sample plugin, then log out to simulate an anonymous client."""
super().setUp()
from plugin.registry import registry
registry.set_plugin_state(self.PLUGIN_SLUG, True)
self.client.logout()
def test_plugin_detail_unauthenticated(self):
"""GET /api/plugins/<slug>/ must return 401 for unauthenticated users."""
url = reverse('api-plugin-detail', kwargs={'plugin': self.PLUGIN_SLUG})
self.get(url, expected_code=401)
def test_plugin_setting_list_unauthenticated(self):
"""GET /api/plugins/settings/ must return 401 for unauthenticated users."""
self.get(reverse('api-plugin-setting-list'), expected_code=401)
def test_plugin_all_settings_unauthenticated(self):
"""GET /api/plugins/<slug>/settings/ must return 401 for unauthenticated users."""
url = reverse('api-plugin-settings', kwargs={'plugin': self.PLUGIN_SLUG})
self.get(url, expected_code=401)
def test_plugin_setting_detail_unauthenticated(self):
"""GET /api/plugins/<slug>/settings/<key>/ must return 401 for unauthenticated users."""
url = reverse(
'api-plugin-setting-detail',
kwargs={'plugin': self.PLUGIN_SLUG, 'key': self.SETTING_KEY},
)
self.get(url, expected_code=401)