2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 04:25:42 +00:00

feat(backend): add oauth2 (#9333)

* feat(backend): add oauth2

* fix import

* Add inventree roles

* refactor to make lookup more efficient

* fix single scope definitions

* cleanup

* fix schema

* reduce auth methods

* fix OAuth validator

* re-enable token and basic auth again

* Add models to role mapping

* change scope args

* add debug step for schema generation

* add oauth config for schema generation

* improve token -> permission mapping

* fix req

* extend checks to ensure normal auth also passes

* fix api version

* fix ignore

* fix rule name

* bump api version

* remove old modelref

* move scope definition

* make test results easier to work with

* add tests to ensure scopes are in sync with rulesets

* fix docstring

* fix various permissions and their mapping to oauth

* refactor

* simplify

* fix permission mapping

* ignore failure cases

* fix unauthenticated access

* flag oAuth2 till it is done

* Add OIDC support

* add RSA key generation and docs

* fix test

* move imports

* update ignore

* feat(backend): Add API Schema stats

* add scope stats

* fix name

* fix scope output

* feat(backend): test custom command

* add warning for unknown scopes

* reduce diff in launch.json

* cleanup  diff

* add error code for ruleset / scope issues

* update structure

* add oauth docs

* add experimetnal feature docs

* simplify metadata endpoint

* add importer model

* refactor(backend): simplify metadata endpoint

* fix imports

* simplify even more

* remove unneeded schema tooling

* fix permission mappings

* fix testing

* fix role calculations

* fix mapping

* remove importer change to unblock this

* remove importer scope everywhere

* fix merge conflict in test

* add missing models

* fix api version

* fix OASToken matcher

* revert permission class change

* reduce size of test log by writing schema

* fix permissions

* fix file path

* extend schema to remove need for TokenMatchesOASRequirements

* cleanup permissions file

* add base object permission
This commit is contained in:
Matthias Mair
2025-04-18 11:27:32 +02:00
committed by GitHub
parent afbcfe66bb
commit 8bb03b7afd
43 changed files with 718 additions and 204 deletions

View File

@ -11,13 +11,12 @@ from django.utils.translation import gettext_lazy as _
import structlog
from django_q.models import OrmQ
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from rest_framework.views import APIView
import InvenTree.ready
import InvenTree.version
from common.settings import get_global_setting
from InvenTree import helpers
@ -88,7 +87,7 @@ class LicenseViewSerializer(serializers.Serializer):
class LicenseView(APIView):
"""Simple JSON endpoint for InvenTree license information."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
@extend_schema(responses={200: OpenApiResponse(response=LicenseViewSerializer)})
def get(self, request, *args, **kwargs):
@ -134,7 +133,7 @@ class VersionViewSerializer(serializers.Serializer):
class VersionView(APIView):
"""Simple JSON endpoint for InvenTree version information."""
permission_classes = [permissions.IsAdminUser]
permission_classes = [InvenTree.permissions.IsAdminOrAdminScope]
@extend_schema(responses={200: OpenApiResponse(response=VersionViewSerializer)})
def get(self, request, *args, **kwargs):
@ -195,7 +194,7 @@ class VersionTextView(ListAPI):
serializer_class = VersionInformationSerializer
permission_classes = [permissions.IsAdminUser]
permission_classes = [InvenTree.permissions.IsAdminOrAdminScope]
# Specifically disable pagination for this view
pagination_class = None
@ -266,7 +265,7 @@ class InfoView(APIView):
Use to confirm that the server is running, etc.
"""
permission_classes = [permissions.AllowAny]
permission_classes = [InvenTree.permissions.AllowAnyOrReadScope]
def worker_pending_tasks(self):
"""Return the current number of outstanding background tasks."""
@ -349,7 +348,7 @@ class InfoView(APIView):
class NotFoundView(APIView):
"""Simple JSON view when accessing an invalid API view."""
permission_classes = [permissions.AllowAny]
permission_classes = [InvenTree.permissions.AllowAnyOrReadScope]
def not_found(self, request):
"""Return a 404 error."""
@ -613,7 +612,7 @@ class APISearchView(GenericAPIView):
Is much more efficient and simplifies code!
"""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = APISearchViewSerializer
def get_result_types(self):

View File

@ -1,13 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 337
INVENTREE_API_VERSION = 338
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v338 -> 2025-04-15 : https://github.com/inventree/InvenTree/pull/9333
- Adds oAuth2 support for the API
v337 -> 2025-04-15 : https://github.com/inventree/InvenTree/pull/9505
- Adds API endpoint with extra serial number information for a given StockItem object

View File

@ -365,6 +365,52 @@ def get_secret_key():
return key_data
def get_oidc_private_key():
"""Return the private key for OIDC authentication.
Following options are tested, in descending order of preference:
A) Check for environment variable INVENTREE_OIDC_PRIVATE_KEY or config yalue => Use raw key data
B) Check for environment variable INVENTREE_OIDC_PRIVATE_KEY_FILE or config value => Load key data from file
C) Create "oidc.pem" if it does not exist
"""
RSA_KEY = get_setting('INVENTREE_OIDC_PRIVATE_KEY', 'oidc_private_key')
if RSA_KEY:
logger.info('RSA_KEY loaded by INVENTREE_OIDC_PRIVATE_KEY') # pragma: no cover
return RSA_KEY
# Look for private key file
key_loc = Path(
get_setting(
'INVENTREE_OIDC_PRIVATE_KEY_FILE',
'oidc_private_key_file',
get_base_dir().joinpath('oidc.pem'),
)
)
if key_loc.exists():
return key_loc.read_text()
else:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
# Default location for private key file
logger.info("Generating oidc key file at '%s'", key_loc)
ensure_dir(key_loc.parent)
# Create a random key file
new_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
# Write our key to disk for safe keeping
with open(str(key_loc), 'wb') as f:
f.write(
new_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
)
RSA_KEY = key_loc.read_text()
return RSA_KEY
def get_custom_file(
env_ref: str, conf_ref: str, log_ref: str, lookup_media: bool = False
):

View File

@ -1,4 +1,4 @@
"""Check if there are any pending database migrations, and run them."""
"""Extended schema generator."""
from pathlib import Path
from typing import TypeVar

View File

@ -116,14 +116,7 @@ class InvenTreeMetadata(SimpleMetadata):
actions = {}
# Map the request method to a permission type
rolemap = {
'OPTIONS': 'view',
'GET': 'view',
'POST': 'add',
'PUT': 'change',
'PATCH': 'change',
'DELETE': 'delete',
}
rolemap = {**InvenTree.permissions.ACTION_MAP, 'OPTIONS': 'view'}
# let the view define a custom rolemap
if hasattr(view, 'rolemap'):

View File

@ -86,6 +86,11 @@ class AuthRequiredMiddleware:
response = self.get_response(request)
return response
# oAuth2 requests are handled by the oAuth2 library
if request.path_info.startswith('/o/'):
response = self.get_response(request)
return response
# Is the function exempt from auth requirements?
path_func = resolve(request.path).func

View File

@ -1,10 +1,29 @@
"""Permission set for InvenTree."""
from functools import wraps
from typing import Optional
from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements
from oauth2_provider.contrib.rest_framework.authentication import OAuth2Authentication
from rest_framework import permissions
import users.permissions
import users.ruleset
from users.oauth2_scopes import (
DEFAULT_READ,
DEFAULT_STAFF,
DEFAULT_SUPERUSER,
get_granular_scope,
)
ACTION_MAP = {
'GET': 'view',
'POST': 'add',
'PUT': 'change',
'PATCH': 'change',
'DELETE': 'delete',
'OPTIONS': DEFAULT_READ,
}
def get_model_for_view(view):
@ -21,7 +40,126 @@ def get_model_for_view(view):
raise AttributeError(f'Serializer class not specified for {view.__class__}')
class RolePermission(permissions.BasePermission):
def map_scope(
roles: Optional[list[str]] = None,
only_read=False,
read_name=DEFAULT_READ,
map_read: Optional[list[str]] = None,
map_read_name=DEFAULT_READ,
) -> dict:
"""Generate the required scopes for OAS permission views.
Args:
roles (Optional[list[str]]): A list of roles or tables to generate granular scopes for.
only_read (bool): If True, only the read scope will be returned for all actions.
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.
Returns:
dict: A dictionary mapping HTTP methods to their corresponding scopes.
Each scope is represented as a list of lists of strings.
"""
def scope_name(action):
if only_read:
return [[read_name]]
if roles:
return [[get_granular_scope(action, table) for table in roles]]
return [[action]]
def get_scope(method, action):
if map_read and method in map_read:
return [[map_read_name]]
return scope_name(action)
return {
method: get_scope(method, action) if method != 'OPTIONS' else [[DEFAULT_READ]]
for method, action in ACTION_MAP.items()
}
# Precalculate the roles mapping
roles = users.ruleset.get_ruleset_models()
precalculated_roles = {}
for role, tables in roles.items():
for table in tables:
if table not in precalculated_roles:
precalculated_roles[table] = []
precalculated_roles[table].append(role)
class OASTokenMixin:
"""Mixin that combines the permissions of normal classes and token classes."""
def has_permission(self, request, view):
"""Check if the user has the required scopes or was authenticated another way."""
return self.check_oauth2_authentication(
request, view
) or super().has_permission(request, view)
def check_oauth2_authentication(self, request, view):
"""Check if the user is authenticated using OAuth2 and has the required scopes."""
return self.is_oauth2ed(
request
) and TokenMatchesOASRequirements().has_permission(request, view)
def is_oauth2ed(self, request):
"""Check if the user is authenticated using OAuth2."""
oauth2authenticated = False
if bool(request.user and request.user.is_authenticated):
oauth2authenticated = isinstance(
request.successful_authenticator, OAuth2Authentication
)
return oauth2authenticated
class InvenTreeRoleScopeMixin(OASTokenMixin):
"""Permission that discovers the required scopes from the OpenAPI schema."""
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
if hasattr(view, 'required_alternate_scopes'):
return view.required_alternate_scopes
try:
# Extract the model name associated with this request
model = get_model_for_view(view)
calc = precalculated_roles.get(
f'{model._meta.app_label}_{model._meta.model_name}', []
)
if model is None or not calc:
return map_scope(only_read=True)
return map_scope(roles=calc)
except AttributeError:
# We will assume that if the serializer class does *not* have a Meta,
# then we don't need a permission
return map_scope(only_read=True)
except Exception:
return map_scope(only_read=True)
class InvenTreeTokenMatchesOASRequirements(InvenTreeRoleScopeMixin):
"""Combines InvenTree role-based scope handling with OpenAPI schema token requirements.
Usesd as default permission class.
"""
def has_permission(self, request, view):
"""Check if the user has the required scopes or was authenticated another way."""
if self.is_oauth2ed(request):
# Check if the user is authenticated using OAuth2 and has the required scopes
return super().has_permission(request, view)
# If the user is authenticated using another method, check if they have the required permissions
return bool(request.user and request.user.is_authenticated)
def has_object_permission(self, request, view, obj):
"""Return `True` if permission is granted, `False` otherwise."""
return True
class RolePermission(InvenTreeRoleScopeMixin, permissions.BasePermission):
"""Role mixin for API endpoints, allowing us to specify the user "role" which is required for certain operations.
Each endpoint can have one or more of the following actions:
@ -52,14 +190,7 @@ class RolePermission(permissions.BasePermission):
return True
# Map the request method to a permission type
rolemap = {
'GET': 'view',
'OPTIONS': 'view',
'POST': 'add',
'PUT': 'change',
'PATCH': 'change',
'DELETE': 'delete',
}
rolemap = {**ACTION_MAP, 'OPTIONS': 'view'}
# let the view define a custom rolemap
if hasattr(view, 'rolemap'):
@ -115,6 +246,13 @@ class RolePermissionOrReadOnly(RolePermission):
return request.method in permissions.SAFE_METHODS
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
scopes = map_scope(
only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS
)
return scopes
class StaffRolePermissionOrReadOnly(RolePermissionOrReadOnly):
"""RolePermission which requires staff AND role access, or read-only."""
@ -122,15 +260,19 @@ class StaffRolePermissionOrReadOnly(RolePermissionOrReadOnly):
REQUIRE_STAFF = True
class IsSuperuser(permissions.IsAdminUser):
class IsSuperuserOrSuperScope(OASTokenMixin, permissions.IsAdminUser):
"""Allows access only to superuser users."""
def has_permission(self, request, view):
"""Check if the user is a superuser."""
return bool(request.user and request.user.is_superuser)
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
return map_scope(only_read=True, read_name=DEFAULT_SUPERUSER)
class IsSuperuserOrReadOnly(permissions.IsAdminUser):
class IsSuperuserOrReadOnlyOrScope(OASTokenMixin, permissions.IsAdminUser):
"""Allow read-only access to any user, but write access is restricted to superuser users."""
def has_permission(self, request, view):
@ -140,17 +282,59 @@ class IsSuperuserOrReadOnly(permissions.IsAdminUser):
or request.method in permissions.SAFE_METHODS
)
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
return map_scope(
only_read=True,
read_name=DEFAULT_SUPERUSER,
map_read=permissions.SAFE_METHODS,
)
class IsStaffOrReadOnly(permissions.IsAdminUser):
"""Allows read-only access to any user, but write access is restricted to staff users."""
class IsAuthenticatedOrReadScope(OASTokenMixin, permissions.IsAuthenticated):
"""Allows access only to authenticated users or read scope tokens."""
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
return map_scope(only_read=True)
class IsStaffOrReadOnlyScope(OASTokenMixin, permissions.IsAuthenticated):
"""Allows read-only access to any authenticated user, but write access is restricted to staff users."""
def has_permission(self, request, view):
"""Check if the user is a superuser."""
return bool(
"""Check if the user is a staff."""
return bool(permissions.IsAuthenticated().has_permission(request, view)) and (
(request.user and request.user.is_staff)
or request.method in permissions.SAFE_METHODS
)
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
return map_scope(
only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS
)
class IsAdminOrAdminScope(OASTokenMixin, permissions.IsAdminUser):
"""Allows access only to admin users or admin scope tokens."""
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
return map_scope(only_read=True, read_name=DEFAULT_STAFF)
class AllowAnyOrReadScope(OASTokenMixin, permissions.AllowAny):
"""Allows access to any user or read scope tokens."""
def has_permission(self, request, view):
"""Anyone is allowed."""
return True
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
return map_scope(only_read=True)
def auth_exempt(view_func):
"""Mark a view function as being exempt from auth requirements."""
@ -160,3 +344,43 @@ def auth_exempt(view_func):
wrapped_view.auth_exempt = True
return wraps(view_func)(wrapped_view)
class UserSettingsPermissionsOrScope(OASTokenMixin, permissions.BasePermission):
"""Special permission class to determine if the user can view / edit a particular setting."""
def has_object_permission(self, request, view, obj):
"""Check if the user that requested is also the object owner."""
try:
user = request.user
except AttributeError: # pragma: no cover
return False
return user == obj.user
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
return map_scope(only_read=True)
class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission):
"""Special permission class to determine if the user is "staff"."""
def has_permission(self, request, view):
"""Check that the requesting user is 'admin'."""
try:
user = request.user
if request.method in permissions.SAFE_METHODS:
return True
# Any other methods require staff access permissions
return user.is_staff
except AttributeError: # pragma: no cover
return False
def get_required_alternate_scopes(self, request, view):
"""Return the required scopes for the current request."""
return map_scope(
only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS
)

View File

@ -3,10 +3,36 @@
from itertools import chain
from typing import Optional
from drf_spectacular.contrib.django_oauth_toolkit import DjangoOAuthToolkitScheme
from drf_spectacular.drainage import warn
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import ComponentRegistry
from drf_spectacular.utils import _SchemaType
from InvenTree.permissions import OASTokenMixin
from users.oauth2_scopes import oauth2_scopes
class ExtendedOAuth2Scheme(DjangoOAuthToolkitScheme):
"""Extend drf-spectacular to allow customizing the schema to match the actual API behavior."""
target_class = 'users.authentication.ExtendedOAuth2Authentication'
def get_security_requirement(self, auto_schema):
"""Get the security requirement for the current view."""
ret = super().get_security_requirement(auto_schema)
if ret:
return ret
# If no security requirement is found, try if the view uses our OASTokenMixin
for permission in auto_schema.view.get_permissions():
if isinstance(permission, OASTokenMixin):
alt_scopes = permission.get_required_alternate_scopes(
auto_schema.view.request, auto_schema.view
)
alt_scopes = alt_scopes.get(auto_schema.method, [])
return [{self.name: group} for group in alt_scopes]
class ExtendedAutoSchema(AutoSchema):
"""Extend drf-spectacular to allow customizing the schema to match the actual API behavior."""
@ -122,4 +148,9 @@ def postprocess_print_stats(result, generator, request, public):
print(f' {scope}: {len(paths)}')
print()
# Check for unknown scopes
for scope, paths in scopes.items():
if scope not in oauth2_scopes:
warn(f'unknown scope `{scope}` in {len(paths)} paths')
return result

View File

@ -25,10 +25,16 @@ from corsheaders.defaults import default_headers as default_cors_headers
from dotenv import load_dotenv
from InvenTree.cache import get_cache_config, is_global_cache_enabled
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
from InvenTree.config import (
get_boolean_setting,
get_custom_file,
get_oidc_private_key,
get_setting,
)
from InvenTree.ready import isInMainThread
from InvenTree.sentry import default_sentry_dsn, init_sentry
from InvenTree.version import checkMinPythonVersion, inventreeApiVersion
from users.oauth2_scopes import oauth2_scopes
from . import config, locales
@ -299,6 +305,7 @@ INSTALLED_APPS = [
'django_otp', # OTP is needed for MFA - base package
'django_otp.plugins.otp_totp', # Time based OTP
'django_otp.plugins.otp_static', # Backup codes
'oauth2_provider', # OAuth2 provider and API access
'drf_spectacular', # API documentation
'django_ical', # For exporting calendars
]
@ -322,6 +329,7 @@ MIDDLEWARE = CONFIG.get(
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'InvenTree.middleware.AuthRequiredMiddleware',
'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA
'oauth2_provider.middleware.OAuth2TokenMiddleware', # oauth2_provider
'maintenance_mode.middleware.MaintenanceModeMiddleware',
'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting
'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching
@ -354,6 +362,7 @@ QUERYCOUNT = {
AUTHENTICATION_BACKENDS = CONFIG.get(
'authentication_backends',
[
'oauth2_provider.backends.OAuth2Backend', # OAuth2 provider
'django.contrib.auth.backends.RemoteUserBackend', # proxy login
'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
@ -523,6 +532,15 @@ 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',
@ -530,12 +548,14 @@ REST_FRAMEWORK = {
'users.authentication.ApiTokenAuthentication',
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
'users.authentication.ExtendedOAuth2Authentication',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
'rest_framework.permissions.DjangoModelPermissions',
'InvenTree.permissions.RolePermission',
'InvenTree.permissions.InvenTreeTokenMatchesOASRequirements',
],
'DEFAULT_SCHEMA_CLASS': 'InvenTree.schema.ExtendedAutoSchema',
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
@ -1401,6 +1421,7 @@ FLAGS = {
'NEXT_GEN': [
{'condition': 'parameter', 'value': 'ngen='}
], # Should next-gen features be turned on?
'OIDC': [{'condition': 'parameter', 'value': 'oidc='}],
}
# Get custom flags from environment/yaml
@ -1445,6 +1466,11 @@ SPECTACULAR_SETTINGS = {
'UnauthorizedStatus': [[401, 401]],
'IsTrueEnum': [[True, True]],
},
# oAuth2
'OAUTH2_FLOWS': ['authorizationCode', 'clientCredentials'],
'OAUTH2_AUTHORIZATION_URL': '/o/authorize/',
'OAUTH2_TOKEN_URL': '/o/token/',
'OAUTH2_REFRESH_URL': '/o/revoke_token/',
}
if SITE_URL and not TESTING:

View File

@ -709,3 +709,13 @@ def email_user(user_id: int, subject: str, message: str) -> None:
if email := get_email_for_user(user):
send_email(subject, message, [email])
@scheduled_task(ScheduledTask.DAILY)
def run_oauth_maintenance():
"""Run the OAuth maintenance task(s)."""
from oauth2_provider.models import clear_expired
logger.info('Starting OAuth maintenance task')
clear_expired()
logger.info('Completed OAuth maintenance task')

View File

@ -1,4 +1,4 @@
"""Testts for custom InvenTree management commands."""
"""Tests for custom InvenTree management commands."""
from django.core.management import call_command
from django.test import TestCase
@ -9,5 +9,5 @@ class CommandTestCase(TestCase):
def test_schema(self):
"""Test the schema generation command."""
output = call_command('schema', verbosity=0)
output = call_command('schema', file='schema.yml', verbosity=0)
self.assertEqual(output, 'done')

View File

@ -12,6 +12,8 @@ from django.views.generic.base import RedirectView
from allauth.headless.urls import Client, build_urlpatterns
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
from flags.urls import flagged_path
from oauth2_provider import urls as oauth2_urls
from sesame.views import LoginView
import build.api
@ -125,6 +127,8 @@ backendpatterns = [
), # Used for (DRF) browsable API auth
path('auth/', auth_request), # Used for proxies to check if user is authenticated
path('accounts/', include('allauth.urls')),
# OAuth2
flagged_path('OIDC', 'o/', include(oauth2_urls)),
path(
'accounts/login/',
RedirectView.as_view(url=f'/{settings.FRONTEND_URL_BASE}', permanent=False),

View File

@ -20,9 +20,9 @@ from djmoney.contrib.exchange.models import ExchangeBackend, Rate
from drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error
from pint._typing import UnitLike
from rest_framework import permissions, serializers
from rest_framework import serializers
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
from rest_framework.permissions import IsAdminUser
from rest_framework.permissions import IsAdminUser, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
@ -44,7 +44,15 @@ from InvenTree.mixins import (
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
)
from InvenTree.permissions import IsStaffOrReadOnly, IsSuperuser
from InvenTree.permissions import (
AllowAnyOrReadScope,
GlobalSettingsPermissions,
IsAdminOrAdminScope,
IsAuthenticatedOrReadScope,
IsStaffOrReadOnlyScope,
IsSuperuserOrSuperScope,
UserSettingsPermissionsOrScope,
)
from plugin.models import NotificationUserSetting
from plugin.serializers import NotificationUserSettingSerializer
@ -134,7 +142,7 @@ class WebhookView(CsrfExemptMixin, APIView):
class CurrencyExchangeView(APIView):
"""API endpoint for displaying currency information."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
serializer_class = None
@extend_schema(responses={200: common.serializers.CurrencyExchangeSerializer})
@ -178,7 +186,7 @@ class CurrencyRefreshView(APIView):
User must be a 'staff' user to access this endpoint
"""
permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser]
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
serializer_class = None
def post(self, request, *args, **kwargs):
@ -208,6 +216,7 @@ class GlobalSettingsList(SettingsList):
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith='_')
serializer_class = common.serializers.GlobalSettingsSerializer
permission_classes = [IsAuthenticated, GlobalSettingsPermissions]
def list(self, request, *args, **kwargs):
"""Ensure all global settings are created."""
@ -215,23 +224,6 @@ class GlobalSettingsList(SettingsList):
return super().list(request, *args, **kwargs)
class GlobalSettingsPermissions(permissions.BasePermission):
"""Special permission class to determine if the user is "staff"."""
def has_permission(self, request, view):
"""Check that the requesting user is 'admin'."""
try:
user = request.user
if request.method in ['GET', 'HEAD', 'OPTIONS']:
return True
# Any other methods require staff access permissions
return user.is_staff
except AttributeError: # pragma: no cover
return False
class GlobalSettingsDetail(RetrieveUpdateAPI):
"""Detail view for an individual "global setting" object.
@ -241,6 +233,7 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
lookup_field = 'key'
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith='_')
serializer_class = common.serializers.GlobalSettingsSerializer
permission_classes = [IsAuthenticated, GlobalSettingsPermissions]
def get_object(self):
"""Attempt to find a global setting object with the provided key."""
@ -253,17 +246,13 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
key, cache=False, create=True
)
permission_classes = [permissions.IsAuthenticated, GlobalSettingsPermissions]
class UserSettingsList(SettingsList):
"""API endpoint for accessing a list of user settings objects."""
queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer
# Note: Any user can view and edit their own settings
permission_classes = [permissions.IsAuthenticated]
permission_classes = [UserSettingsPermissionsOrScope]
def list(self, request, *args, **kwargs):
"""Ensure all user settings are created."""
@ -291,19 +280,6 @@ class UserSettingsList(SettingsList):
return queryset
class UserSettingsPermissions(permissions.BasePermission):
"""Special permission class to determine if the user can view / edit a particular setting."""
def has_object_permission(self, request, view, obj):
"""Check if the user that requested is also the object owner."""
try:
user = request.user
except AttributeError: # pragma: no cover
return False
return user == obj.user
class UserSettingsDetail(RetrieveUpdateAPI):
"""Detail view for an individual "user setting" object.
@ -313,6 +289,7 @@ class UserSettingsDetail(RetrieveUpdateAPI):
lookup_field = 'key'
queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer
permission_classes = [UserSettingsPermissionsOrScope]
def get_object(self):
"""Attempt to find a user setting object with the provided key."""
@ -328,14 +305,13 @@ class UserSettingsDetail(RetrieveUpdateAPI):
key, user=self.request.user, cache=False, create=True
)
permission_classes = [UserSettingsPermissions]
class NotificationUserSettingsList(SettingsList):
"""API endpoint for accessing a list of notification user settings objects."""
queryset = NotificationUserSetting.objects.all()
serializer_class = NotificationUserSettingSerializer
permission_classes = [UserSettingsPermissionsOrScope]
def filter_queryset(self, queryset):
"""Only list settings which apply to the current user."""
@ -357,7 +333,7 @@ class NotificationUserSettingsDetail(RetrieveUpdateAPI):
queryset = NotificationUserSetting.objects.all()
serializer_class = NotificationUserSettingSerializer
permission_classes = [UserSettingsPermissions]
permission_classes = [UserSettingsPermissionsOrScope]
class NotificationMessageMixin:
@ -365,7 +341,7 @@ class NotificationMessageMixin:
queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationMessageSerializer
permission_classes = [UserSettingsPermissions]
permission_classes = [UserSettingsPermissionsOrScope]
def get_queryset(self):
"""Return prefetched queryset."""
@ -387,7 +363,7 @@ class NotificationMessageMixin:
class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
"""List view for all notifications of the current user."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER
@ -440,7 +416,7 @@ class NewsFeedMixin:
queryset = common.models.NewsFeedEntry.objects.all()
serializer_class = common.serializers.NewsFeedEntrySerializer
permission_classes = [IsAdminUser]
permission_classes = [IsAdminOrAdminScope]
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
@ -464,7 +440,7 @@ class ConfigList(ListAPI):
queryset = CONFIG_LOOKUPS
serializer_class = common.serializers.ConfigSerializer
permission_classes = [IsSuperuser]
permission_classes = [IsSuperuserOrSuperScope]
# Specifically disable pagination for this view
pagination_class = None
@ -474,7 +450,7 @@ class ConfigDetail(RetrieveAPI):
"""Detail view for an individual configuration."""
serializer_class = common.serializers.ConfigSerializer
permission_classes = [IsSuperuser]
permission_classes = [IsSuperuserOrSuperScope]
def get_object(self):
"""Attempt to find a config object with the provided key."""
@ -490,7 +466,7 @@ class NotesImageList(ListCreateAPI):
queryset = common.models.NotesImage.objects.all()
serializer_class = common.serializers.NotesImageSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER
@ -508,7 +484,7 @@ class ProjectCodeList(DataExportViewMixin, ListCreateAPI):
queryset = common.models.ProjectCode.objects.all()
serializer_class = common.serializers.ProjectCodeSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
permission_classes = [IsStaffOrReadOnlyScope]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['code']
@ -521,7 +497,7 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
queryset = common.models.ProjectCode.objects.all()
serializer_class = common.serializers.ProjectCodeSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
permission_classes = [IsStaffOrReadOnlyScope]
class CustomUnitList(DataExportViewMixin, ListCreateAPI):
@ -529,7 +505,7 @@ class CustomUnitList(DataExportViewMixin, ListCreateAPI):
queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
permission_classes = [IsStaffOrReadOnlyScope]
filter_backends = SEARCH_ORDER_FILTER
@ -538,14 +514,14 @@ class CustomUnitDetail(RetrieveUpdateDestroyAPI):
queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
permission_classes = [IsStaffOrReadOnlyScope]
class AllUnitList(RetrieveAPI):
"""List of all defined units."""
serializer_class = common.serializers.AllUnitListResponseSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
permission_classes = [IsStaffOrReadOnlyScope]
def get(self, request, *args, **kwargs):
"""Return a list of all available units."""
@ -576,7 +552,7 @@ class ErrorMessageList(BulkDeleteMixin, ListAPI):
queryset = Error.objects.all()
serializer_class = common.serializers.ErrorMessageSerializer
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
filter_backends = SEARCH_ORDER_FILTER
@ -592,13 +568,13 @@ class ErrorMessageDetail(RetrieveUpdateDestroyAPI):
queryset = Error.objects.all()
serializer_class = common.serializers.ErrorMessageSerializer
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
class BackgroundTaskOverview(APIView):
"""Provides an overview of the background task queue status."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
serializer_class = None
def get(self, request, fmt=None):
@ -620,7 +596,7 @@ class BackgroundTaskOverview(APIView):
class PendingTaskList(BulkDeleteMixin, ListAPI):
"""Provides a read-only list of currently pending tasks."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
queryset = django_q.models.OrmQ.objects.all()
serializer_class = common.serializers.PendingTaskSerializer
@ -629,7 +605,7 @@ class PendingTaskList(BulkDeleteMixin, ListAPI):
class ScheduledTaskList(ListAPI):
"""Provides a read-only list of currently scheduled tasks."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
queryset = django_q.models.Schedule.objects.all()
serializer_class = common.serializers.ScheduledTaskSerializer
@ -649,7 +625,7 @@ class ScheduledTaskList(ListAPI):
class FailedTaskList(BulkDeleteMixin, ListAPI):
"""Provides a read-only list of currently failed tasks."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
queryset = django_q.models.Failure.objects.all()
serializer_class = common.serializers.FailedTaskSerializer
@ -666,14 +642,14 @@ class FlagList(ListAPI):
queryset = settings.FLAGS
serializer_class = common.serializers.FlagSerializer
permission_classes = [permissions.AllowAny]
permission_classes = [AllowAnyOrReadScope]
class FlagDetail(RetrieveAPI):
"""Detail view for an individual feature flag."""
serializer_class = common.serializers.FlagSerializer
permission_classes = [permissions.AllowAny]
permission_classes = [AllowAnyOrReadScope]
def get_object(self):
"""Attempt to find a config object with the provided key."""
@ -689,7 +665,7 @@ class ContentTypeList(ListAPI):
queryset = ContentType.objects.all()
serializer_class = common.serializers.ContentTypeSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['app_label', 'model']
@ -699,7 +675,7 @@ class ContentTypeDetail(RetrieveAPI):
queryset = ContentType.objects.all()
serializer_class = common.serializers.ContentTypeSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
class ContentTypeModelDetail(ContentTypeDetail):
@ -753,7 +729,7 @@ class AttachmentList(BulkDeleteMixin, ListCreateAPI):
queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER
filterset_class = AttachmentFilter
@ -791,7 +767,7 @@ class AttachmentDetail(RetrieveUpdateDestroyAPI):
queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
def destroy(self, request, *args, **kwargs):
"""Check user permissions before deleting an attachment."""
@ -810,7 +786,7 @@ class IconList(ListAPI):
"""List view for available icon packages."""
serializer_class = common.serializers.IconPackageSerializer
permission_classes = [permissions.AllowAny]
permission_classes = [AllowAnyOrReadScope]
def get_queryset(self):
"""Return a list of all available icon packages."""
@ -822,7 +798,7 @@ class SelectionListList(ListCreateAPI):
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self):
"""Override the queryset method to include entry count."""
@ -834,7 +810,7 @@ class SelectionListDetail(RetrieveUpdateDestroyAPI):
queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
class EntryMixin:
@ -842,7 +818,7 @@ class EntryMixin:
queryset = common.models.SelectionListEntry.objects.all()
serializer_class = common.serializers.SelectionEntrySerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
lookup_url_kwarg = 'entrypk'
def get_queryset(self):
@ -861,22 +837,22 @@ class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a SelectionEntry object."""
class DataOutputEndpoint:
class DataOutputEndpointMixin:
"""Mixin class for DataOutput endpoints."""
queryset = common.models.DataOutput.objects.all()
serializer_class = common.serializers.DataOutputSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [IsAuthenticatedOrReadScope]
class DataOutputList(DataOutputEndpoint, BulkDeleteMixin, ListAPI):
class DataOutputList(DataOutputEndpointMixin, BulkDeleteMixin, ListAPI):
"""List view for DataOutput objects."""
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
class DataOutputDetail(DataOutputEndpoint, RetrieveAPI):
class DataOutputDetail(DataOutputEndpointMixin, RetrieveAPI):
"""Detail view for a DataOutput object."""
@ -1014,7 +990,10 @@ common_api_urls = [
include([
path(
'metadata/',
MetadataView.as_view(model=common.models.ProjectCode),
MetadataView.as_view(
model=common.models.ProjectCode,
permission_classes=[IsStaffOrReadOnlyScope],
),
name='api-project-code-metadata',
),
path(

View File

@ -1173,7 +1173,7 @@ class CommonTest(InvenTreeAPITestCase):
"""Test flag URLs."""
# Not superuser
response = self.get(reverse('api-flag-list'), expected_code=200)
self.assertEqual(len(response.data), 2)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['key'], 'EXPERIMENTAL')
# Turn into superuser
@ -1182,7 +1182,7 @@ class CommonTest(InvenTreeAPITestCase):
# Successful checks
response = self.get(reverse('api-flag-list'), expected_code=200)
self.assertEqual(len(response.data), 2)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['key'], 'EXPERIMENTAL')
self.assertTrue(response.data[0]['conditions'])

View File

@ -5,6 +5,9 @@
# Secret key for backend
# Use the environment variable INVENTREE_SECRET_KEY_FILE
#secret_key_file: '/etc/inventree/secret_key.txt'
# Use the environment variable INVENTREE_OIDC_PRIVATE_KEY or INVENTREE_OIDC_PRIVATE_KEY_FILE
#oidc_private_key: '-----BEGIN PRIVATE KEY-----aaa'
#oidc_private_key_file: '/etc/inventree/oidc.key'
# Database backend selection - Configure backend database settings
# Documentation: https://docs.inventree.org/en/latest/start/config/

View File

@ -5,16 +5,16 @@ import inspect
from django.urls import include, path
from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework import permissions, serializers
from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
import common.models
import common.serializers
import InvenTree.permissions
from data_exporter.mixins import DataExportViewMixin
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
from InvenTree.permissions import IsStaffOrReadOnly
from InvenTree.serializers import EmptySerializer
from .serializers import GenericStateClassSerializer
@ -36,7 +36,7 @@ class StatusView(GenericAPIView):
all available 'StockStatus' codes
"""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = GenericStateClassSerializer
# Override status_class for implementing subclass
@ -96,7 +96,7 @@ class StatusView(GenericAPIView):
class AllStatusViews(StatusView):
"""Endpoint for listing all defined status models."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = EmptySerializer
@extend_schema(operation_id='generic_status_retrieve_all')
@ -136,7 +136,7 @@ class CustomStateList(DataExportViewMixin, ListCreateAPI):
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
serializer_class = common.serializers.CustomStateSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['key']
search_fields = ['key', 'name', 'label', 'reference_status']
@ -148,7 +148,7 @@ class CustomStateDetail(RetrieveUpdateDestroyAPI):
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
serializer_class = common.serializers.CustomStateSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
urlpattern = [

View File

@ -12,6 +12,7 @@ from rest_framework.views import APIView
import importer.models
import importer.registry
import importer.serializers
import InvenTree.permissions
from InvenTree.api import BulkDeleteMixin
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import (
@ -28,7 +29,7 @@ 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 permissions.IsAuthenticated."""
"""Class level permission checks are handled via InvenTree.permissions.IsAuthenticatedOrReadScope."""
return True
def has_object_permission(self, request, view, obj):
@ -53,7 +54,10 @@ class DataImporterPermissionMixin:
"""Mixin class for checking permissions on DataImporter objects."""
# Default permissions: User must be authenticated
permission_classes = [permissions.IsAuthenticated, DataImporterPermission]
permission_classes = [
InvenTree.permissions.IsAuthenticatedOrReadScope,
DataImporterPermission,
]
class DataImporterModelSerializer(serializers.Serializer):
@ -67,7 +71,7 @@ class DataImporterModelSerializer(serializers.Serializer):
class DataImporterModelList(APIView):
"""API endpoint for displaying a list of models available for import."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = DataImporterModelSerializer(many=True)
def get(self, request):
@ -110,7 +114,7 @@ class DataImportSessionDetail(DataImporterPermission, RetrieveUpdateDestroyAPI):
class DataImportSessionAcceptFields(APIView):
"""API endpoint to accept the field mapping for a DataImportSession."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = None
@extend_schema(

View File

@ -3,11 +3,11 @@
from django.urls import include, path, re_path
from drf_spectacular.utils import extend_schema
from rest_framework import permissions
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from rest_framework.views import APIView
import InvenTree.permissions
import machine.serializers as MachineSerializers
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI
@ -78,7 +78,7 @@ class MachineSettingList(APIView):
- GET: return all settings for a machine config
"""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
@extend_schema(
responses={200: MachineSerializers.MachineSettingSerializer(many=True)}
@ -142,7 +142,7 @@ class MachineRestart(APIView):
- POST: restart machine by pk
"""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
@extend_schema(
request=None, responses={200: MachineSerializers.MachineRestartSerializer()}
@ -162,7 +162,7 @@ class MachineTypesList(APIView):
- GET: List all machine types
"""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
@extend_schema(responses={200: MachineSerializers.MachineTypeSerializer(many=True)})
def get(self, request):
@ -180,7 +180,7 @@ class MachineDriverList(APIView):
- GET: List all machine drivers
"""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
@extend_schema(
responses={200: MachineSerializers.MachineDriverSerializer(many=True)}
@ -203,7 +203,7 @@ class RegistryStatusView(APIView):
- GET: Provide status data for the machine registry
"""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = MachineSerializers.MachineRegistryStatusSerializer

View File

@ -12,10 +12,11 @@ from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import permissions, serializers
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
import InvenTree.permissions
import order.models
import part.filters
from build.models import Build, BuildItem
@ -41,7 +42,6 @@ from InvenTree.mixins import (
RetrieveUpdateDestroyAPI,
UpdateAPI,
)
from InvenTree.permissions import RolePermission
from InvenTree.serializers import EmptySerializer
from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
from stock.models import StockLocation
@ -1738,7 +1738,10 @@ class PartStocktakeReportGenerate(CreateAPI):
serializer_class = part_serializers.PartStocktakeReportGenerateSerializer
permission_classes = [permissions.IsAuthenticated, RolePermission]
permission_classes = [
InvenTree.permissions.IsAuthenticatedOrReadScope,
InvenTree.permissions.RolePermission,
]
role_required = 'stocktake'

View File

@ -9,13 +9,13 @@ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema
from rest_framework import permissions, status
from rest_framework import status
from rest_framework.exceptions import NotFound
from rest_framework.response import Response
from rest_framework.views import APIView
import InvenTree.permissions
import plugin.serializers as PluginSerializers
from common.api import GlobalSettingsPermissions
from InvenTree.api import MetadataView
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.helpers import str2bool
@ -27,7 +27,6 @@ from InvenTree.mixins import (
RetrieveUpdateAPI,
UpdateAPI,
)
from InvenTree.permissions import IsSuperuser, IsSuperuserOrReadOnly
from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.locate.api import LocatePluginView
@ -138,7 +137,7 @@ class PluginList(ListAPI):
# Allow any logged in user to read this endpoint
# This is necessary to allow certain functionality,
# e.g. determining which label printing plugins are available
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
filterset_class = PluginFilter
@ -169,7 +168,7 @@ class PluginDetail(RetrieveDestroyAPI):
queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginConfigSerializer
permission_classes = [IsSuperuserOrReadOnly]
permission_classes = [InvenTree.permissions.IsSuperuserOrReadOnlyOrScope]
lookup_field = 'key'
lookup_url_kwarg = 'plugin'
@ -228,7 +227,7 @@ class PluginUninstall(UpdateAPI):
queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginUninstallSerializer
permission_classes = [IsSuperuser]
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
lookup_field = 'key'
lookup_url_kwarg = 'plugin'
@ -249,7 +248,7 @@ class PluginActivate(UpdateAPI):
queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginActivateSerializer
permission_classes = [IsSuperuser]
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
lookup_field = 'key'
lookup_url_kwarg = 'plugin'
@ -269,7 +268,7 @@ class PluginReload(CreateAPI):
queryset = PluginConfig.objects.none()
serializer_class = PluginSerializers.PluginReloadSerializer
permission_classes = [IsSuperuser]
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
def perform_create(self, serializer):
"""Saving the serializer instance performs plugin installation."""
@ -286,7 +285,7 @@ class PluginSettingList(ListAPI):
queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer
permission_classes = [GlobalSettingsPermissions]
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
filter_backends = [DjangoFilterBackend]
@ -359,7 +358,7 @@ class PluginAllSettingList(APIView):
- GET: return all settings for a plugin config
"""
permission_classes = [GlobalSettingsPermissions]
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
@extend_schema(
responses={200: PluginSerializers.PluginSettingSerializer(many=True)}
@ -413,7 +412,7 @@ class PluginSettingDetail(RetrieveUpdateAPI):
)
# Staff permission required
permission_classes = [GlobalSettingsPermissions]
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
class RegistryStatusView(APIView):
@ -422,7 +421,7 @@ class RegistryStatusView(APIView):
- GET: Provide status data for the plugin registry
"""
permission_classes = [IsSuperuser]
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
serializer_class = PluginSerializers.PluginRegistryStatusSerializer

View File

@ -2,10 +2,11 @@
from django.utils.translation import gettext_lazy as _
from rest_framework import permissions, serializers
from rest_framework import serializers
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
import InvenTree.permissions
from InvenTree.exceptions import log_error
from plugin import PluginMixinEnum, registry
@ -20,7 +21,7 @@ class ActionPluginSerializer(serializers.Serializer):
class ActionPluginView(GenericAPIView):
"""Endpoint for running custom action plugins."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = ActionPluginSerializer
def post(self, request, *args, **kwargs):

View File

@ -7,12 +7,13 @@ from django.utils.translation import gettext_lazy as _
import structlog
from django_filters import rest_framework as rest_filters
from drf_spectacular.utils import extend_schema, extend_schema_view
from rest_framework import permissions, status
from rest_framework import status
from rest_framework.exceptions import PermissionDenied, ValidationError
from rest_framework.generics import CreateAPIView
from rest_framework.response import Response
import common.models
import InvenTree.permissions
import order.models
import plugin.base.barcodes.helper
import stock.models
@ -22,7 +23,6 @@ from InvenTree.exceptions import log_error
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.helpers import hash_barcode
from InvenTree.mixins import ListAPI, RetrieveDestroyAPI
from InvenTree.permissions import IsStaffOrReadOnly
from plugin import PluginMixinEnum, registry
from users.permissions import check_user_permission
@ -107,7 +107,7 @@ class BarcodeView(CreateAPIView):
return None
# Default permission classes (can be overridden)
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
def create(self, request, *args, **kwargs):
"""Handle create method - override default create."""
@ -236,7 +236,7 @@ class BarcodeGenerate(CreateAPIView):
return None
# Default permission classes (can be overridden)
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
def create(self, request, *args, **kwargs):
"""Perform the barcode generation action."""
@ -758,7 +758,7 @@ class BarcodeScanResultMixin:
queryset = common.models.BarcodeScanResult.objects.all()
serializer_class = barcode_serializers.BarcodeScanResultSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
def get_queryset(self):
"""Return the queryset for the BarcodeScan API."""

View File

@ -1,10 +1,11 @@
"""API for location plugins."""
from rest_framework import permissions, serializers
from rest_framework import serializers
from rest_framework.exceptions import NotFound, ParseError, ValidationError
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
import InvenTree.permissions
from InvenTree.exceptions import log_error
from InvenTree.tasks import offload_task
from plugin import PluginMixinEnum, registry
@ -27,7 +28,7 @@ class LocatePluginSerializer(serializers.Serializer):
class LocatePluginView(GenericAPIView):
"""Endpoint for using a custom plugin to identify or 'locate' a stock item or location."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = LocatePluginSerializer
def post(self, request, *args, **kwargs):

View File

@ -3,10 +3,10 @@
from django.urls import path
from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
import InvenTree.permissions
import plugin.base.ui.serializers as UIPluginSerializers
from common.settings import get_global_setting
from InvenTree.exceptions import log_error
@ -16,7 +16,7 @@ from plugin import PluginMixinEnum, registry
class PluginUIFeatureList(APIView):
"""API endpoint for listing all available plugin ui features."""
permission_classes = [IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = UIPluginSerializers.PluginUIFeatureSerializer
@extend_schema(

View File

@ -8,7 +8,6 @@ from django.views.decorators.cache import never_cache
from django_filters import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import permissions
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
@ -30,10 +29,7 @@ class TemplatePermissionMixin:
"""Permission mixin for report and label templates."""
# Read only for non-staff users
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.IsStaffOrReadOnly,
]
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
class ReportFilterBase(rest_filters.FilterSet):
@ -100,7 +96,7 @@ class LabelPrint(GenericAPIView):
"""API endpoint for printing labels."""
# Any authenticated user can print labels
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = report.serializers.LabelPrintSerializer
def get_plugin_class(self, plugin_slug: str, raise_error=False):
@ -253,7 +249,7 @@ class ReportPrint(GenericAPIView):
"""API endpoint for printing reports."""
# Any authenticated user can print reports
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = report.serializers.ReportPrintSerializer
@method_decorator(never_cache)

View File

@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as rest_filters
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import permissions, status
from rest_framework import status
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
@ -20,6 +20,7 @@ from rest_framework.serializers import ValidationError
import common.models
import common.settings
import InvenTree.helpers
import InvenTree.permissions
import stock.serializers as StockSerializers
from build.models import Build
from build.serializers import BuildSerializer
@ -65,7 +66,7 @@ from stock.status_codes import StockHistoryCode, StockStatus
class GenerateBatchCode(GenericAPIView):
"""API endpoint for generating batch codes."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = StockSerializers.GenerateBatchCodeSerializer
def post(self, request, *args, **kwargs):
@ -81,7 +82,7 @@ class GenerateBatchCode(GenericAPIView):
class GenerateSerialNumber(GenericAPIView):
"""API endpoint for generating serial numbers."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = StockSerializers.GenerateSerialNumberSerializer
def post(self, request, *args, **kwargs):

View File

@ -10,9 +10,8 @@ from django.views.generic.base import RedirectView
import structlog
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
from rest_framework import exceptions, permissions
from rest_framework import exceptions
from rest_framework.generics import DestroyAPIView, GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
import InvenTree.helpers
@ -51,6 +50,7 @@ class OwnerList(ListAPI):
queryset = Owner.objects.all()
serializer_class = OwnerSerializer
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
def filter_queryset(self, queryset):
"""Implement text search for the "owner" model.
@ -114,12 +114,13 @@ class OwnerDetail(RetrieveAPI):
queryset = Owner.objects.all()
serializer_class = OwnerSerializer
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
class RoleDetails(RetrieveAPI):
"""API endpoint which lists the available role permissions for the current user."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = RoleSerializer
def get_object(self):
@ -149,7 +150,7 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail):
"""
serializer_class = MeUserSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'}
@ -206,7 +207,7 @@ class GroupMixin:
queryset = Group.objects.all()
serializer_class = GroupSerializer
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
def get_serializer(self, *args, **kwargs):
"""Return serializer instance for this endpoint."""
@ -254,7 +255,7 @@ class RuleSetMixin:
queryset = RuleSet.objects.all()
serializer_class = RuleSetSerializer
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
class RuleSetList(RuleSetMixin, ListAPI):
@ -274,7 +275,7 @@ class RuleSetDetail(RuleSetMixin, RetrieveUpdateDestroyAPI):
class GetAuthToken(GenericAPIView):
"""Return authentication token for an authenticated user."""
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = GetAuthTokenSerializer
@extend_schema(
@ -343,7 +344,7 @@ class GetAuthToken(GenericAPIView):
class TokenMixin:
"""Mixin for API token endpoints."""
permission_classes = (IsAuthenticated,)
permission_classes = (InvenTree.permissions.IsAuthenticatedOrReadScope,)
serializer_class = ApiTokenSerializer
def get_queryset(self):
@ -426,7 +427,7 @@ class UserProfileDetail(RetrieveUpdateAPI):
queryset = UserProfile.objects.all()
serializer_class = UserProfileSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
def get_object(self):
"""Return the profile of the current user."""

View File

@ -4,6 +4,7 @@ import datetime
from django.utils.translation import gettext_lazy as _
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
@ -37,3 +38,7 @@ class ApiTokenAuthentication(TokenAuthentication):
token.save()
return (user, token)
class ExtendedOAuth2Authentication(OAuth2Authentication):
"""Custom implementation of OAuth2Authentication class to support custom scope rendering."""

View File

@ -0,0 +1,44 @@
"""Static scope definitions for OAuth2 scopes."""
def get_granular_scope(method, role=None, type='r'):
"""Generate a granular scope string for a given method and role."""
if role:
return f'{type}:{method}:{role}'
return f'{type}:{method}'
# region generated stuff
_roles = {
'admin': 'Role Admin',
'part_category': 'Role Part Categories',
'part': 'Role Parts',
'stocktake': 'Role Stocktake',
'stock_location': 'Role Stock Locations',
'stock': 'Role Stock Items',
'build': 'Role Build Orders',
'purchase_order': 'Role Purchase Orders',
'sales_order': 'Role Sales Orders',
'return_order': 'Role Return Orders',
}
_methods = {'view': 'GET', 'add': 'POST', 'change': 'PUT / PATCH', 'delete': 'DELETE'}
calculated = {
get_granular_scope(method[0], role[0]): f'{method[1]} for {role[1]}'
for method in _methods.items()
for role in _roles.items()
}
# endregion
DEFAULT_READ = get_granular_scope('read', type='g')
DEFAULT_STAFF = get_granular_scope('staff', type='a')
DEFAULT_SUPERUSER = get_granular_scope('superuser', type='a')
# This is actually used
oauth2_scopes = {
DEFAULT_READ: 'General Read scope',
'openid': 'OpenID Connect scope',
# Admin scopes
DEFAULT_STAFF: 'User Role Staff',
DEFAULT_SUPERUSER: 'User Role Superuser',
} | calculated

View File

@ -74,9 +74,17 @@ def get_ruleset_models() -> dict:
'otp_static_statictoken',
'otp_static_staticdevice',
'mfa_authenticator',
# Oauth
'oauth2_provider_application',
'oauth2_provider_grant',
'oauth2_provider_idtoken',
'oauth2_provider_accesstoken',
'oauth2_provider_refreshtoken',
# Plugins
'plugin_pluginconfig',
'plugin_pluginsetting',
'plugin_notificationusersetting',
# Misc
'common_barcodescanresult',
'common_newsfeedentry',
'taggit_tag',

View File

@ -8,6 +8,7 @@ from django.urls import reverse
from common.settings import set_global_setting
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
from users.models import ApiToken, Owner
from users.oauth2_scopes import _roles
from users.ruleset import (
RULESET_CHOICES,
RULESET_NAMES,
@ -15,20 +16,24 @@ from users.ruleset import (
get_ruleset_models,
)
G_RULESETS = get_ruleset_models()
G_RULESETS_IG = get_ruleset_ignore()
G_SCOPES = _roles.keys()
class RuleSetModelTest(TestCase):
"""Some simplistic tests to ensure the RuleSet model is setup correctly."""
def test_ruleset_models(self):
"""Test that the role rulesets work as intended."""
keys = get_ruleset_models().keys()
keys = G_RULESETS.keys()
# Check if there are any rulesets which do not have models defined
missing = [name for name in RULESET_NAMES if name not in keys]
if len(missing) > 0: # pragma: no cover
print('The following rulesets do not have models assigned:')
print('INVE-E5: The following rulesets do not have models assigned:')
for m in missing:
print('-', m)
@ -37,22 +42,24 @@ class RuleSetModelTest(TestCase):
if len(extra) > 0: # pragma: no cover
print(
'The following rulesets have been improperly added to get_ruleset_models():'
'INVE-E5: The following rulesets have been improperly added to get_ruleset_models():'
)
for e in extra:
print('-', e)
# Check that each ruleset has models assigned
empty = [key for key in keys if len(get_ruleset_models()[key]) == 0]
empty = [key for key in keys if len(G_RULESETS[key]) == 0]
if len(empty) > 0: # pragma: no cover
print('The following rulesets have empty entries in get_ruleset_models():')
print(
'INVE-E5: The following rulesets have empty entries in get_ruleset_models():'
)
for e in empty:
print('-', e)
self.assertEqual(len(missing), 0)
self.assertEqual(len(extra), 0)
self.assertEqual(len(empty), 0)
self.assertEqual(len(missing), 0, 'See INVE-E5 in the docs')
self.assertEqual(len(extra), 0, 'See INVE-E5 in the docs')
self.assertEqual(len(empty), 0, 'See INVE-E5 in the docs')
def test_model_names(self):
"""Test that each model defined in the rulesets is valid, based on the database schema!"""
@ -69,7 +76,9 @@ class RuleSetModelTest(TestCase):
assigned_models = set()
# Now check that each defined model is a valid table name
for models in get_ruleset_models().values():
for key in G_RULESETS:
models = G_RULESETS[key]
for m in models:
assigned_models.add(m)
@ -77,13 +86,13 @@ class RuleSetModelTest(TestCase):
for model in available_tables:
if (
model not in assigned_models and model not in get_ruleset_ignore()
model not in assigned_models and model not in G_RULESETS_IG
): # pragma: no cover
missing_models.add(model)
if len(missing_models) > 0: # pragma: no cover
print(
'The following database models are not covered by the defined RuleSet permissions:'
'INVE-E5: The following database models are not covered by the defined RuleSet permissions:'
)
for m in missing_models:
print('-', m)
@ -95,7 +104,7 @@ class RuleSetModelTest(TestCase):
for model in assigned_models:
defined_models.add(model)
for model in get_ruleset_ignore():
for model in G_RULESETS_IG:
defined_models.add(model)
for model in defined_models: # pragma: no cover
@ -103,12 +112,35 @@ class RuleSetModelTest(TestCase):
extra_models.add(model)
if len(extra_models) > 0: # pragma: no cover
print('The following RuleSet permissions do not match a database model:')
print(
'INVE-E5: The following RuleSet permissions do not match a database model:'
)
for m in extra_models:
print('-', m)
self.assertEqual(len(missing_models), 0)
self.assertEqual(len(extra_models), 0)
self.assertEqual(len(missing_models), 0, 'See INVE-E5 in the docs')
self.assertEqual(len(extra_models), 0, 'See INVE-E5 in the docs')
def test_scope_names(self):
"""Ensure that the rulesets map to scopes and vice versa."""
# Check that each scope has a corresponding ruleset
missing = [scope for scope in G_SCOPES if scope not in G_RULESETS]
if len(missing) > 0: # pragma: no cover
print('INVE-E6: The following scopes do not have corresponding rulesets:')
for m in missing:
print('-', m)
# Check that each ruleset has a corresponding scope
extra = [scope for scope in G_RULESETS if scope not in G_SCOPES]
if len(extra) > 0: # pragma: no cover
print('INVE-E6: The following rulesets do not have corresponding scopes:')
for m in extra:
print('-', m)
self.assertEqual(len(missing), 0, 'See INVE-E6 in the docs')
self.assertEqual(len(extra), 0, 'See INVE-E6 in the docs')
def test_permission_assign(self):
"""Test that the permission assigning works!"""
@ -123,7 +155,7 @@ class RuleSetModelTest(TestCase):
# Check that all permissions have been assigned permissions?
permission_set = set()
for models in get_ruleset_models().values():
for models in G_RULESETS.values():
for model in models:
permission_set.add(model)

View File

@ -26,6 +26,7 @@ django-structlog # Structured logging
django-stdimage # Advanced ImageField management
django-taggit # Tagging support
django-otp==1.3.0 # Two-factor authentication (legacy to ensure migrations) https://github.com/inventree/InvenTree/pull/6293
django-oauth-toolkit # OAuth2 provider
djangorestframework # DRF framework
djangorestframework-simplejwt[crypto] # JWT authentication
django-xforwardedfor-middleware # IP forwarding metadata

View File

@ -372,6 +372,7 @@ cryptography==44.0.2 \
# -r src/backend/requirements.in
# djangorestframework-simplejwt
# fido2
# jwcrypto
# pyjwt
cssselect2==0.8.0 \
--hash=sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e \
@ -404,6 +405,7 @@ django==4.2.20 \
# django-js-asset
# django-markdownify
# django-money
# django-oauth-toolkit
# django-otp
# django-picklefield
# django-q2
@ -474,6 +476,10 @@ django-mptt==0.17.0 \
--hash=sha256:e2dca00536450b91bdc80d8fc1454993b84307728394ae42c72947fc09574d3d \
--hash=sha256:e77dd4b4d0de14f50239b0900def44c1d31403664ab3682b80b27fa1319fe7f0
# via -r src/backend/requirements.in
django-oauth-toolkit==3.0.1 \
--hash=sha256:3ef00b062a284f2031b0732b32dc899e3bbf0eac221bbb1cffcb50b8932e55ed \
--hash=sha256:7200e4a9fb229b145a6d808cbf0423b6d69a87f68557437733eec3c0cf71db02
# via -r src/backend/requirements.in
django-otp==1.3.0 \
--hash=sha256:5277731bc05b6cdbf96aa84ac46018e30ed5fb248086053b0146f925de059060 \
--hash=sha256:8f4156a3c14ce2aaa31379385eadf388925cd50fc4b5d20a3b944f454c98ff7c
@ -771,6 +777,10 @@ jsonschema-specifications==2024.10.1 \
--hash=sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272 \
--hash=sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf
# via jsonschema
jwcrypto==1.5.6 \
--hash=sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789 \
--hash=sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039
# via django-oauth-toolkit
lxml==5.3.0 \
--hash=sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e \
--hash=sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229 \
@ -983,7 +993,9 @@ markupsafe==3.0.2 \
oauthlib==3.2.2 \
--hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \
--hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918
# via requests-oauthlib
# via
# django-oauth-toolkit
# requests-oauthlib
openpyxl==3.1.5 \
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
@ -1446,6 +1458,7 @@ requests==2.32.3 \
# via
# coreapi
# django-allauth
# django-oauth-toolkit
# opentelemetry-exporter-otlp-proto-http
# requests-oauthlib
requests-oauthlib==2.0.0 \
@ -1645,6 +1658,7 @@ typing-extensions==4.12.2 \
# drf-spectacular
# flexcache
# flexparser
# jwcrypto
# opentelemetry-sdk
# pint
# py-moneyed