2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-27 11:06: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/config.yaml
test -f data/plugins.txt test -f data/plugins.txt
test -f data/secret_key.txt test -f data/secret_key.txt
test -f data/oidc.pem
- name: Run Unit Tests - name: Run Unit Tests
run: | run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env

1
.gitignore vendored
View File

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

1
.vscode/launch.json vendored
View File

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

View File

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

View File

@ -181,6 +181,7 @@ function detect_envs() {
export INVENTREE_PLUGINS_ENABLED=true export INVENTREE_PLUGINS_ENABLED=true
export INVENTREE_PLUGIN_FILE=${CONF_DIR}/plugins.txt export INVENTREE_PLUGIN_FILE=${CONF_DIR}/plugins.txt
export INVENTREE_SECRET_KEY_FILE=${CONF_DIR}/secret_key.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_ENGINE=${INVENTREE_DB_ENGINE:-sqlite3}
export INVENTREE_DB_NAME=${INVENTREE_DB_NAME:-${DATA_DIR}/database.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} sed -i s=#plugin_file:\ \'/path/to/plugins.txt\'=plugin_file:\ \'${INVENTREE_PLUGIN_FILE}\'=g ${INVENTREE_CONFIG_FILE}
# Secret key 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} 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 # Debug mode
sed -i s=debug:\ True=debug:\ False=g ${INVENTREE_CONFIG_FILE} 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. 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" !!! 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 ### 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. 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" !!! 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. 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. 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) 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 ## Authorization
### User Roles ### 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. 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) ### INVE-W (InvenTree Warning)
Warnings - These are non-critical errors which should be addressed when possible. 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" !!! info "Administrator Account"
Providing `INVENTREE_ADMIN` credentials will result in the provided account being created with *superuser* permissions when InvenTree is started. 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" !!! 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`) on first run. 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 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 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 | | Environment Variable | Configuration File | Description | Default |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| INVENTREE_SECRET_KEY | secret_key | Raw secret key value | *Not specified* | | 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_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 ## Database Options

View File

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

View File

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

View File

@ -1,13 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ INVENTREE_API_TEXT = """
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 v337 -> 2025-04-15 : https://github.com/inventree/InvenTree/pull/9505
- Adds API endpoint with extra serial number information for a given StockItem object - 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 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( def get_custom_file(
env_ref: str, conf_ref: str, log_ref: str, lookup_media: bool = False 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 pathlib import Path
from typing import TypeVar from typing import TypeVar

View File

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

View File

@ -86,6 +86,11 @@ class AuthRequiredMiddleware:
response = self.get_response(request) response = self.get_response(request)
return response 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? # Is the function exempt from auth requirements?
path_func = resolve(request.path).func path_func = resolve(request.path).func

View File

@ -1,10 +1,29 @@
"""Permission set for InvenTree.""" """Permission set for InvenTree."""
from functools import wraps 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 from rest_framework import permissions
import users.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): 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__}') 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. """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: Each endpoint can have one or more of the following actions:
@ -52,14 +190,7 @@ class RolePermission(permissions.BasePermission):
return True return True
# Map the request method to a permission type # Map the request method to a permission type
rolemap = { rolemap = {**ACTION_MAP, 'OPTIONS': 'view'}
'GET': 'view',
'OPTIONS': 'view',
'POST': 'add',
'PUT': 'change',
'PATCH': 'change',
'DELETE': 'delete',
}
# let the view define a custom rolemap # let the view define a custom rolemap
if hasattr(view, 'rolemap'): if hasattr(view, 'rolemap'):
@ -115,6 +246,13 @@ class RolePermissionOrReadOnly(RolePermission):
return request.method in permissions.SAFE_METHODS 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): class StaffRolePermissionOrReadOnly(RolePermissionOrReadOnly):
"""RolePermission which requires staff AND role access, or read-only.""" """RolePermission which requires staff AND role access, or read-only."""
@ -122,15 +260,19 @@ class StaffRolePermissionOrReadOnly(RolePermissionOrReadOnly):
REQUIRE_STAFF = True REQUIRE_STAFF = True
class IsSuperuser(permissions.IsAdminUser): class IsSuperuserOrSuperScope(OASTokenMixin, permissions.IsAdminUser):
"""Allows access only to superuser users.""" """Allows access only to superuser users."""
def has_permission(self, request, view): def has_permission(self, request, view):
"""Check if the user is a superuser.""" """Check if the user is a superuser."""
return bool(request.user and request.user.is_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.""" """Allow read-only access to any user, but write access is restricted to superuser users."""
def has_permission(self, request, view): def has_permission(self, request, view):
@ -140,17 +282,59 @@ class IsSuperuserOrReadOnly(permissions.IsAdminUser):
or request.method in permissions.SAFE_METHODS 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): def has_permission(self, request, view):
"""Check if the user is a superuser.""" """Check if the user is a staff."""
return bool( return bool(permissions.IsAuthenticated().has_permission(request, view)) and (
(request.user and request.user.is_staff) (request.user and request.user.is_staff)
or request.method in permissions.SAFE_METHODS 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): def auth_exempt(view_func):
"""Mark a view function as being exempt from auth requirements.""" """Mark a view function as being exempt from auth requirements."""
@ -160,3 +344,43 @@ def auth_exempt(view_func):
wrapped_view.auth_exempt = True wrapped_view.auth_exempt = True
return wraps(view_func)(wrapped_view) 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 itertools import chain
from typing import Optional 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.openapi import AutoSchema
from drf_spectacular.plumbing import ComponentRegistry from drf_spectacular.plumbing import ComponentRegistry
from drf_spectacular.utils import _SchemaType 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): class ExtendedAutoSchema(AutoSchema):
"""Extend drf-spectacular to allow customizing the schema to match the actual API behavior.""" """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(f' {scope}: {len(paths)}')
print() 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 return result

View File

@ -25,10 +25,16 @@ from corsheaders.defaults import default_headers as default_cors_headers
from dotenv import load_dotenv from dotenv import load_dotenv
from InvenTree.cache import get_cache_config, is_global_cache_enabled 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.ready import isInMainThread
from InvenTree.sentry import default_sentry_dsn, init_sentry from InvenTree.sentry import default_sentry_dsn, init_sentry
from InvenTree.version import checkMinPythonVersion, inventreeApiVersion from InvenTree.version import checkMinPythonVersion, inventreeApiVersion
from users.oauth2_scopes import oauth2_scopes
from . import config, locales from . import config, locales
@ -299,6 +305,7 @@ INSTALLED_APPS = [
'django_otp', # OTP is needed for MFA - base package 'django_otp', # OTP is needed for MFA - base package
'django_otp.plugins.otp_totp', # Time based OTP 'django_otp.plugins.otp_totp', # Time based OTP
'django_otp.plugins.otp_static', # Backup codes 'django_otp.plugins.otp_static', # Backup codes
'oauth2_provider', # OAuth2 provider and API access
'drf_spectacular', # API documentation 'drf_spectacular', # API documentation
'django_ical', # For exporting calendars 'django_ical', # For exporting calendars
] ]
@ -322,6 +329,7 @@ MIDDLEWARE = CONFIG.get(
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'InvenTree.middleware.AuthRequiredMiddleware', 'InvenTree.middleware.AuthRequiredMiddleware',
'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA 'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA
'oauth2_provider.middleware.OAuth2TokenMiddleware', # oauth2_provider
'maintenance_mode.middleware.MaintenanceModeMiddleware', 'maintenance_mode.middleware.MaintenanceModeMiddleware',
'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting 'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting
'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching 'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching
@ -354,6 +362,7 @@ QUERYCOUNT = {
AUTHENTICATION_BACKENDS = CONFIG.get( AUTHENTICATION_BACKENDS = CONFIG.get(
'authentication_backends', 'authentication_backends',
[ [
'oauth2_provider.backends.OAuth2Backend', # OAuth2 provider
'django.contrib.auth.backends.RemoteUserBackend', # proxy login 'django.contrib.auth.backends.RemoteUserBackend', # proxy login
'django.contrib.auth.backends.ModelBackend', 'django.contrib.auth.backends.ModelBackend',
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers '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 = { REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler', 'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler',
'DATETIME_FORMAT': '%Y-%m-%d %H:%M', 'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
@ -530,12 +548,14 @@ REST_FRAMEWORK = {
'users.authentication.ApiTokenAuthentication', 'users.authentication.ApiTokenAuthentication',
'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
'users.authentication.ExtendedOAuth2Authentication',
], ],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': [ 'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated', 'rest_framework.permissions.IsAuthenticated',
'rest_framework.permissions.DjangoModelPermissions', 'rest_framework.permissions.DjangoModelPermissions',
'InvenTree.permissions.RolePermission', 'InvenTree.permissions.RolePermission',
'InvenTree.permissions.InvenTreeTokenMatchesOASRequirements',
], ],
'DEFAULT_SCHEMA_CLASS': 'InvenTree.schema.ExtendedAutoSchema', 'DEFAULT_SCHEMA_CLASS': 'InvenTree.schema.ExtendedAutoSchema',
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata', 'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
@ -1401,6 +1421,7 @@ FLAGS = {
'NEXT_GEN': [ 'NEXT_GEN': [
{'condition': 'parameter', 'value': 'ngen='} {'condition': 'parameter', 'value': 'ngen='}
], # Should next-gen features be turned on? ], # Should next-gen features be turned on?
'OIDC': [{'condition': 'parameter', 'value': 'oidc='}],
} }
# Get custom flags from environment/yaml # Get custom flags from environment/yaml
@ -1445,6 +1466,11 @@ SPECTACULAR_SETTINGS = {
'UnauthorizedStatus': [[401, 401]], 'UnauthorizedStatus': [[401, 401]],
'IsTrueEnum': [[True, True]], '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: 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): if email := get_email_for_user(user):
send_email(subject, message, [email]) 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.core.management import call_command
from django.test import TestCase from django.test import TestCase
@ -9,5 +9,5 @@ class CommandTestCase(TestCase):
def test_schema(self): def test_schema(self):
"""Test the schema generation command.""" """Test the schema generation command."""
output = call_command('schema', verbosity=0) output = call_command('schema', file='schema.yml', verbosity=0)
self.assertEqual(output, 'done') 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 allauth.headless.urls import Client, build_urlpatterns
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView 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 from sesame.views import LoginView
import build.api import build.api
@ -125,6 +127,8 @@ backendpatterns = [
), # Used for (DRF) browsable API auth ), # Used for (DRF) browsable API auth
path('auth/', auth_request), # Used for proxies to check if user is authenticated path('auth/', auth_request), # Used for proxies to check if user is authenticated
path('accounts/', include('allauth.urls')), path('accounts/', include('allauth.urls')),
# OAuth2
flagged_path('OIDC', 'o/', include(oauth2_urls)),
path( path(
'accounts/login/', 'accounts/login/',
RedirectView.as_view(url=f'/{settings.FRONTEND_URL_BASE}', permanent=False), 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 drf_spectacular.utils import OpenApiResponse, extend_schema
from error_report.models import Error from error_report.models import Error
from pint._typing import UnitLike 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.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.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@ -44,7 +44,15 @@ from InvenTree.mixins import (
RetrieveUpdateAPI, RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI, 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.models import NotificationUserSetting
from plugin.serializers import NotificationUserSettingSerializer from plugin.serializers import NotificationUserSettingSerializer
@ -134,7 +142,7 @@ class WebhookView(CsrfExemptMixin, APIView):
class CurrencyExchangeView(APIView): class CurrencyExchangeView(APIView):
"""API endpoint for displaying currency information.""" """API endpoint for displaying currency information."""
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
serializer_class = None serializer_class = None
@extend_schema(responses={200: common.serializers.CurrencyExchangeSerializer}) @extend_schema(responses={200: common.serializers.CurrencyExchangeSerializer})
@ -178,7 +186,7 @@ class CurrencyRefreshView(APIView):
User must be a 'staff' user to access this endpoint User must be a 'staff' user to access this endpoint
""" """
permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
serializer_class = None serializer_class = None
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -208,6 +216,7 @@ class GlobalSettingsList(SettingsList):
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith='_') queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith='_')
serializer_class = common.serializers.GlobalSettingsSerializer serializer_class = common.serializers.GlobalSettingsSerializer
permission_classes = [IsAuthenticated, GlobalSettingsPermissions]
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
"""Ensure all global settings are created.""" """Ensure all global settings are created."""
@ -215,23 +224,6 @@ class GlobalSettingsList(SettingsList):
return super().list(request, *args, **kwargs) 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): class GlobalSettingsDetail(RetrieveUpdateAPI):
"""Detail view for an individual "global setting" object. """Detail view for an individual "global setting" object.
@ -241,6 +233,7 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
lookup_field = 'key' lookup_field = 'key'
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith='_') queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith='_')
serializer_class = common.serializers.GlobalSettingsSerializer serializer_class = common.serializers.GlobalSettingsSerializer
permission_classes = [IsAuthenticated, GlobalSettingsPermissions]
def get_object(self): def get_object(self):
"""Attempt to find a global setting object with the provided key.""" """Attempt to find a global setting object with the provided key."""
@ -253,17 +246,13 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
key, cache=False, create=True key, cache=False, create=True
) )
permission_classes = [permissions.IsAuthenticated, GlobalSettingsPermissions]
class UserSettingsList(SettingsList): class UserSettingsList(SettingsList):
"""API endpoint for accessing a list of user settings objects.""" """API endpoint for accessing a list of user settings objects."""
queryset = common.models.InvenTreeUserSetting.objects.all() queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer serializer_class = common.serializers.UserSettingsSerializer
permission_classes = [UserSettingsPermissionsOrScope]
# Note: Any user can view and edit their own settings
permission_classes = [permissions.IsAuthenticated]
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
"""Ensure all user settings are created.""" """Ensure all user settings are created."""
@ -291,19 +280,6 @@ class UserSettingsList(SettingsList):
return queryset 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): class UserSettingsDetail(RetrieveUpdateAPI):
"""Detail view for an individual "user setting" object. """Detail view for an individual "user setting" object.
@ -313,6 +289,7 @@ class UserSettingsDetail(RetrieveUpdateAPI):
lookup_field = 'key' lookup_field = 'key'
queryset = common.models.InvenTreeUserSetting.objects.all() queryset = common.models.InvenTreeUserSetting.objects.all()
serializer_class = common.serializers.UserSettingsSerializer serializer_class = common.serializers.UserSettingsSerializer
permission_classes = [UserSettingsPermissionsOrScope]
def get_object(self): def get_object(self):
"""Attempt to find a user setting object with the provided key.""" """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 key, user=self.request.user, cache=False, create=True
) )
permission_classes = [UserSettingsPermissions]
class NotificationUserSettingsList(SettingsList): class NotificationUserSettingsList(SettingsList):
"""API endpoint for accessing a list of notification user settings objects.""" """API endpoint for accessing a list of notification user settings objects."""
queryset = NotificationUserSetting.objects.all() queryset = NotificationUserSetting.objects.all()
serializer_class = NotificationUserSettingSerializer serializer_class = NotificationUserSettingSerializer
permission_classes = [UserSettingsPermissionsOrScope]
def filter_queryset(self, queryset): def filter_queryset(self, queryset):
"""Only list settings which apply to the current user.""" """Only list settings which apply to the current user."""
@ -357,7 +333,7 @@ class NotificationUserSettingsDetail(RetrieveUpdateAPI):
queryset = NotificationUserSetting.objects.all() queryset = NotificationUserSetting.objects.all()
serializer_class = NotificationUserSettingSerializer serializer_class = NotificationUserSettingSerializer
permission_classes = [UserSettingsPermissions] permission_classes = [UserSettingsPermissionsOrScope]
class NotificationMessageMixin: class NotificationMessageMixin:
@ -365,7 +341,7 @@ class NotificationMessageMixin:
queryset = common.models.NotificationMessage.objects.all() queryset = common.models.NotificationMessage.objects.all()
serializer_class = common.serializers.NotificationMessageSerializer serializer_class = common.serializers.NotificationMessageSerializer
permission_classes = [UserSettingsPermissions] permission_classes = [UserSettingsPermissionsOrScope]
def get_queryset(self): def get_queryset(self):
"""Return prefetched queryset.""" """Return prefetched queryset."""
@ -387,7 +363,7 @@ class NotificationMessageMixin:
class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI): class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
"""List view for all notifications of the current user.""" """List view for all notifications of the current user."""
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
@ -440,7 +416,7 @@ class NewsFeedMixin:
queryset = common.models.NewsFeedEntry.objects.all() queryset = common.models.NewsFeedEntry.objects.all()
serializer_class = common.serializers.NewsFeedEntrySerializer serializer_class = common.serializers.NewsFeedEntrySerializer
permission_classes = [IsAdminUser] permission_classes = [IsAdminOrAdminScope]
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI): class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
@ -464,7 +440,7 @@ class ConfigList(ListAPI):
queryset = CONFIG_LOOKUPS queryset = CONFIG_LOOKUPS
serializer_class = common.serializers.ConfigSerializer serializer_class = common.serializers.ConfigSerializer
permission_classes = [IsSuperuser] permission_classes = [IsSuperuserOrSuperScope]
# Specifically disable pagination for this view # Specifically disable pagination for this view
pagination_class = None pagination_class = None
@ -474,7 +450,7 @@ class ConfigDetail(RetrieveAPI):
"""Detail view for an individual configuration.""" """Detail view for an individual configuration."""
serializer_class = common.serializers.ConfigSerializer serializer_class = common.serializers.ConfigSerializer
permission_classes = [IsSuperuser] permission_classes = [IsSuperuserOrSuperScope]
def get_object(self): def get_object(self):
"""Attempt to find a config object with the provided key.""" """Attempt to find a config object with the provided key."""
@ -490,7 +466,7 @@ class NotesImageList(ListCreateAPI):
queryset = common.models.NotesImage.objects.all() queryset = common.models.NotesImage.objects.all()
serializer_class = common.serializers.NotesImageSerializer serializer_class = common.serializers.NotesImageSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
@ -508,7 +484,7 @@ class ProjectCodeList(DataExportViewMixin, ListCreateAPI):
queryset = common.models.ProjectCode.objects.all() queryset = common.models.ProjectCode.objects.all()
serializer_class = common.serializers.ProjectCodeSerializer serializer_class = common.serializers.ProjectCodeSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] permission_classes = [IsStaffOrReadOnlyScope]
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['code'] ordering_fields = ['code']
@ -521,7 +497,7 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
queryset = common.models.ProjectCode.objects.all() queryset = common.models.ProjectCode.objects.all()
serializer_class = common.serializers.ProjectCodeSerializer serializer_class = common.serializers.ProjectCodeSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] permission_classes = [IsStaffOrReadOnlyScope]
class CustomUnitList(DataExportViewMixin, ListCreateAPI): class CustomUnitList(DataExportViewMixin, ListCreateAPI):
@ -529,7 +505,7 @@ class CustomUnitList(DataExportViewMixin, ListCreateAPI):
queryset = common.models.CustomUnit.objects.all() queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] permission_classes = [IsStaffOrReadOnlyScope]
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
@ -538,14 +514,14 @@ class CustomUnitDetail(RetrieveUpdateDestroyAPI):
queryset = common.models.CustomUnit.objects.all() queryset = common.models.CustomUnit.objects.all()
serializer_class = common.serializers.CustomUnitSerializer serializer_class = common.serializers.CustomUnitSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] permission_classes = [IsStaffOrReadOnlyScope]
class AllUnitList(RetrieveAPI): class AllUnitList(RetrieveAPI):
"""List of all defined units.""" """List of all defined units."""
serializer_class = common.serializers.AllUnitListResponseSerializer serializer_class = common.serializers.AllUnitListResponseSerializer
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly] permission_classes = [IsStaffOrReadOnlyScope]
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""Return a list of all available units.""" """Return a list of all available units."""
@ -576,7 +552,7 @@ class ErrorMessageList(BulkDeleteMixin, ListAPI):
queryset = Error.objects.all() queryset = Error.objects.all()
serializer_class = common.serializers.ErrorMessageSerializer serializer_class = common.serializers.ErrorMessageSerializer
permission_classes = [permissions.IsAuthenticated, IsAdminUser] permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
@ -592,13 +568,13 @@ class ErrorMessageDetail(RetrieveUpdateDestroyAPI):
queryset = Error.objects.all() queryset = Error.objects.all()
serializer_class = common.serializers.ErrorMessageSerializer serializer_class = common.serializers.ErrorMessageSerializer
permission_classes = [permissions.IsAuthenticated, IsAdminUser] permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
class BackgroundTaskOverview(APIView): class BackgroundTaskOverview(APIView):
"""Provides an overview of the background task queue status.""" """Provides an overview of the background task queue status."""
permission_classes = [permissions.IsAuthenticated, IsAdminUser] permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
serializer_class = None serializer_class = None
def get(self, request, fmt=None): def get(self, request, fmt=None):
@ -620,7 +596,7 @@ class BackgroundTaskOverview(APIView):
class PendingTaskList(BulkDeleteMixin, ListAPI): class PendingTaskList(BulkDeleteMixin, ListAPI):
"""Provides a read-only list of currently pending tasks.""" """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() queryset = django_q.models.OrmQ.objects.all()
serializer_class = common.serializers.PendingTaskSerializer serializer_class = common.serializers.PendingTaskSerializer
@ -629,7 +605,7 @@ class PendingTaskList(BulkDeleteMixin, ListAPI):
class ScheduledTaskList(ListAPI): class ScheduledTaskList(ListAPI):
"""Provides a read-only list of currently scheduled tasks.""" """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() queryset = django_q.models.Schedule.objects.all()
serializer_class = common.serializers.ScheduledTaskSerializer serializer_class = common.serializers.ScheduledTaskSerializer
@ -649,7 +625,7 @@ class ScheduledTaskList(ListAPI):
class FailedTaskList(BulkDeleteMixin, ListAPI): class FailedTaskList(BulkDeleteMixin, ListAPI):
"""Provides a read-only list of currently failed tasks.""" """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() queryset = django_q.models.Failure.objects.all()
serializer_class = common.serializers.FailedTaskSerializer serializer_class = common.serializers.FailedTaskSerializer
@ -666,14 +642,14 @@ class FlagList(ListAPI):
queryset = settings.FLAGS queryset = settings.FLAGS
serializer_class = common.serializers.FlagSerializer serializer_class = common.serializers.FlagSerializer
permission_classes = [permissions.AllowAny] permission_classes = [AllowAnyOrReadScope]
class FlagDetail(RetrieveAPI): class FlagDetail(RetrieveAPI):
"""Detail view for an individual feature flag.""" """Detail view for an individual feature flag."""
serializer_class = common.serializers.FlagSerializer serializer_class = common.serializers.FlagSerializer
permission_classes = [permissions.AllowAny] permission_classes = [AllowAnyOrReadScope]
def get_object(self): def get_object(self):
"""Attempt to find a config object with the provided key.""" """Attempt to find a config object with the provided key."""
@ -689,7 +665,7 @@ class ContentTypeList(ListAPI):
queryset = ContentType.objects.all() queryset = ContentType.objects.all()
serializer_class = common.serializers.ContentTypeSerializer serializer_class = common.serializers.ContentTypeSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
search_fields = ['app_label', 'model'] search_fields = ['app_label', 'model']
@ -699,7 +675,7 @@ class ContentTypeDetail(RetrieveAPI):
queryset = ContentType.objects.all() queryset = ContentType.objects.all()
serializer_class = common.serializers.ContentTypeSerializer serializer_class = common.serializers.ContentTypeSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
class ContentTypeModelDetail(ContentTypeDetail): class ContentTypeModelDetail(ContentTypeDetail):
@ -753,7 +729,7 @@ class AttachmentList(BulkDeleteMixin, ListCreateAPI):
queryset = common.models.Attachment.objects.all() queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
filterset_class = AttachmentFilter filterset_class = AttachmentFilter
@ -791,7 +767,7 @@ class AttachmentDetail(RetrieveUpdateDestroyAPI):
queryset = common.models.Attachment.objects.all() queryset = common.models.Attachment.objects.all()
serializer_class = common.serializers.AttachmentSerializer serializer_class = common.serializers.AttachmentSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
"""Check user permissions before deleting an attachment.""" """Check user permissions before deleting an attachment."""
@ -810,7 +786,7 @@ class IconList(ListAPI):
"""List view for available icon packages.""" """List view for available icon packages."""
serializer_class = common.serializers.IconPackageSerializer serializer_class = common.serializers.IconPackageSerializer
permission_classes = [permissions.AllowAny] permission_classes = [AllowAnyOrReadScope]
def get_queryset(self): def get_queryset(self):
"""Return a list of all available icon packages.""" """Return a list of all available icon packages."""
@ -822,7 +798,7 @@ class SelectionListList(ListCreateAPI):
queryset = common.models.SelectionList.objects.all() queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer serializer_class = common.serializers.SelectionListSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
def get_queryset(self): def get_queryset(self):
"""Override the queryset method to include entry count.""" """Override the queryset method to include entry count."""
@ -834,7 +810,7 @@ class SelectionListDetail(RetrieveUpdateDestroyAPI):
queryset = common.models.SelectionList.objects.all() queryset = common.models.SelectionList.objects.all()
serializer_class = common.serializers.SelectionListSerializer serializer_class = common.serializers.SelectionListSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
class EntryMixin: class EntryMixin:
@ -842,7 +818,7 @@ class EntryMixin:
queryset = common.models.SelectionListEntry.objects.all() queryset = common.models.SelectionListEntry.objects.all()
serializer_class = common.serializers.SelectionEntrySerializer serializer_class = common.serializers.SelectionEntrySerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [IsAuthenticatedOrReadScope]
lookup_url_kwarg = 'entrypk' lookup_url_kwarg = 'entrypk'
def get_queryset(self): def get_queryset(self):
@ -861,22 +837,22 @@ class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
"""Detail view for a SelectionEntry object.""" """Detail view for a SelectionEntry object."""
class DataOutputEndpoint: class DataOutputEndpointMixin:
"""Mixin class for DataOutput endpoints.""" """Mixin class for DataOutput endpoints."""
queryset = common.models.DataOutput.objects.all() queryset = common.models.DataOutput.objects.all()
serializer_class = common.serializers.DataOutputSerializer 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.""" """List view for DataOutput objects."""
filter_backends = SEARCH_ORDER_FILTER filter_backends = SEARCH_ORDER_FILTER
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created'] ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
class DataOutputDetail(DataOutputEndpoint, RetrieveAPI): class DataOutputDetail(DataOutputEndpointMixin, RetrieveAPI):
"""Detail view for a DataOutput object.""" """Detail view for a DataOutput object."""
@ -1014,7 +990,10 @@ common_api_urls = [
include([ include([
path( path(
'metadata/', 'metadata/',
MetadataView.as_view(model=common.models.ProjectCode), MetadataView.as_view(
model=common.models.ProjectCode,
permission_classes=[IsStaffOrReadOnlyScope],
),
name='api-project-code-metadata', name='api-project-code-metadata',
), ),
path( path(

View File

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

View File

@ -5,6 +5,9 @@
# Secret key for backend # Secret key for backend
# Use the environment variable INVENTREE_SECRET_KEY_FILE # Use the environment variable INVENTREE_SECRET_KEY_FILE
#secret_key_file: '/etc/inventree/secret_key.txt' #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 # Database backend selection - Configure backend database settings
# Documentation: https://docs.inventree.org/en/latest/start/config/ # Documentation: https://docs.inventree.org/en/latest/start/config/

View File

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

View File

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

View File

@ -3,11 +3,11 @@
from django.urls import include, path, re_path from django.urls import include, path, re_path
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework import permissions
from rest_framework.exceptions import NotFound from rest_framework.exceptions import NotFound
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
import InvenTree.permissions
import machine.serializers as MachineSerializers import machine.serializers as MachineSerializers
from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI from InvenTree.mixins import ListCreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI
@ -78,7 +78,7 @@ class MachineSettingList(APIView):
- GET: return all settings for a machine config - GET: return all settings for a machine config
""" """
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
@extend_schema( @extend_schema(
responses={200: MachineSerializers.MachineSettingSerializer(many=True)} responses={200: MachineSerializers.MachineSettingSerializer(many=True)}
@ -142,7 +142,7 @@ class MachineRestart(APIView):
- POST: restart machine by pk - POST: restart machine by pk
""" """
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
@extend_schema( @extend_schema(
request=None, responses={200: MachineSerializers.MachineRestartSerializer()} request=None, responses={200: MachineSerializers.MachineRestartSerializer()}
@ -162,7 +162,7 @@ class MachineTypesList(APIView):
- GET: List all machine types - GET: List all machine types
""" """
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
@extend_schema(responses={200: MachineSerializers.MachineTypeSerializer(many=True)}) @extend_schema(responses={200: MachineSerializers.MachineTypeSerializer(many=True)})
def get(self, request): def get(self, request):
@ -180,7 +180,7 @@ class MachineDriverList(APIView):
- GET: List all machine drivers - GET: List all machine drivers
""" """
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
@extend_schema( @extend_schema(
responses={200: MachineSerializers.MachineDriverSerializer(many=True)} responses={200: MachineSerializers.MachineDriverSerializer(many=True)}
@ -203,7 +203,7 @@ class RegistryStatusView(APIView):
- GET: Provide status data for the machine registry - GET: Provide status data for the machine registry
""" """
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = MachineSerializers.MachineRegistryStatusSerializer 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 django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field 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.exceptions import ValidationError
from rest_framework.response import Response from rest_framework.response import Response
import InvenTree.permissions
import order.models import order.models
import part.filters import part.filters
from build.models import Build, BuildItem from build.models import Build, BuildItem
@ -41,7 +42,6 @@ from InvenTree.mixins import (
RetrieveUpdateDestroyAPI, RetrieveUpdateDestroyAPI,
UpdateAPI, UpdateAPI,
) )
from InvenTree.permissions import RolePermission
from InvenTree.serializers import EmptySerializer from InvenTree.serializers import EmptySerializer
from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
from stock.models import StockLocation from stock.models import StockLocation
@ -1738,7 +1738,10 @@ class PartStocktakeReportGenerate(CreateAPI):
serializer_class = part_serializers.PartStocktakeReportGenerateSerializer serializer_class = part_serializers.PartStocktakeReportGenerateSerializer
permission_classes = [permissions.IsAuthenticated, RolePermission] permission_classes = [
InvenTree.permissions.IsAuthenticatedOrReadScope,
InvenTree.permissions.RolePermission,
]
role_required = 'stocktake' 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 import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.utils import extend_schema 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.exceptions import NotFound
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
import InvenTree.permissions
import plugin.serializers as PluginSerializers import plugin.serializers as PluginSerializers
from common.api import GlobalSettingsPermissions
from InvenTree.api import MetadataView from InvenTree.api import MetadataView
from InvenTree.filters import SEARCH_ORDER_FILTER from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.helpers import str2bool from InvenTree.helpers import str2bool
@ -27,7 +27,6 @@ from InvenTree.mixins import (
RetrieveUpdateAPI, RetrieveUpdateAPI,
UpdateAPI, UpdateAPI,
) )
from InvenTree.permissions import IsSuperuser, IsSuperuserOrReadOnly
from plugin.base.action.api import ActionPluginView from plugin.base.action.api import ActionPluginView
from plugin.base.barcodes.api import barcode_api_urls from plugin.base.barcodes.api import barcode_api_urls
from plugin.base.locate.api import LocatePluginView from plugin.base.locate.api import LocatePluginView
@ -138,7 +137,7 @@ class PluginList(ListAPI):
# Allow any logged in user to read this endpoint # Allow any logged in user to read this endpoint
# This is necessary to allow certain functionality, # This is necessary to allow certain functionality,
# e.g. determining which label printing plugins are available # e.g. determining which label printing plugins are available
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
filterset_class = PluginFilter filterset_class = PluginFilter
@ -169,7 +168,7 @@ class PluginDetail(RetrieveDestroyAPI):
queryset = PluginConfig.objects.all() queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginConfigSerializer serializer_class = PluginSerializers.PluginConfigSerializer
permission_classes = [IsSuperuserOrReadOnly] permission_classes = [InvenTree.permissions.IsSuperuserOrReadOnlyOrScope]
lookup_field = 'key' lookup_field = 'key'
lookup_url_kwarg = 'plugin' lookup_url_kwarg = 'plugin'
@ -228,7 +227,7 @@ class PluginUninstall(UpdateAPI):
queryset = PluginConfig.objects.all() queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginUninstallSerializer serializer_class = PluginSerializers.PluginUninstallSerializer
permission_classes = [IsSuperuser] permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
lookup_field = 'key' lookup_field = 'key'
lookup_url_kwarg = 'plugin' lookup_url_kwarg = 'plugin'
@ -249,7 +248,7 @@ class PluginActivate(UpdateAPI):
queryset = PluginConfig.objects.all() queryset = PluginConfig.objects.all()
serializer_class = PluginSerializers.PluginActivateSerializer serializer_class = PluginSerializers.PluginActivateSerializer
permission_classes = [IsSuperuser] permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
lookup_field = 'key' lookup_field = 'key'
lookup_url_kwarg = 'plugin' lookup_url_kwarg = 'plugin'
@ -269,7 +268,7 @@ class PluginReload(CreateAPI):
queryset = PluginConfig.objects.none() queryset = PluginConfig.objects.none()
serializer_class = PluginSerializers.PluginReloadSerializer serializer_class = PluginSerializers.PluginReloadSerializer
permission_classes = [IsSuperuser] permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
def perform_create(self, serializer): def perform_create(self, serializer):
"""Saving the serializer instance performs plugin installation.""" """Saving the serializer instance performs plugin installation."""
@ -286,7 +285,7 @@ class PluginSettingList(ListAPI):
queryset = PluginSetting.objects.all() queryset = PluginSetting.objects.all()
serializer_class = PluginSerializers.PluginSettingSerializer serializer_class = PluginSerializers.PluginSettingSerializer
permission_classes = [GlobalSettingsPermissions] permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
filter_backends = [DjangoFilterBackend] filter_backends = [DjangoFilterBackend]
@ -359,7 +358,7 @@ class PluginAllSettingList(APIView):
- GET: return all settings for a plugin config - GET: return all settings for a plugin config
""" """
permission_classes = [GlobalSettingsPermissions] permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
@extend_schema( @extend_schema(
responses={200: PluginSerializers.PluginSettingSerializer(many=True)} responses={200: PluginSerializers.PluginSettingSerializer(many=True)}
@ -413,7 +412,7 @@ class PluginSettingDetail(RetrieveUpdateAPI):
) )
# Staff permission required # Staff permission required
permission_classes = [GlobalSettingsPermissions] permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
class RegistryStatusView(APIView): class RegistryStatusView(APIView):
@ -422,7 +421,7 @@ class RegistryStatusView(APIView):
- GET: Provide status data for the plugin registry - GET: Provide status data for the plugin registry
""" """
permission_classes = [IsSuperuser] permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
serializer_class = PluginSerializers.PluginRegistryStatusSerializer serializer_class = PluginSerializers.PluginRegistryStatusSerializer

View File

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

View File

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

View File

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

View File

@ -3,10 +3,10 @@
from django.urls import path from django.urls import path
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
import InvenTree.permissions
import plugin.base.ui.serializers as UIPluginSerializers import plugin.base.ui.serializers as UIPluginSerializers
from common.settings import get_global_setting from common.settings import get_global_setting
from InvenTree.exceptions import log_error from InvenTree.exceptions import log_error
@ -16,7 +16,7 @@ from plugin import PluginMixinEnum, registry
class PluginUIFeatureList(APIView): class PluginUIFeatureList(APIView):
"""API endpoint for listing all available plugin ui features.""" """API endpoint for listing all available plugin ui features."""
permission_classes = [IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = UIPluginSerializers.PluginUIFeatureSerializer serializer_class = UIPluginSerializers.PluginUIFeatureSerializer
@extend_schema( @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 import rest_framework as rest_filters
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import permissions
from rest_framework.generics import GenericAPIView from rest_framework.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
@ -30,10 +29,7 @@ class TemplatePermissionMixin:
"""Permission mixin for report and label templates.""" """Permission mixin for report and label templates."""
# Read only for non-staff users # Read only for non-staff users
permission_classes = [ permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
permissions.IsAuthenticated,
InvenTree.permissions.IsStaffOrReadOnly,
]
class ReportFilterBase(rest_filters.FilterSet): class ReportFilterBase(rest_filters.FilterSet):
@ -100,7 +96,7 @@ class LabelPrint(GenericAPIView):
"""API endpoint for printing labels.""" """API endpoint for printing labels."""
# Any authenticated user can print labels # Any authenticated user can print labels
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = report.serializers.LabelPrintSerializer serializer_class = report.serializers.LabelPrintSerializer
def get_plugin_class(self, plugin_slug: str, raise_error=False): def get_plugin_class(self, plugin_slug: str, raise_error=False):
@ -253,7 +249,7 @@ class ReportPrint(GenericAPIView):
"""API endpoint for printing reports.""" """API endpoint for printing reports."""
# Any authenticated user can print reports # Any authenticated user can print reports
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = report.serializers.ReportPrintSerializer serializer_class = report.serializers.ReportPrintSerializer
@method_decorator(never_cache) @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 django_filters import rest_framework as rest_filters
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field 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.generics import GenericAPIView
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import ValidationError from rest_framework.serializers import ValidationError
@ -20,6 +20,7 @@ from rest_framework.serializers import ValidationError
import common.models import common.models
import common.settings import common.settings
import InvenTree.helpers import InvenTree.helpers
import InvenTree.permissions
import stock.serializers as StockSerializers import stock.serializers as StockSerializers
from build.models import Build from build.models import Build
from build.serializers import BuildSerializer from build.serializers import BuildSerializer
@ -65,7 +66,7 @@ from stock.status_codes import StockHistoryCode, StockStatus
class GenerateBatchCode(GenericAPIView): class GenerateBatchCode(GenericAPIView):
"""API endpoint for generating batch codes.""" """API endpoint for generating batch codes."""
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = StockSerializers.GenerateBatchCodeSerializer serializer_class = StockSerializers.GenerateBatchCodeSerializer
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@ -81,7 +82,7 @@ class GenerateBatchCode(GenericAPIView):
class GenerateSerialNumber(GenericAPIView): class GenerateSerialNumber(GenericAPIView):
"""API endpoint for generating serial numbers.""" """API endpoint for generating serial numbers."""
permission_classes = [permissions.IsAuthenticated] permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
serializer_class = StockSerializers.GenerateSerialNumberSerializer serializer_class = StockSerializers.GenerateSerialNumberSerializer
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):

View File

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

View File

@ -4,6 +4,7 @@ import datetime
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework import exceptions from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
@ -37,3 +38,7 @@ class ApiTokenAuthentication(TokenAuthentication):
token.save() token.save()
return (user, token) 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_statictoken',
'otp_static_staticdevice', 'otp_static_staticdevice',
'mfa_authenticator', 'mfa_authenticator',
# Oauth
'oauth2_provider_application',
'oauth2_provider_grant',
'oauth2_provider_idtoken',
'oauth2_provider_accesstoken',
'oauth2_provider_refreshtoken',
# Plugins
'plugin_pluginconfig', 'plugin_pluginconfig',
'plugin_pluginsetting', 'plugin_pluginsetting',
'plugin_notificationusersetting', 'plugin_notificationusersetting',
# Misc
'common_barcodescanresult', 'common_barcodescanresult',
'common_newsfeedentry', 'common_newsfeedentry',
'taggit_tag', 'taggit_tag',

View File

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

View File

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

View File

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