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:
@ -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):
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
):
|
||||
|
@ -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
|
||||
|
@ -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'):
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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')
|
||||
|
@ -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')
|
||||
|
@ -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),
|
||||
|
@ -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(
|
||||
|
@ -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'])
|
||||
|
||||
|
@ -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/
|
||||
|
@ -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 = [
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
||||
|
@ -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'
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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."""
|
||||
|
@ -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):
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
|
@ -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."""
|
||||
|
@ -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."""
|
||||
|
44
src/backend/InvenTree/users/oauth2_scopes.py
Normal file
44
src/backend/InvenTree/users/oauth2_scopes.py
Normal 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
|
@ -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',
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user