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:
@@ -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()
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user