2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-26 18:46:44 +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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 718 additions and 204 deletions

View File

@ -120,6 +120,7 @@ jobs:
test -f data/config.yaml
test -f data/plugins.txt
test -f data/secret_key.txt
test -f data/oidc.pem
- name: Run Unit Tests
run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env

1
.gitignore vendored
View File

@ -57,6 +57,7 @@ data.json
# Key file
secret_key.txt
oidc.pem
# IDE / development files
.idea/

1
.vscode/launch.json vendored
View File

@ -60,6 +60,7 @@
"schema",
"--file","src/frontend/schema.yml"
],
"django": true,
"justMyCode": false
},
{

View File

@ -37,6 +37,7 @@ ENV INVENTREE_BACKEND_DIR="${INVENTREE_HOME}/src/backend"
# InvenTree configuration files
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
ENV INVENTREE_OIDC_PRIVATE_KEY_FILE="${INVENTREE_DATA_DIR}/oidc.pem"
ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DATA_DIR}/plugins.txt"
# Worker configuration (can be altered by user)

View File

@ -181,6 +181,7 @@ function detect_envs() {
export INVENTREE_PLUGINS_ENABLED=true
export INVENTREE_PLUGIN_FILE=${CONF_DIR}/plugins.txt
export INVENTREE_SECRET_KEY_FILE=${CONF_DIR}/secret_key.txt
export INVENTREE_OIDC_PRIVATE_KEY_FILE=${CONF_DIR}/oidc.pem
export INVENTREE_DB_ENGINE=${INVENTREE_DB_ENGINE:-sqlite3}
export INVENTREE_DB_NAME=${INVENTREE_DB_NAME:-${DATA_DIR}/database.sqlite3}
@ -339,6 +340,8 @@ function set_env() {
sed -i s=#plugin_file:\ \'/path/to/plugins.txt\'=plugin_file:\ \'${INVENTREE_PLUGIN_FILE}\'=g ${INVENTREE_CONFIG_FILE}
# Secret key file
sed -i s=#secret_key_file:\ \'/etc/inventree/secret_key.txt\'=secret_key_file:\ \'${INVENTREE_SECRET_KEY_FILE}\'=g ${INVENTREE_CONFIG_FILE}
# OIDC private key file
sed -i s=#oidc_private_key_file:\ \'/etc/inventree/oidc.pem\'=oidc_private_key_file:\ \'${INVENTREE_OIDC_PRIVATE_KEY_FILE}\'=g ${INVENTREE_CONFIG_FILE}
# Debug mode
sed -i s=debug:\ True=debug:\ False=g ${INVENTREE_CONFIG_FILE}

View File

@ -33,7 +33,7 @@ invoke dev.schema -help
Users must be authenticated to gain access to the InvenTree API. The API accepts either basic username:password authentication, or token authentication. Token authentication is recommended as it provides much faster API access.
!!! warning "Permissions"
API access is restricted based on the permissions assigned to the user.
API access is restricted based on the permissions assigned to the user or scope of the application.
### Basic Auth
@ -44,9 +44,9 @@ Users can authenticate against the API using basic authentication - specifically
Each user is assigned an authentication token which can be used to access the API. This token is persistent for that user (unless invalidated by an administrator) and can be used across multiple sessions.
!!! info "Token Administration"
User tokens can be created and/or invalidated via the Admin interface.
User tokens can be created and/or invalidated via the user settings, admin center or admin interface.
### Requesting a Token
#### Requesting a Token
If a user does not know their access token, it can be requested via the API interface itself, using a basic authentication request.
@ -66,7 +66,7 @@ HTTP_200_OK
}
```
### Using a Token
#### Using a Token
After reception of a valid authentication token, it can be subsequently used to perform token-based authentication.
@ -95,6 +95,46 @@ headers = {
response = request.get('http://localhost:8080/api/part/', data=data, headers=headers)
```
### oAuth2 / OIDC
!!! warning "Experimental"
This is an experimental feature that needs to be specifically enabled. See [Experimental features](../settings/experimental.md) for more information.
InvenTree has built-in support for using [oAuth2](https://oauth.net/2/) and OpenID Connect (OIDC) for authentication to the API. This enables using the instance as a very limited identity provider.
A default application using a public client with PKCE enabled ships with each instance. Intended to be used with the python api and configured with very wide scopes this can also be used for quick tests - the cliend_id is `zDFnsiRheJIOKNx6aCQ0quBxECg1QBHtVFDPloJ6`.
#### Managing applications
Superusers can register new applications and manage existing ones using a small application under the subpath `/o/applications/`.
It is recommended to:
- read the spec (RFC 6749 / 6750) and/or best practices (RFC 9700) before choosing client types
- chose scopes as narrow as possible
- configure redirection URIs as exact as possible
#### Scopes
InvenTree's oAuth scopes are strongly related to the [user roles](#user-roles).
Names consist of 1. type, 2. kind and 3. (opt) role, separated by colons.
There are 3 types:
- a: administrative scopes - used for administrating the server - these can be staff or superuser scopes
- g: general scopes - give wide access to the basic building blocks of InvenTree
- r: role scopes - map to specific actions (2) and roles (3)
Examples:
```bash
a:superuser
g:read
r:change:part
r:delete:stock
```
!!! info "Read the API docs"
The API [documentation](#documentation) and [schema](./schema.md) list the required scopes for every API endpoint / interaction in the security sections.
## Authorization
### User Roles

View File

@ -32,6 +32,18 @@ As the `django.db.models.QuerySet` is not a generic class, we would loose type i
Models that implement the `InvenTreeReportMixin` must have an explicit return type annotation for the `report_context` function.
#### INVE-E5
**Rulesets have issues - Backend**
The rulesets used for managing user/group/oAuth permissions have an issue.
This might be caused by an addition or removal of models to the code base. Running the test suit should surface more logs with the error code indicating the exact infractions.
#### INVE-E6
**Scopes have issues - Backend**
The scopes used for oAuth permissions have an issue and do not match the rulesets.
This might be caused by an addition or removal of models to the code base or changes to the rulesets. Running the test suit should surface more logs with the error code indicating the exact infractions.
### INVE-W (InvenTree Warning)
Warnings - These are non-critical errors which should be addressed when possible.

View File

@ -0,0 +1,17 @@
---
title: Experimental Features
---
## Feature Flags
InvenTree ships with django-flags and enables path (parameter), user, session, date or settings based feature flags. This allows admins to slowly test and roll out new features on their instance without running parallel instances.
Additional flags can be provided via the the `INVENTREE_FLAGS` environment key (see [configuration](../start/config.md#environment-variables)).
Superusers can configure run-time conditions [as per django-flags](https://cfpb.github.io/django-flags/conditions/) docs under `/admin/flags/flagstate/`.
## Current Experimental Features
| Feature | Key | Description |
| ------- | ----------- |
| oAuth provider / api | OIDC | Use oAuth and OIDC to authenticate users with the API - [read more](../api/api.md#oauth2--oidc). |

View File

@ -218,31 +218,35 @@ You can either specify the password directly using `INVENTREE_ADMIN_PASSWORD`, o
!!! info "Administrator Account"
Providing `INVENTREE_ADMIN` credentials will result in the provided account being created with *superuser* permissions when InvenTree is started.
## Secret Key
## Secret Key material
InvenTree requires a secret key for providing cryptographic signing - this should be a secret (and unpredictable) value.
InvenTree requires secret keys for providing cryptographic signing and oidc private keys- this should be a secret (and unpredictable) value.
!!! info "Auto-Generated Key"
If none of the following options are specified, InvenTree will automatically generate a secret key file (stored in `secret_key.txt`) on first run.
!!! info "Auto-Generated material"
If none of the following options are specified, InvenTree will automatically generate a secret key file (stored in `secret_key.txt`) and a oidc key file (stored in `oidc.pem`) on first run.
The secret key can be provided in multiple ways, with the following (descending) priorities:
The secret key material can be provided in multiple ways, with the following (descending) priorities:
**Pass Secret Key via Environment Variable**
**Pass Secret Key Material via Environment Variable**
A secret key string can be passed directly using the environment variable `INVENTREE_SECRET_KEY`
A oidc private key can be passed directly using the environment variable `INVENTREE_OIDC_PRIVATE_KEY`
**Pass Secret Key File via Environment Variable**
**Pass Secret Key Material File via Environment Variable**
A file containing the secret key can be passed via the environment variable `INVENTREE_SECRET_KEY_FILE`
A PEM-encoded file containing the oidc private key can be passed via the environment variable `INVENTREE_OIDC_PRIVATE_KEY_FILE`
**Fallback to Default Secret Key File**
**Fallback to Default Secret Key Material**
If not specified via environment variables, the fallback secret_key file (automatically generated as part of InvenTree installation) will be used.
If not specified via environment variables, the fallback files (automatically generated as part of InvenTree installation) will be used.
| Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- |
| INVENTREE_SECRET_KEY | secret_key | Raw secret key value | *Not specified* |
| INVENTREE_SECRET_KEY_FILE | secret_key_file | File containing secret key value | *Not specified* |
| INVENTREE_OIDC_PRIVATE_KEY | oidc_private_key | Raw private key value | *Not specified* |
| INVENTREE_OIDC_PRIVATE_KEY_FILE | oidc_private_key_file | File containing private key value in PEM format | *Not specified* |
## Database Options

View File

@ -161,6 +161,7 @@ nav:
- Single Sign on: settings/SSO.md
- Multi Factor Authentication: settings/MFA.md
- Email: settings/email.md
- Experimental Features: settings/experimental.md
- Export Data: settings/export.md
- Import Data: settings/import.md
- Operations:

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