From 4d446198b668433f15a70a56b1609bd185dfd456 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Fri, 11 Jul 2025 00:45:55 +0200 Subject: [PATCH] fix(backend): missing scope (#10001) * move permission and fix scope * add a way to only use scopes for doc purposes * add a check to stop this from happening again * bump api version --- .../InvenTree/InvenTree/api_version.py | 5 +- .../InvenTree/InvenTree/permissions.py | 48 ++++++++++++++++++- src/backend/InvenTree/InvenTree/schema.py | 12 +++++ src/backend/InvenTree/InvenTree/settings.py | 23 +++++---- src/backend/InvenTree/importer/api.py | 31 ++---------- 5 files changed, 79 insertions(+), 40 deletions(-) diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 74e3088da8..83dfae13bc 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 366 +INVENTREE_API_VERSION = 367 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v367 -> 2025-07-10 : https://github.com/inventree/InvenTree/pull/10001 + - Adds OAuth2 scopes for importer sessions + v366 -> 2025-07-09 : https://github.com/inventree/InvenTree/pull/9987 - Adds "category" filter to BomItem API endpoint diff --git a/src/backend/InvenTree/InvenTree/permissions.py b/src/backend/InvenTree/InvenTree/permissions.py index c78a24f384..9f5edea30a 100644 --- a/src/backend/InvenTree/InvenTree/permissions.py +++ b/src/backend/InvenTree/InvenTree/permissions.py @@ -13,6 +13,7 @@ from users.oauth2_scopes import ( DEFAULT_READ, DEFAULT_STAFF, DEFAULT_SUPERUSER, + _roles, get_granular_scope, ) @@ -46,6 +47,7 @@ def map_scope( read_name=DEFAULT_READ, map_read: Optional[list[str]] = None, map_read_name=DEFAULT_READ, + override_all_actions: Optional[str] = None, ) -> dict: """Generate the required scopes for OAS permission views. @@ -55,6 +57,7 @@ def map_scope( read_name (str): The read scope name to use when `only_read` is True. map_read (Optional[list[str]]): A list of HTTP methods that should map to the default read scope (use if some actions requirea differing role). map_read_name (str): The read scope name to use for methods specified in `map_read` when `map_read` is specified. + override_all_actions (Optional[str]): If specified, all actions will be overridden to use the provided action name instead of the default action names. Returns: dict: A dictionary mapping HTTP methods to their corresponding scopes. @@ -71,7 +74,7 @@ def map_scope( def get_scope(method, action): if map_read and method in map_read: return [[map_read_name]] - return scope_name(action) + return scope_name(override_all_actions if override_all_actions else action) return { method: get_scope(method, action) if method != 'OPTIONS' else [[DEFAULT_READ]] @@ -92,8 +95,12 @@ for role, tables in roles.items(): class OASTokenMixin: """Mixin that combines the permissions of normal classes and token classes.""" + ENFORCE_USER_PERMS: bool = False + def has_permission(self, request, view): """Check if the user has the required scopes or was authenticated another way.""" + if self.ENFORCE_USER_PERMS: + return super().has_permission(request, view) return self.check_oauth2_authentication( request, view ) or super().has_permission(request, view) @@ -384,3 +391,42 @@ class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission): return map_scope( only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS ) + + +class DataImporterPermission(OASTokenMixin, permissions.BasePermission): + """Mixin class for determining if the user has correct permissions.""" + + ENFORCE_USER_PERMS = True + + def has_permission(self, request, view): + """Class level permission checks are handled via InvenTree.permissions.IsAuthenticatedOrReadScope.""" + return request.user and request.user.is_authenticated + + def get_required_alternate_scopes(self, request, view): + """Return the required scopes for the current request.""" + return map_scope( + roles=_roles, + map_read=permissions.SAFE_METHODS, + override_all_actions='change', # this is done to match the custom has_object_permission method + ) + + def has_object_permission(self, request, view, obj): + """Check if the user has permission to access the imported object.""" + import importer.models + + # For safe methods (GET, HEAD, OPTIONS), allow access + if request.method in permissions.SAFE_METHODS: + return True + + if isinstance(obj, importer.models.DataImportSession): + session = obj + else: + session = getattr(obj, 'session', None) + + if session: + if model_class := session.model_class: + return users.permissions.check_user_permission( + request.user, model_class, 'change' + ) + + return True diff --git a/src/backend/InvenTree/InvenTree/schema.py b/src/backend/InvenTree/InvenTree/schema.py index 1999b102ca..1ec31a8272 100644 --- a/src/backend/InvenTree/InvenTree/schema.py +++ b/src/backend/InvenTree/InvenTree/schema.py @@ -3,6 +3,8 @@ from itertools import chain from typing import Optional +from django.conf import settings + from drf_spectacular.contrib.django_oauth_toolkit import DjangoOAuthToolkitScheme from drf_spectacular.drainage import warn from drf_spectacular.openapi import AutoSchema @@ -174,4 +176,14 @@ def postprocess_print_stats(result, generator, request, public): if scope not in oauth2_scopes: warn(f'unknown scope `{scope}` in {len(paths)} paths') + # Raise error if the paths missing scopes are not specifically excluded from oauth2 + wrong_url = [ + path for path in no_oauth2_wa if path not in settings.OAUTH2_CHECK_EXCLUDED + ] + if len(wrong_url) > 0: + warn( + f'Found {len(wrong_url)} paths without oauth2 that are not excluded:\n{", ".join(wrong_url)}. ' + '\n\nPlease check the schema and add oauth2 scopes where necessary.' + ) + return result diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index 1465dc1982..9259e0fa53 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -540,15 +540,6 @@ TEMPLATES = [ } ] -OAUTH2_PROVIDER = { - # default scopes - 'SCOPES': oauth2_scopes, - # OIDC - 'OIDC_ENABLED': True, - 'OIDC_RSA_PRIVATE_KEY': get_oidc_private_key(), - 'PKCE_REQUIRED': False, -} - REST_FRAMEWORK = { 'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler', 'DATETIME_FORMAT': '%Y-%m-%d %H:%M', @@ -1480,7 +1471,7 @@ if CUSTOM_FLAGS: SESAME_MAX_AGE = 300 LOGIN_REDIRECT_URL = '/api/auth/login-redirect/' -# Configuration for API schema generation +# Configuration for API schema generation / oAuth2 SPECTACULAR_SETTINGS = { 'TITLE': 'InvenTree API', 'DESCRIPTION': 'API for InvenTree - the intuitive open source inventory management system', @@ -1515,6 +1506,18 @@ SPECTACULAR_SETTINGS = { 'OAUTH2_TOKEN_URL': '/o/token/', 'OAUTH2_REFRESH_URL': '/o/revoke_token/', } +OAUTH2_PROVIDER = { + # default scopes + 'SCOPES': oauth2_scopes, + # OIDC + 'OIDC_ENABLED': True, + 'OIDC_RSA_PRIVATE_KEY': get_oidc_private_key(), + 'PKCE_REQUIRED': False, +} +OAUTH2_CHECK_EXCLUDED = [ # This setting mutes schema checks for these rule/method combinations + '/api/email/generate/:post', + '/api/webhook/{endpoint}/:post', +] if SITE_URL and not TESTING: SPECTACULAR_SETTINGS['SERVERS'] = [{'url': SITE_URL}] diff --git a/src/backend/InvenTree/importer/api.py b/src/backend/InvenTree/importer/api.py index bae964131a..5d3fd6a748 100644 --- a/src/backend/InvenTree/importer/api.py +++ b/src/backend/InvenTree/importer/api.py @@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404 from django.urls import include, path from drf_spectacular.utils import extend_schema -from rest_framework import permissions, serializers +from rest_framework import serializers from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.views import APIView @@ -25,38 +25,13 @@ from InvenTree.mixins import ( from users.permissions import check_user_permission -class DataImporterPermission(permissions.BasePermission): - """Mixin class for determining if the user has correct permissions.""" - - def has_permission(self, request, view): - """Class level permission checks are handled via InvenTree.permissions.IsAuthenticatedOrReadScope.""" - return request.user and request.user.is_authenticated - - def has_object_permission(self, request, view, obj): - """Check if the user has permission to access the imported object.""" - # For safe methods (GET, HEAD, OPTIONS), allow access - if request.method in permissions.SAFE_METHODS: - return True - - if isinstance(obj, importer.models.DataImportSession): - session = obj - else: - session = getattr(obj, 'session', None) - - if session: - if model_class := session.model_class: - return check_user_permission(request.user, model_class, 'change') - - return True - - class DataImporterPermissionMixin: """Mixin class for checking permissions on DataImporter objects.""" # Default permissions: User must be authenticated permission_classes = [ InvenTree.permissions.IsAuthenticatedOrReadScope, - DataImporterPermission, + InvenTree.permissions.DataImporterPermission, ] @@ -96,7 +71,7 @@ class DataImportSessionMixin: queryset = importer.models.DataImportSession.objects.all() serializer_class = importer.serializers.DataImportSessionSerializer - permission_classes = [DataImporterPermission] + permission_classes = [InvenTree.permissions.DataImporterPermission] class DataImportSessionList(BulkDeleteMixin, DataImportSessionMixin, ListCreateAPI):