mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-26 18:46:44 +00:00
feat(backend): add oauth2 (#9333)
* feat(backend): add oauth2 * fix import * Add inventree roles * refactor to make lookup more efficient * fix single scope definitions * cleanup * fix schema * reduce auth methods * fix OAuth validator * re-enable token and basic auth again * Add models to role mapping * change scope args * add debug step for schema generation * add oauth config for schema generation * improve token -> permission mapping * fix req * extend checks to ensure normal auth also passes * fix api version * fix ignore * fix rule name * bump api version * remove old modelref * move scope definition * make test results easier to work with * add tests to ensure scopes are in sync with rulesets * fix docstring * fix various permissions and their mapping to oauth * refactor * simplify * fix permission mapping * ignore failure cases * fix unauthenticated access * flag oAuth2 till it is done * Add OIDC support * add RSA key generation and docs * fix test * move imports * update ignore * feat(backend): Add API Schema stats * add scope stats * fix name * fix scope output * feat(backend): test custom command * add warning for unknown scopes * reduce diff in launch.json * cleanup diff * add error code for ruleset / scope issues * update structure * add oauth docs * add experimetnal feature docs * simplify metadata endpoint * add importer model * refactor(backend): simplify metadata endpoint * fix imports * simplify even more * remove unneeded schema tooling * fix permission mappings * fix testing * fix role calculations * fix mapping * remove importer change to unblock this * remove importer scope everywhere * fix merge conflict in test * add missing models * fix api version * fix OASToken matcher * revert permission class change * reduce size of test log by writing schema * fix permissions * fix file path * extend schema to remove need for TokenMatchesOASRequirements * cleanup permissions file * add base object permission
This commit is contained in:
parent
afbcfe66bb
commit
8bb03b7afd
1
.github/workflows/docker.yaml
vendored
1
.github/workflows/docker.yaml
vendored
@ -120,6 +120,7 @@ jobs:
|
||||
test -f data/config.yaml
|
||||
test -f data/plugins.txt
|
||||
test -f data/secret_key.txt
|
||||
test -f data/oidc.pem
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> contrib/container/docker.dev.env
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -57,6 +57,7 @@ data.json
|
||||
|
||||
# Key file
|
||||
secret_key.txt
|
||||
oidc.pem
|
||||
|
||||
# IDE / development files
|
||||
.idea/
|
||||
|
1
.vscode/launch.json
vendored
1
.vscode/launch.json
vendored
@ -60,6 +60,7 @@
|
||||
"schema",
|
||||
"--file","src/frontend/schema.yml"
|
||||
],
|
||||
"django": true,
|
||||
"justMyCode": false
|
||||
},
|
||||
{
|
||||
|
@ -37,6 +37,7 @@ ENV INVENTREE_BACKEND_DIR="${INVENTREE_HOME}/src/backend"
|
||||
# InvenTree configuration files
|
||||
ENV INVENTREE_CONFIG_FILE="${INVENTREE_DATA_DIR}/config.yaml"
|
||||
ENV INVENTREE_SECRET_KEY_FILE="${INVENTREE_DATA_DIR}/secret_key.txt"
|
||||
ENV INVENTREE_OIDC_PRIVATE_KEY_FILE="${INVENTREE_DATA_DIR}/oidc.pem"
|
||||
ENV INVENTREE_PLUGIN_FILE="${INVENTREE_DATA_DIR}/plugins.txt"
|
||||
|
||||
# Worker configuration (can be altered by user)
|
||||
|
@ -181,6 +181,7 @@ function detect_envs() {
|
||||
export INVENTREE_PLUGINS_ENABLED=true
|
||||
export INVENTREE_PLUGIN_FILE=${CONF_DIR}/plugins.txt
|
||||
export INVENTREE_SECRET_KEY_FILE=${CONF_DIR}/secret_key.txt
|
||||
export INVENTREE_OIDC_PRIVATE_KEY_FILE=${CONF_DIR}/oidc.pem
|
||||
|
||||
export INVENTREE_DB_ENGINE=${INVENTREE_DB_ENGINE:-sqlite3}
|
||||
export INVENTREE_DB_NAME=${INVENTREE_DB_NAME:-${DATA_DIR}/database.sqlite3}
|
||||
@ -339,6 +340,8 @@ function set_env() {
|
||||
sed -i s=#plugin_file:\ \'/path/to/plugins.txt\'=plugin_file:\ \'${INVENTREE_PLUGIN_FILE}\'=g ${INVENTREE_CONFIG_FILE}
|
||||
# Secret key file
|
||||
sed -i s=#secret_key_file:\ \'/etc/inventree/secret_key.txt\'=secret_key_file:\ \'${INVENTREE_SECRET_KEY_FILE}\'=g ${INVENTREE_CONFIG_FILE}
|
||||
# OIDC private key file
|
||||
sed -i s=#oidc_private_key_file:\ \'/etc/inventree/oidc.pem\'=oidc_private_key_file:\ \'${INVENTREE_OIDC_PRIVATE_KEY_FILE}\'=g ${INVENTREE_CONFIG_FILE}
|
||||
# Debug mode
|
||||
sed -i s=debug:\ True=debug:\ False=g ${INVENTREE_CONFIG_FILE}
|
||||
|
||||
|
@ -33,7 +33,7 @@ invoke dev.schema -help
|
||||
Users must be authenticated to gain access to the InvenTree API. The API accepts either basic username:password authentication, or token authentication. Token authentication is recommended as it provides much faster API access.
|
||||
|
||||
!!! warning "Permissions"
|
||||
API access is restricted based on the permissions assigned to the user.
|
||||
API access is restricted based on the permissions assigned to the user or scope of the application.
|
||||
|
||||
### Basic Auth
|
||||
|
||||
@ -44,9 +44,9 @@ Users can authenticate against the API using basic authentication - specifically
|
||||
Each user is assigned an authentication token which can be used to access the API. This token is persistent for that user (unless invalidated by an administrator) and can be used across multiple sessions.
|
||||
|
||||
!!! info "Token Administration"
|
||||
User tokens can be created and/or invalidated via the Admin interface.
|
||||
User tokens can be created and/or invalidated via the user settings, admin center or admin interface.
|
||||
|
||||
### Requesting a Token
|
||||
#### Requesting a Token
|
||||
|
||||
If a user does not know their access token, it can be requested via the API interface itself, using a basic authentication request.
|
||||
|
||||
@ -66,7 +66,7 @@ HTTP_200_OK
|
||||
}
|
||||
```
|
||||
|
||||
### Using a Token
|
||||
#### Using a Token
|
||||
|
||||
After reception of a valid authentication token, it can be subsequently used to perform token-based authentication.
|
||||
|
||||
@ -95,6 +95,46 @@ headers = {
|
||||
response = request.get('http://localhost:8080/api/part/', data=data, headers=headers)
|
||||
```
|
||||
|
||||
### oAuth2 / OIDC
|
||||
|
||||
!!! warning "Experimental"
|
||||
This is an experimental feature that needs to be specifically enabled. See [Experimental features](../settings/experimental.md) for more information.
|
||||
|
||||
InvenTree has built-in support for using [oAuth2](https://oauth.net/2/) and OpenID Connect (OIDC) for authentication to the API. This enables using the instance as a very limited identity provider.
|
||||
|
||||
A default application using a public client with PKCE enabled ships with each instance. Intended to be used with the python api and configured with very wide scopes this can also be used for quick tests - the cliend_id is `zDFnsiRheJIOKNx6aCQ0quBxECg1QBHtVFDPloJ6`.
|
||||
|
||||
#### Managing applications
|
||||
|
||||
Superusers can register new applications and manage existing ones using a small application under the subpath `/o/applications/`.
|
||||
It is recommended to:
|
||||
- read the spec (RFC 6749 / 6750) and/or best practices (RFC 9700) before choosing client types
|
||||
- chose scopes as narrow as possible
|
||||
- configure redirection URIs as exact as possible
|
||||
|
||||
#### Scopes
|
||||
|
||||
InvenTree's oAuth scopes are strongly related to the [user roles](#user-roles).
|
||||
Names consist of 1. type, 2. kind and 3. (opt) role, separated by colons.
|
||||
|
||||
|
||||
There are 3 types:
|
||||
|
||||
- a: administrative scopes - used for administrating the server - these can be staff or superuser scopes
|
||||
- g: general scopes - give wide access to the basic building blocks of InvenTree
|
||||
- r: role scopes - map to specific actions (2) and roles (3)
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
a:superuser
|
||||
g:read
|
||||
r:change:part
|
||||
r:delete:stock
|
||||
```
|
||||
|
||||
!!! info "Read the API docs"
|
||||
The API [documentation](#documentation) and [schema](./schema.md) list the required scopes for every API endpoint / interaction in the security sections.
|
||||
|
||||
## Authorization
|
||||
|
||||
### User Roles
|
||||
|
@ -32,6 +32,18 @@ As the `django.db.models.QuerySet` is not a generic class, we would loose type i
|
||||
|
||||
Models that implement the `InvenTreeReportMixin` must have an explicit return type annotation for the `report_context` function.
|
||||
|
||||
#### INVE-E5
|
||||
**Rulesets have issues - Backend**
|
||||
|
||||
The rulesets used for managing user/group/oAuth permissions have an issue.
|
||||
This might be caused by an addition or removal of models to the code base. Running the test suit should surface more logs with the error code indicating the exact infractions.
|
||||
|
||||
#### INVE-E6
|
||||
**Scopes have issues - Backend**
|
||||
|
||||
The scopes used for oAuth permissions have an issue and do not match the rulesets.
|
||||
This might be caused by an addition or removal of models to the code base or changes to the rulesets. Running the test suit should surface more logs with the error code indicating the exact infractions.
|
||||
|
||||
### INVE-W (InvenTree Warning)
|
||||
Warnings - These are non-critical errors which should be addressed when possible.
|
||||
|
||||
|
17
docs/docs/settings/experimental.md
Normal file
17
docs/docs/settings/experimental.md
Normal 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). |
|
@ -218,31 +218,35 @@ You can either specify the password directly using `INVENTREE_ADMIN_PASSWORD`, o
|
||||
!!! info "Administrator Account"
|
||||
Providing `INVENTREE_ADMIN` credentials will result in the provided account being created with *superuser* permissions when InvenTree is started.
|
||||
|
||||
## Secret Key
|
||||
## Secret Key material
|
||||
|
||||
InvenTree requires a secret key for providing cryptographic signing - this should be a secret (and unpredictable) value.
|
||||
InvenTree requires secret keys for providing cryptographic signing and oidc private keys- this should be a secret (and unpredictable) value.
|
||||
|
||||
!!! info "Auto-Generated Key"
|
||||
If none of the following options are specified, InvenTree will automatically generate a secret key file (stored in `secret_key.txt`) on first run.
|
||||
!!! info "Auto-Generated material"
|
||||
If none of the following options are specified, InvenTree will automatically generate a secret key file (stored in `secret_key.txt`) and a oidc key file (stored in `oidc.pem`) on first run.
|
||||
|
||||
The secret key can be provided in multiple ways, with the following (descending) priorities:
|
||||
The secret key material can be provided in multiple ways, with the following (descending) priorities:
|
||||
|
||||
**Pass Secret Key via Environment Variable**
|
||||
**Pass Secret Key Material via Environment Variable**
|
||||
|
||||
A secret key string can be passed directly using the environment variable `INVENTREE_SECRET_KEY`
|
||||
A oidc private key can be passed directly using the environment variable `INVENTREE_OIDC_PRIVATE_KEY`
|
||||
|
||||
**Pass Secret Key File via Environment Variable**
|
||||
**Pass Secret Key Material File via Environment Variable**
|
||||
|
||||
A file containing the secret key can be passed via the environment variable `INVENTREE_SECRET_KEY_FILE`
|
||||
A PEM-encoded file containing the oidc private key can be passed via the environment variable `INVENTREE_OIDC_PRIVATE_KEY_FILE`
|
||||
|
||||
**Fallback to Default Secret Key File**
|
||||
**Fallback to Default Secret Key Material**
|
||||
|
||||
If not specified via environment variables, the fallback secret_key file (automatically generated as part of InvenTree installation) will be used.
|
||||
If not specified via environment variables, the fallback files (automatically generated as part of InvenTree installation) will be used.
|
||||
|
||||
| Environment Variable | Configuration File | Description | Default |
|
||||
| --- | --- | --- | --- |
|
||||
| INVENTREE_SECRET_KEY | secret_key | Raw secret key value | *Not specified* |
|
||||
| INVENTREE_SECRET_KEY_FILE | secret_key_file | File containing secret key value | *Not specified* |
|
||||
| INVENTREE_OIDC_PRIVATE_KEY | oidc_private_key | Raw private key value | *Not specified* |
|
||||
| INVENTREE_OIDC_PRIVATE_KEY_FILE | oidc_private_key_file | File containing private key value in PEM format | *Not specified* |
|
||||
|
||||
## Database Options
|
||||
|
||||
|
@ -161,6 +161,7 @@ nav:
|
||||
- Single Sign on: settings/SSO.md
|
||||
- Multi Factor Authentication: settings/MFA.md
|
||||
- Email: settings/email.md
|
||||
- Experimental Features: settings/experimental.md
|
||||
- Export Data: settings/export.md
|
||||
- Import Data: settings/import.md
|
||||
- Operations:
|
||||
|
@ -11,13 +11,12 @@ from django.utils.translation import gettext_lazy as _
|
||||
import structlog
|
||||
from django_q.models import OrmQ
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework import serializers
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import InvenTree.ready
|
||||
import InvenTree.version
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree import helpers
|
||||
@ -88,7 +87,7 @@ class LicenseViewSerializer(serializers.Serializer):
|
||||
class LicenseView(APIView):
|
||||
"""Simple JSON endpoint for InvenTree license information."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
@extend_schema(responses={200: OpenApiResponse(response=LicenseViewSerializer)})
|
||||
def get(self, request, *args, **kwargs):
|
||||
@ -134,7 +133,7 @@ class VersionViewSerializer(serializers.Serializer):
|
||||
class VersionView(APIView):
|
||||
"""Simple JSON endpoint for InvenTree version information."""
|
||||
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
permission_classes = [InvenTree.permissions.IsAdminOrAdminScope]
|
||||
|
||||
@extend_schema(responses={200: OpenApiResponse(response=VersionViewSerializer)})
|
||||
def get(self, request, *args, **kwargs):
|
||||
@ -195,7 +194,7 @@ class VersionTextView(ListAPI):
|
||||
|
||||
serializer_class = VersionInformationSerializer
|
||||
|
||||
permission_classes = [permissions.IsAdminUser]
|
||||
permission_classes = [InvenTree.permissions.IsAdminOrAdminScope]
|
||||
|
||||
# Specifically disable pagination for this view
|
||||
pagination_class = None
|
||||
@ -266,7 +265,7 @@ class InfoView(APIView):
|
||||
Use to confirm that the server is running, etc.
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
permission_classes = [InvenTree.permissions.AllowAnyOrReadScope]
|
||||
|
||||
def worker_pending_tasks(self):
|
||||
"""Return the current number of outstanding background tasks."""
|
||||
@ -349,7 +348,7 @@ class InfoView(APIView):
|
||||
class NotFoundView(APIView):
|
||||
"""Simple JSON view when accessing an invalid API view."""
|
||||
|
||||
permission_classes = [permissions.AllowAny]
|
||||
permission_classes = [InvenTree.permissions.AllowAnyOrReadScope]
|
||||
|
||||
def not_found(self, request):
|
||||
"""Return a 404 error."""
|
||||
@ -613,7 +612,7 @@ class APISearchView(GenericAPIView):
|
||||
Is much more efficient and simplifies code!
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = APISearchViewSerializer
|
||||
|
||||
def get_result_types(self):
|
||||
|
@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 337
|
||||
INVENTREE_API_VERSION = 338
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v338 -> 2025-04-15 : https://github.com/inventree/InvenTree/pull/9333
|
||||
- Adds oAuth2 support for the API
|
||||
|
||||
v337 -> 2025-04-15 : https://github.com/inventree/InvenTree/pull/9505
|
||||
- Adds API endpoint with extra serial number information for a given StockItem object
|
||||
|
||||
|
@ -365,6 +365,52 @@ def get_secret_key():
|
||||
return key_data
|
||||
|
||||
|
||||
def get_oidc_private_key():
|
||||
"""Return the private key for OIDC authentication.
|
||||
|
||||
Following options are tested, in descending order of preference:
|
||||
A) Check for environment variable INVENTREE_OIDC_PRIVATE_KEY or config yalue => Use raw key data
|
||||
B) Check for environment variable INVENTREE_OIDC_PRIVATE_KEY_FILE or config value => Load key data from file
|
||||
C) Create "oidc.pem" if it does not exist
|
||||
"""
|
||||
RSA_KEY = get_setting('INVENTREE_OIDC_PRIVATE_KEY', 'oidc_private_key')
|
||||
if RSA_KEY:
|
||||
logger.info('RSA_KEY loaded by INVENTREE_OIDC_PRIVATE_KEY') # pragma: no cover
|
||||
return RSA_KEY
|
||||
|
||||
# Look for private key file
|
||||
key_loc = Path(
|
||||
get_setting(
|
||||
'INVENTREE_OIDC_PRIVATE_KEY_FILE',
|
||||
'oidc_private_key_file',
|
||||
get_base_dir().joinpath('oidc.pem'),
|
||||
)
|
||||
)
|
||||
if key_loc.exists():
|
||||
return key_loc.read_text()
|
||||
else:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
# Default location for private key file
|
||||
logger.info("Generating oidc key file at '%s'", key_loc)
|
||||
ensure_dir(key_loc.parent)
|
||||
|
||||
# Create a random key file
|
||||
new_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||
# Write our key to disk for safe keeping
|
||||
with open(str(key_loc), 'wb') as f:
|
||||
f.write(
|
||||
new_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
RSA_KEY = key_loc.read_text()
|
||||
return RSA_KEY
|
||||
|
||||
|
||||
def get_custom_file(
|
||||
env_ref: str, conf_ref: str, log_ref: str, lookup_media: bool = False
|
||||
):
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Check if there are any pending database migrations, and run them."""
|
||||
"""Extended schema generator."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
|
@ -116,14 +116,7 @@ class InvenTreeMetadata(SimpleMetadata):
|
||||
actions = {}
|
||||
|
||||
# Map the request method to a permission type
|
||||
rolemap = {
|
||||
'OPTIONS': 'view',
|
||||
'GET': 'view',
|
||||
'POST': 'add',
|
||||
'PUT': 'change',
|
||||
'PATCH': 'change',
|
||||
'DELETE': 'delete',
|
||||
}
|
||||
rolemap = {**InvenTree.permissions.ACTION_MAP, 'OPTIONS': 'view'}
|
||||
|
||||
# let the view define a custom rolemap
|
||||
if hasattr(view, 'rolemap'):
|
||||
|
@ -86,6 +86,11 @@ class AuthRequiredMiddleware:
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
||||
# oAuth2 requests are handled by the oAuth2 library
|
||||
if request.path_info.startswith('/o/'):
|
||||
response = self.get_response(request)
|
||||
return response
|
||||
|
||||
# Is the function exempt from auth requirements?
|
||||
path_func = resolve(request.path).func
|
||||
|
||||
|
@ -1,10 +1,29 @@
|
||||
"""Permission set for InvenTree."""
|
||||
|
||||
from functools import wraps
|
||||
from typing import Optional
|
||||
|
||||
from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements
|
||||
from oauth2_provider.contrib.rest_framework.authentication import OAuth2Authentication
|
||||
from rest_framework import permissions
|
||||
|
||||
import users.permissions
|
||||
import users.ruleset
|
||||
from users.oauth2_scopes import (
|
||||
DEFAULT_READ,
|
||||
DEFAULT_STAFF,
|
||||
DEFAULT_SUPERUSER,
|
||||
get_granular_scope,
|
||||
)
|
||||
|
||||
ACTION_MAP = {
|
||||
'GET': 'view',
|
||||
'POST': 'add',
|
||||
'PUT': 'change',
|
||||
'PATCH': 'change',
|
||||
'DELETE': 'delete',
|
||||
'OPTIONS': DEFAULT_READ,
|
||||
}
|
||||
|
||||
|
||||
def get_model_for_view(view):
|
||||
@ -21,7 +40,126 @@ def get_model_for_view(view):
|
||||
raise AttributeError(f'Serializer class not specified for {view.__class__}')
|
||||
|
||||
|
||||
class RolePermission(permissions.BasePermission):
|
||||
def map_scope(
|
||||
roles: Optional[list[str]] = None,
|
||||
only_read=False,
|
||||
read_name=DEFAULT_READ,
|
||||
map_read: Optional[list[str]] = None,
|
||||
map_read_name=DEFAULT_READ,
|
||||
) -> dict:
|
||||
"""Generate the required scopes for OAS permission views.
|
||||
|
||||
Args:
|
||||
roles (Optional[list[str]]): A list of roles or tables to generate granular scopes for.
|
||||
only_read (bool): If True, only the read scope will be returned for all actions.
|
||||
read_name (str): The read scope name to use when `only_read` is True.
|
||||
map_read (Optional[list[str]]): A list of HTTP methods that should map to the default read scope (use if some actions requirea differing role).
|
||||
map_read_name (str): The read scope name to use for methods specified in `map_read` when `map_read` is specified.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping HTTP methods to their corresponding scopes.
|
||||
Each scope is represented as a list of lists of strings.
|
||||
"""
|
||||
|
||||
def scope_name(action):
|
||||
if only_read:
|
||||
return [[read_name]]
|
||||
if roles:
|
||||
return [[get_granular_scope(action, table) for table in roles]]
|
||||
return [[action]]
|
||||
|
||||
def get_scope(method, action):
|
||||
if map_read and method in map_read:
|
||||
return [[map_read_name]]
|
||||
return scope_name(action)
|
||||
|
||||
return {
|
||||
method: get_scope(method, action) if method != 'OPTIONS' else [[DEFAULT_READ]]
|
||||
for method, action in ACTION_MAP.items()
|
||||
}
|
||||
|
||||
|
||||
# Precalculate the roles mapping
|
||||
roles = users.ruleset.get_ruleset_models()
|
||||
precalculated_roles = {}
|
||||
for role, tables in roles.items():
|
||||
for table in tables:
|
||||
if table not in precalculated_roles:
|
||||
precalculated_roles[table] = []
|
||||
precalculated_roles[table].append(role)
|
||||
|
||||
|
||||
class OASTokenMixin:
|
||||
"""Mixin that combines the permissions of normal classes and token classes."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if the user has the required scopes or was authenticated another way."""
|
||||
return self.check_oauth2_authentication(
|
||||
request, view
|
||||
) or super().has_permission(request, view)
|
||||
|
||||
def check_oauth2_authentication(self, request, view):
|
||||
"""Check if the user is authenticated using OAuth2 and has the required scopes."""
|
||||
return self.is_oauth2ed(
|
||||
request
|
||||
) and TokenMatchesOASRequirements().has_permission(request, view)
|
||||
|
||||
def is_oauth2ed(self, request):
|
||||
"""Check if the user is authenticated using OAuth2."""
|
||||
oauth2authenticated = False
|
||||
if bool(request.user and request.user.is_authenticated):
|
||||
oauth2authenticated = isinstance(
|
||||
request.successful_authenticator, OAuth2Authentication
|
||||
)
|
||||
return oauth2authenticated
|
||||
|
||||
|
||||
class InvenTreeRoleScopeMixin(OASTokenMixin):
|
||||
"""Permission that discovers the required scopes from the OpenAPI schema."""
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
if hasattr(view, 'required_alternate_scopes'):
|
||||
return view.required_alternate_scopes
|
||||
try:
|
||||
# Extract the model name associated with this request
|
||||
model = get_model_for_view(view)
|
||||
calc = precalculated_roles.get(
|
||||
f'{model._meta.app_label}_{model._meta.model_name}', []
|
||||
)
|
||||
|
||||
if model is None or not calc:
|
||||
return map_scope(only_read=True)
|
||||
return map_scope(roles=calc)
|
||||
except AttributeError:
|
||||
# We will assume that if the serializer class does *not* have a Meta,
|
||||
# then we don't need a permission
|
||||
return map_scope(only_read=True)
|
||||
except Exception:
|
||||
return map_scope(only_read=True)
|
||||
|
||||
|
||||
class InvenTreeTokenMatchesOASRequirements(InvenTreeRoleScopeMixin):
|
||||
"""Combines InvenTree role-based scope handling with OpenAPI schema token requirements.
|
||||
|
||||
Usesd as default permission class.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if the user has the required scopes or was authenticated another way."""
|
||||
if self.is_oauth2ed(request):
|
||||
# Check if the user is authenticated using OAuth2 and has the required scopes
|
||||
return super().has_permission(request, view)
|
||||
|
||||
# If the user is authenticated using another method, check if they have the required permissions
|
||||
return bool(request.user and request.user.is_authenticated)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Return `True` if permission is granted, `False` otherwise."""
|
||||
return True
|
||||
|
||||
|
||||
class RolePermission(InvenTreeRoleScopeMixin, permissions.BasePermission):
|
||||
"""Role mixin for API endpoints, allowing us to specify the user "role" which is required for certain operations.
|
||||
|
||||
Each endpoint can have one or more of the following actions:
|
||||
@ -52,14 +190,7 @@ class RolePermission(permissions.BasePermission):
|
||||
return True
|
||||
|
||||
# Map the request method to a permission type
|
||||
rolemap = {
|
||||
'GET': 'view',
|
||||
'OPTIONS': 'view',
|
||||
'POST': 'add',
|
||||
'PUT': 'change',
|
||||
'PATCH': 'change',
|
||||
'DELETE': 'delete',
|
||||
}
|
||||
rolemap = {**ACTION_MAP, 'OPTIONS': 'view'}
|
||||
|
||||
# let the view define a custom rolemap
|
||||
if hasattr(view, 'rolemap'):
|
||||
@ -115,6 +246,13 @@ class RolePermissionOrReadOnly(RolePermission):
|
||||
|
||||
return request.method in permissions.SAFE_METHODS
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
scopes = map_scope(
|
||||
only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS
|
||||
)
|
||||
return scopes
|
||||
|
||||
|
||||
class StaffRolePermissionOrReadOnly(RolePermissionOrReadOnly):
|
||||
"""RolePermission which requires staff AND role access, or read-only."""
|
||||
@ -122,15 +260,19 @@ class StaffRolePermissionOrReadOnly(RolePermissionOrReadOnly):
|
||||
REQUIRE_STAFF = True
|
||||
|
||||
|
||||
class IsSuperuser(permissions.IsAdminUser):
|
||||
class IsSuperuserOrSuperScope(OASTokenMixin, permissions.IsAdminUser):
|
||||
"""Allows access only to superuser users."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if the user is a superuser."""
|
||||
return bool(request.user and request.user.is_superuser)
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
return map_scope(only_read=True, read_name=DEFAULT_SUPERUSER)
|
||||
|
||||
class IsSuperuserOrReadOnly(permissions.IsAdminUser):
|
||||
|
||||
class IsSuperuserOrReadOnlyOrScope(OASTokenMixin, permissions.IsAdminUser):
|
||||
"""Allow read-only access to any user, but write access is restricted to superuser users."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
@ -140,17 +282,59 @@ class IsSuperuserOrReadOnly(permissions.IsAdminUser):
|
||||
or request.method in permissions.SAFE_METHODS
|
||||
)
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
return map_scope(
|
||||
only_read=True,
|
||||
read_name=DEFAULT_SUPERUSER,
|
||||
map_read=permissions.SAFE_METHODS,
|
||||
)
|
||||
|
||||
class IsStaffOrReadOnly(permissions.IsAdminUser):
|
||||
"""Allows read-only access to any user, but write access is restricted to staff users."""
|
||||
|
||||
class IsAuthenticatedOrReadScope(OASTokenMixin, permissions.IsAuthenticated):
|
||||
"""Allows access only to authenticated users or read scope tokens."""
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
return map_scope(only_read=True)
|
||||
|
||||
|
||||
class IsStaffOrReadOnlyScope(OASTokenMixin, permissions.IsAuthenticated):
|
||||
"""Allows read-only access to any authenticated user, but write access is restricted to staff users."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if the user is a superuser."""
|
||||
return bool(
|
||||
"""Check if the user is a staff."""
|
||||
return bool(permissions.IsAuthenticated().has_permission(request, view)) and (
|
||||
(request.user and request.user.is_staff)
|
||||
or request.method in permissions.SAFE_METHODS
|
||||
)
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
return map_scope(
|
||||
only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS
|
||||
)
|
||||
|
||||
|
||||
class IsAdminOrAdminScope(OASTokenMixin, permissions.IsAdminUser):
|
||||
"""Allows access only to admin users or admin scope tokens."""
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
return map_scope(only_read=True, read_name=DEFAULT_STAFF)
|
||||
|
||||
|
||||
class AllowAnyOrReadScope(OASTokenMixin, permissions.AllowAny):
|
||||
"""Allows access to any user or read scope tokens."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Anyone is allowed."""
|
||||
return True
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
return map_scope(only_read=True)
|
||||
|
||||
|
||||
def auth_exempt(view_func):
|
||||
"""Mark a view function as being exempt from auth requirements."""
|
||||
@ -160,3 +344,43 @@ def auth_exempt(view_func):
|
||||
|
||||
wrapped_view.auth_exempt = True
|
||||
return wraps(view_func)(wrapped_view)
|
||||
|
||||
|
||||
class UserSettingsPermissionsOrScope(OASTokenMixin, permissions.BasePermission):
|
||||
"""Special permission class to determine if the user can view / edit a particular setting."""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check if the user that requested is also the object owner."""
|
||||
try:
|
||||
user = request.user
|
||||
except AttributeError: # pragma: no cover
|
||||
return False
|
||||
|
||||
return user == obj.user
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
return map_scope(only_read=True)
|
||||
|
||||
|
||||
class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission):
|
||||
"""Special permission class to determine if the user is "staff"."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check that the requesting user is 'admin'."""
|
||||
try:
|
||||
user = request.user
|
||||
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
# Any other methods require staff access permissions
|
||||
return user.is_staff
|
||||
|
||||
except AttributeError: # pragma: no cover
|
||||
return False
|
||||
|
||||
def get_required_alternate_scopes(self, request, view):
|
||||
"""Return the required scopes for the current request."""
|
||||
return map_scope(
|
||||
only_read=True, read_name=DEFAULT_STAFF, map_read=permissions.SAFE_METHODS
|
||||
)
|
||||
|
@ -3,10 +3,36 @@
|
||||
from itertools import chain
|
||||
from typing import Optional
|
||||
|
||||
from drf_spectacular.contrib.django_oauth_toolkit import DjangoOAuthToolkitScheme
|
||||
from drf_spectacular.drainage import warn
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
from drf_spectacular.plumbing import ComponentRegistry
|
||||
from drf_spectacular.utils import _SchemaType
|
||||
|
||||
from InvenTree.permissions import OASTokenMixin
|
||||
from users.oauth2_scopes import oauth2_scopes
|
||||
|
||||
|
||||
class ExtendedOAuth2Scheme(DjangoOAuthToolkitScheme):
|
||||
"""Extend drf-spectacular to allow customizing the schema to match the actual API behavior."""
|
||||
|
||||
target_class = 'users.authentication.ExtendedOAuth2Authentication'
|
||||
|
||||
def get_security_requirement(self, auto_schema):
|
||||
"""Get the security requirement for the current view."""
|
||||
ret = super().get_security_requirement(auto_schema)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
# If no security requirement is found, try if the view uses our OASTokenMixin
|
||||
for permission in auto_schema.view.get_permissions():
|
||||
if isinstance(permission, OASTokenMixin):
|
||||
alt_scopes = permission.get_required_alternate_scopes(
|
||||
auto_schema.view.request, auto_schema.view
|
||||
)
|
||||
alt_scopes = alt_scopes.get(auto_schema.method, [])
|
||||
return [{self.name: group} for group in alt_scopes]
|
||||
|
||||
|
||||
class ExtendedAutoSchema(AutoSchema):
|
||||
"""Extend drf-spectacular to allow customizing the schema to match the actual API behavior."""
|
||||
@ -122,4 +148,9 @@ def postprocess_print_stats(result, generator, request, public):
|
||||
print(f' {scope}: {len(paths)}')
|
||||
print()
|
||||
|
||||
# Check for unknown scopes
|
||||
for scope, paths in scopes.items():
|
||||
if scope not in oauth2_scopes:
|
||||
warn(f'unknown scope `{scope}` in {len(paths)} paths')
|
||||
|
||||
return result
|
||||
|
@ -25,10 +25,16 @@ from corsheaders.defaults import default_headers as default_cors_headers
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from InvenTree.cache import get_cache_config, is_global_cache_enabled
|
||||
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
|
||||
from InvenTree.config import (
|
||||
get_boolean_setting,
|
||||
get_custom_file,
|
||||
get_oidc_private_key,
|
||||
get_setting,
|
||||
)
|
||||
from InvenTree.ready import isInMainThread
|
||||
from InvenTree.sentry import default_sentry_dsn, init_sentry
|
||||
from InvenTree.version import checkMinPythonVersion, inventreeApiVersion
|
||||
from users.oauth2_scopes import oauth2_scopes
|
||||
|
||||
from . import config, locales
|
||||
|
||||
@ -299,6 +305,7 @@ INSTALLED_APPS = [
|
||||
'django_otp', # OTP is needed for MFA - base package
|
||||
'django_otp.plugins.otp_totp', # Time based OTP
|
||||
'django_otp.plugins.otp_static', # Backup codes
|
||||
'oauth2_provider', # OAuth2 provider and API access
|
||||
'drf_spectacular', # API documentation
|
||||
'django_ical', # For exporting calendars
|
||||
]
|
||||
@ -322,6 +329,7 @@ MIDDLEWARE = CONFIG.get(
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'InvenTree.middleware.AuthRequiredMiddleware',
|
||||
'InvenTree.middleware.Check2FAMiddleware', # Check if the user should be forced to use MFA
|
||||
'oauth2_provider.middleware.OAuth2TokenMiddleware', # oauth2_provider
|
||||
'maintenance_mode.middleware.MaintenanceModeMiddleware',
|
||||
'InvenTree.middleware.InvenTreeExceptionProcessor', # Error reporting
|
||||
'InvenTree.middleware.InvenTreeRequestCacheMiddleware', # Request caching
|
||||
@ -354,6 +362,7 @@ QUERYCOUNT = {
|
||||
AUTHENTICATION_BACKENDS = CONFIG.get(
|
||||
'authentication_backends',
|
||||
[
|
||||
'oauth2_provider.backends.OAuth2Backend', # OAuth2 provider
|
||||
'django.contrib.auth.backends.RemoteUserBackend', # proxy login
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'allauth.account.auth_backends.AuthenticationBackend', # SSO login via external providers
|
||||
@ -523,6 +532,15 @@ TEMPLATES = [
|
||||
}
|
||||
]
|
||||
|
||||
OAUTH2_PROVIDER = {
|
||||
# default scopes
|
||||
'SCOPES': oauth2_scopes,
|
||||
# OIDC
|
||||
'OIDC_ENABLED': True,
|
||||
'OIDC_RSA_PRIVATE_KEY': get_oidc_private_key(),
|
||||
'PKCE_REQUIRED': False,
|
||||
}
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler',
|
||||
'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
|
||||
@ -530,12 +548,14 @@ REST_FRAMEWORK = {
|
||||
'users.authentication.ApiTokenAuthentication',
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'users.authentication.ExtendedOAuth2Authentication',
|
||||
],
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
'rest_framework.permissions.DjangoModelPermissions',
|
||||
'InvenTree.permissions.RolePermission',
|
||||
'InvenTree.permissions.InvenTreeTokenMatchesOASRequirements',
|
||||
],
|
||||
'DEFAULT_SCHEMA_CLASS': 'InvenTree.schema.ExtendedAutoSchema',
|
||||
'DEFAULT_METADATA_CLASS': 'InvenTree.metadata.InvenTreeMetadata',
|
||||
@ -1401,6 +1421,7 @@ FLAGS = {
|
||||
'NEXT_GEN': [
|
||||
{'condition': 'parameter', 'value': 'ngen='}
|
||||
], # Should next-gen features be turned on?
|
||||
'OIDC': [{'condition': 'parameter', 'value': 'oidc='}],
|
||||
}
|
||||
|
||||
# Get custom flags from environment/yaml
|
||||
@ -1445,6 +1466,11 @@ SPECTACULAR_SETTINGS = {
|
||||
'UnauthorizedStatus': [[401, 401]],
|
||||
'IsTrueEnum': [[True, True]],
|
||||
},
|
||||
# oAuth2
|
||||
'OAUTH2_FLOWS': ['authorizationCode', 'clientCredentials'],
|
||||
'OAUTH2_AUTHORIZATION_URL': '/o/authorize/',
|
||||
'OAUTH2_TOKEN_URL': '/o/token/',
|
||||
'OAUTH2_REFRESH_URL': '/o/revoke_token/',
|
||||
}
|
||||
|
||||
if SITE_URL and not TESTING:
|
||||
|
@ -709,3 +709,13 @@ def email_user(user_id: int, subject: str, message: str) -> None:
|
||||
|
||||
if email := get_email_for_user(user):
|
||||
send_email(subject, message, [email])
|
||||
|
||||
|
||||
@scheduled_task(ScheduledTask.DAILY)
|
||||
def run_oauth_maintenance():
|
||||
"""Run the OAuth maintenance task(s)."""
|
||||
from oauth2_provider.models import clear_expired
|
||||
|
||||
logger.info('Starting OAuth maintenance task')
|
||||
clear_expired()
|
||||
logger.info('Completed OAuth maintenance task')
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Testts for custom InvenTree management commands."""
|
||||
"""Tests for custom InvenTree management commands."""
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
@ -9,5 +9,5 @@ class CommandTestCase(TestCase):
|
||||
|
||||
def test_schema(self):
|
||||
"""Test the schema generation command."""
|
||||
output = call_command('schema', verbosity=0)
|
||||
output = call_command('schema', file='schema.yml', verbosity=0)
|
||||
self.assertEqual(output, 'done')
|
||||
|
@ -12,6 +12,8 @@ from django.views.generic.base import RedirectView
|
||||
|
||||
from allauth.headless.urls import Client, build_urlpatterns
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView
|
||||
from flags.urls import flagged_path
|
||||
from oauth2_provider import urls as oauth2_urls
|
||||
from sesame.views import LoginView
|
||||
|
||||
import build.api
|
||||
@ -125,6 +127,8 @@ backendpatterns = [
|
||||
), # Used for (DRF) browsable API auth
|
||||
path('auth/', auth_request), # Used for proxies to check if user is authenticated
|
||||
path('accounts/', include('allauth.urls')),
|
||||
# OAuth2
|
||||
flagged_path('OIDC', 'o/', include(oauth2_urls)),
|
||||
path(
|
||||
'accounts/login/',
|
||||
RedirectView.as_view(url=f'/{settings.FRONTEND_URL_BASE}', permanent=False),
|
||||
|
@ -20,9 +20,9 @@ from djmoney.contrib.exchange.models import ExchangeBackend, Rate
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from error_report.models import Error
|
||||
from pint._typing import UnitLike
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotAcceptable, NotFound, PermissionDenied
|
||||
from rest_framework.permissions import IsAdminUser
|
||||
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
@ -44,7 +44,15 @@ from InvenTree.mixins import (
|
||||
RetrieveUpdateAPI,
|
||||
RetrieveUpdateDestroyAPI,
|
||||
)
|
||||
from InvenTree.permissions import IsStaffOrReadOnly, IsSuperuser
|
||||
from InvenTree.permissions import (
|
||||
AllowAnyOrReadScope,
|
||||
GlobalSettingsPermissions,
|
||||
IsAdminOrAdminScope,
|
||||
IsAuthenticatedOrReadScope,
|
||||
IsStaffOrReadOnlyScope,
|
||||
IsSuperuserOrSuperScope,
|
||||
UserSettingsPermissionsOrScope,
|
||||
)
|
||||
from plugin.models import NotificationUserSetting
|
||||
from plugin.serializers import NotificationUserSettingSerializer
|
||||
|
||||
@ -134,7 +142,7 @@ class WebhookView(CsrfExemptMixin, APIView):
|
||||
class CurrencyExchangeView(APIView):
|
||||
"""API endpoint for displaying currency information."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
serializer_class = None
|
||||
|
||||
@extend_schema(responses={200: common.serializers.CurrencyExchangeSerializer})
|
||||
@ -178,7 +186,7 @@ class CurrencyRefreshView(APIView):
|
||||
User must be a 'staff' user to access this endpoint
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser]
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
serializer_class = None
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@ -208,6 +216,7 @@ class GlobalSettingsList(SettingsList):
|
||||
|
||||
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith='_')
|
||||
serializer_class = common.serializers.GlobalSettingsSerializer
|
||||
permission_classes = [IsAuthenticated, GlobalSettingsPermissions]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Ensure all global settings are created."""
|
||||
@ -215,23 +224,6 @@ class GlobalSettingsList(SettingsList):
|
||||
return super().list(request, *args, **kwargs)
|
||||
|
||||
|
||||
class GlobalSettingsPermissions(permissions.BasePermission):
|
||||
"""Special permission class to determine if the user is "staff"."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check that the requesting user is 'admin'."""
|
||||
try:
|
||||
user = request.user
|
||||
|
||||
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
||||
return True
|
||||
# Any other methods require staff access permissions
|
||||
return user.is_staff
|
||||
|
||||
except AttributeError: # pragma: no cover
|
||||
return False
|
||||
|
||||
|
||||
class GlobalSettingsDetail(RetrieveUpdateAPI):
|
||||
"""Detail view for an individual "global setting" object.
|
||||
|
||||
@ -241,6 +233,7 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
|
||||
lookup_field = 'key'
|
||||
queryset = common.models.InvenTreeSetting.objects.exclude(key__startswith='_')
|
||||
serializer_class = common.serializers.GlobalSettingsSerializer
|
||||
permission_classes = [IsAuthenticated, GlobalSettingsPermissions]
|
||||
|
||||
def get_object(self):
|
||||
"""Attempt to find a global setting object with the provided key."""
|
||||
@ -253,17 +246,13 @@ class GlobalSettingsDetail(RetrieveUpdateAPI):
|
||||
key, cache=False, create=True
|
||||
)
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, GlobalSettingsPermissions]
|
||||
|
||||
|
||||
class UserSettingsList(SettingsList):
|
||||
"""API endpoint for accessing a list of user settings objects."""
|
||||
|
||||
queryset = common.models.InvenTreeUserSetting.objects.all()
|
||||
serializer_class = common.serializers.UserSettingsSerializer
|
||||
|
||||
# Note: Any user can view and edit their own settings
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [UserSettingsPermissionsOrScope]
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Ensure all user settings are created."""
|
||||
@ -291,19 +280,6 @@ class UserSettingsList(SettingsList):
|
||||
return queryset
|
||||
|
||||
|
||||
class UserSettingsPermissions(permissions.BasePermission):
|
||||
"""Special permission class to determine if the user can view / edit a particular setting."""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check if the user that requested is also the object owner."""
|
||||
try:
|
||||
user = request.user
|
||||
except AttributeError: # pragma: no cover
|
||||
return False
|
||||
|
||||
return user == obj.user
|
||||
|
||||
|
||||
class UserSettingsDetail(RetrieveUpdateAPI):
|
||||
"""Detail view for an individual "user setting" object.
|
||||
|
||||
@ -313,6 +289,7 @@ class UserSettingsDetail(RetrieveUpdateAPI):
|
||||
lookup_field = 'key'
|
||||
queryset = common.models.InvenTreeUserSetting.objects.all()
|
||||
serializer_class = common.serializers.UserSettingsSerializer
|
||||
permission_classes = [UserSettingsPermissionsOrScope]
|
||||
|
||||
def get_object(self):
|
||||
"""Attempt to find a user setting object with the provided key."""
|
||||
@ -328,14 +305,13 @@ class UserSettingsDetail(RetrieveUpdateAPI):
|
||||
key, user=self.request.user, cache=False, create=True
|
||||
)
|
||||
|
||||
permission_classes = [UserSettingsPermissions]
|
||||
|
||||
|
||||
class NotificationUserSettingsList(SettingsList):
|
||||
"""API endpoint for accessing a list of notification user settings objects."""
|
||||
|
||||
queryset = NotificationUserSetting.objects.all()
|
||||
serializer_class = NotificationUserSettingSerializer
|
||||
permission_classes = [UserSettingsPermissionsOrScope]
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Only list settings which apply to the current user."""
|
||||
@ -357,7 +333,7 @@ class NotificationUserSettingsDetail(RetrieveUpdateAPI):
|
||||
|
||||
queryset = NotificationUserSetting.objects.all()
|
||||
serializer_class = NotificationUserSettingSerializer
|
||||
permission_classes = [UserSettingsPermissions]
|
||||
permission_classes = [UserSettingsPermissionsOrScope]
|
||||
|
||||
|
||||
class NotificationMessageMixin:
|
||||
@ -365,7 +341,7 @@ class NotificationMessageMixin:
|
||||
|
||||
queryset = common.models.NotificationMessage.objects.all()
|
||||
serializer_class = common.serializers.NotificationMessageSerializer
|
||||
permission_classes = [UserSettingsPermissions]
|
||||
permission_classes = [UserSettingsPermissionsOrScope]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return prefetched queryset."""
|
||||
@ -387,7 +363,7 @@ class NotificationMessageMixin:
|
||||
class NotificationList(NotificationMessageMixin, BulkDeleteMixin, ListAPI):
|
||||
"""List view for all notifications of the current user."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
@ -440,7 +416,7 @@ class NewsFeedMixin:
|
||||
|
||||
queryset = common.models.NewsFeedEntry.objects.all()
|
||||
serializer_class = common.serializers.NewsFeedEntrySerializer
|
||||
permission_classes = [IsAdminUser]
|
||||
permission_classes = [IsAdminOrAdminScope]
|
||||
|
||||
|
||||
class NewsFeedEntryList(NewsFeedMixin, BulkDeleteMixin, ListAPI):
|
||||
@ -464,7 +440,7 @@ class ConfigList(ListAPI):
|
||||
|
||||
queryset = CONFIG_LOOKUPS
|
||||
serializer_class = common.serializers.ConfigSerializer
|
||||
permission_classes = [IsSuperuser]
|
||||
permission_classes = [IsSuperuserOrSuperScope]
|
||||
|
||||
# Specifically disable pagination for this view
|
||||
pagination_class = None
|
||||
@ -474,7 +450,7 @@ class ConfigDetail(RetrieveAPI):
|
||||
"""Detail view for an individual configuration."""
|
||||
|
||||
serializer_class = common.serializers.ConfigSerializer
|
||||
permission_classes = [IsSuperuser]
|
||||
permission_classes = [IsSuperuserOrSuperScope]
|
||||
|
||||
def get_object(self):
|
||||
"""Attempt to find a config object with the provided key."""
|
||||
@ -490,7 +466,7 @@ class NotesImageList(ListCreateAPI):
|
||||
|
||||
queryset = common.models.NotesImage.objects.all()
|
||||
serializer_class = common.serializers.NotesImageSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
@ -508,7 +484,7 @@ class ProjectCodeList(DataExportViewMixin, ListCreateAPI):
|
||||
|
||||
queryset = common.models.ProjectCode.objects.all()
|
||||
serializer_class = common.serializers.ProjectCodeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
permission_classes = [IsStaffOrReadOnlyScope]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
ordering_fields = ['code']
|
||||
@ -521,7 +497,7 @@ class ProjectCodeDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
queryset = common.models.ProjectCode.objects.all()
|
||||
serializer_class = common.serializers.ProjectCodeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
permission_classes = [IsStaffOrReadOnlyScope]
|
||||
|
||||
|
||||
class CustomUnitList(DataExportViewMixin, ListCreateAPI):
|
||||
@ -529,7 +505,7 @@ class CustomUnitList(DataExportViewMixin, ListCreateAPI):
|
||||
|
||||
queryset = common.models.CustomUnit.objects.all()
|
||||
serializer_class = common.serializers.CustomUnitSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
permission_classes = [IsStaffOrReadOnlyScope]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
|
||||
@ -538,14 +514,14 @@ class CustomUnitDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
queryset = common.models.CustomUnit.objects.all()
|
||||
serializer_class = common.serializers.CustomUnitSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
permission_classes = [IsStaffOrReadOnlyScope]
|
||||
|
||||
|
||||
class AllUnitList(RetrieveAPI):
|
||||
"""List of all defined units."""
|
||||
|
||||
serializer_class = common.serializers.AllUnitListResponseSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
permission_classes = [IsStaffOrReadOnlyScope]
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""Return a list of all available units."""
|
||||
@ -576,7 +552,7 @@ class ErrorMessageList(BulkDeleteMixin, ListAPI):
|
||||
|
||||
queryset = Error.objects.all()
|
||||
serializer_class = common.serializers.ErrorMessageSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
|
||||
@ -592,13 +568,13 @@ class ErrorMessageDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
queryset = Error.objects.all()
|
||||
serializer_class = common.serializers.ErrorMessageSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
|
||||
|
||||
class BackgroundTaskOverview(APIView):
|
||||
"""Provides an overview of the background task queue status."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
serializer_class = None
|
||||
|
||||
def get(self, request, fmt=None):
|
||||
@ -620,7 +596,7 @@ class BackgroundTaskOverview(APIView):
|
||||
class PendingTaskList(BulkDeleteMixin, ListAPI):
|
||||
"""Provides a read-only list of currently pending tasks."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
|
||||
queryset = django_q.models.OrmQ.objects.all()
|
||||
serializer_class = common.serializers.PendingTaskSerializer
|
||||
@ -629,7 +605,7 @@ class PendingTaskList(BulkDeleteMixin, ListAPI):
|
||||
class ScheduledTaskList(ListAPI):
|
||||
"""Provides a read-only list of currently scheduled tasks."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
|
||||
queryset = django_q.models.Schedule.objects.all()
|
||||
serializer_class = common.serializers.ScheduledTaskSerializer
|
||||
@ -649,7 +625,7 @@ class ScheduledTaskList(ListAPI):
|
||||
class FailedTaskList(BulkDeleteMixin, ListAPI):
|
||||
"""Provides a read-only list of currently failed tasks."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, IsAdminUser]
|
||||
permission_classes = [IsAuthenticatedOrReadScope, IsAdminUser]
|
||||
|
||||
queryset = django_q.models.Failure.objects.all()
|
||||
serializer_class = common.serializers.FailedTaskSerializer
|
||||
@ -666,14 +642,14 @@ class FlagList(ListAPI):
|
||||
|
||||
queryset = settings.FLAGS
|
||||
serializer_class = common.serializers.FlagSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
permission_classes = [AllowAnyOrReadScope]
|
||||
|
||||
|
||||
class FlagDetail(RetrieveAPI):
|
||||
"""Detail view for an individual feature flag."""
|
||||
|
||||
serializer_class = common.serializers.FlagSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
permission_classes = [AllowAnyOrReadScope]
|
||||
|
||||
def get_object(self):
|
||||
"""Attempt to find a config object with the provided key."""
|
||||
@ -689,7 +665,7 @@ class ContentTypeList(ListAPI):
|
||||
|
||||
queryset = ContentType.objects.all()
|
||||
serializer_class = common.serializers.ContentTypeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
search_fields = ['app_label', 'model']
|
||||
|
||||
@ -699,7 +675,7 @@ class ContentTypeDetail(RetrieveAPI):
|
||||
|
||||
queryset = ContentType.objects.all()
|
||||
serializer_class = common.serializers.ContentTypeSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
|
||||
class ContentTypeModelDetail(ContentTypeDetail):
|
||||
@ -753,7 +729,7 @@ class AttachmentList(BulkDeleteMixin, ListCreateAPI):
|
||||
|
||||
queryset = common.models.Attachment.objects.all()
|
||||
serializer_class = common.serializers.AttachmentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
filterset_class = AttachmentFilter
|
||||
@ -791,7 +767,7 @@ class AttachmentDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
queryset = common.models.Attachment.objects.all()
|
||||
serializer_class = common.serializers.AttachmentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
"""Check user permissions before deleting an attachment."""
|
||||
@ -810,7 +786,7 @@ class IconList(ListAPI):
|
||||
"""List view for available icon packages."""
|
||||
|
||||
serializer_class = common.serializers.IconPackageSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
permission_classes = [AllowAnyOrReadScope]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return a list of all available icon packages."""
|
||||
@ -822,7 +798,7 @@ class SelectionListList(ListCreateAPI):
|
||||
|
||||
queryset = common.models.SelectionList.objects.all()
|
||||
serializer_class = common.serializers.SelectionListSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Override the queryset method to include entry count."""
|
||||
@ -834,7 +810,7 @@ class SelectionListDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
queryset = common.models.SelectionList.objects.all()
|
||||
serializer_class = common.serializers.SelectionListSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
|
||||
class EntryMixin:
|
||||
@ -842,7 +818,7 @@ class EntryMixin:
|
||||
|
||||
queryset = common.models.SelectionListEntry.objects.all()
|
||||
serializer_class = common.serializers.SelectionEntrySerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
lookup_url_kwarg = 'entrypk'
|
||||
|
||||
def get_queryset(self):
|
||||
@ -861,22 +837,22 @@ class SelectionEntryDetail(EntryMixin, RetrieveUpdateDestroyAPI):
|
||||
"""Detail view for a SelectionEntry object."""
|
||||
|
||||
|
||||
class DataOutputEndpoint:
|
||||
class DataOutputEndpointMixin:
|
||||
"""Mixin class for DataOutput endpoints."""
|
||||
|
||||
queryset = common.models.DataOutput.objects.all()
|
||||
serializer_class = common.serializers.DataOutputSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [IsAuthenticatedOrReadScope]
|
||||
|
||||
|
||||
class DataOutputList(DataOutputEndpoint, BulkDeleteMixin, ListAPI):
|
||||
class DataOutputList(DataOutputEndpointMixin, BulkDeleteMixin, ListAPI):
|
||||
"""List view for DataOutput objects."""
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = ['pk', 'user', 'plugin', 'output_type', 'created']
|
||||
|
||||
|
||||
class DataOutputDetail(DataOutputEndpoint, RetrieveAPI):
|
||||
class DataOutputDetail(DataOutputEndpointMixin, RetrieveAPI):
|
||||
"""Detail view for a DataOutput object."""
|
||||
|
||||
|
||||
@ -1014,7 +990,10 @@ common_api_urls = [
|
||||
include([
|
||||
path(
|
||||
'metadata/',
|
||||
MetadataView.as_view(model=common.models.ProjectCode),
|
||||
MetadataView.as_view(
|
||||
model=common.models.ProjectCode,
|
||||
permission_classes=[IsStaffOrReadOnlyScope],
|
||||
),
|
||||
name='api-project-code-metadata',
|
||||
),
|
||||
path(
|
||||
|
@ -1173,7 +1173,7 @@ class CommonTest(InvenTreeAPITestCase):
|
||||
"""Test flag URLs."""
|
||||
# Not superuser
|
||||
response = self.get(reverse('api-flag-list'), expected_code=200)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(response.data[0]['key'], 'EXPERIMENTAL')
|
||||
|
||||
# Turn into superuser
|
||||
@ -1182,7 +1182,7 @@ class CommonTest(InvenTreeAPITestCase):
|
||||
|
||||
# Successful checks
|
||||
response = self.get(reverse('api-flag-list'), expected_code=200)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(response.data[0]['key'], 'EXPERIMENTAL')
|
||||
self.assertTrue(response.data[0]['conditions'])
|
||||
|
||||
|
@ -5,6 +5,9 @@
|
||||
# Secret key for backend
|
||||
# Use the environment variable INVENTREE_SECRET_KEY_FILE
|
||||
#secret_key_file: '/etc/inventree/secret_key.txt'
|
||||
# Use the environment variable INVENTREE_OIDC_PRIVATE_KEY or INVENTREE_OIDC_PRIVATE_KEY_FILE
|
||||
#oidc_private_key: '-----BEGIN PRIVATE KEY-----aaa'
|
||||
#oidc_private_key_file: '/etc/inventree/oidc.key'
|
||||
|
||||
# Database backend selection - Configure backend database settings
|
||||
# Documentation: https://docs.inventree.org/en/latest/start/config/
|
||||
|
@ -5,16 +5,16 @@ import inspect
|
||||
from django.urls import include, path
|
||||
|
||||
from drf_spectacular.utils import OpenApiResponse, extend_schema
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework import serializers
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
import common.models
|
||||
import common.serializers
|
||||
import InvenTree.permissions
|
||||
from data_exporter.mixins import DataExportViewMixin
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateDestroyAPI
|
||||
from InvenTree.permissions import IsStaffOrReadOnly
|
||||
from InvenTree.serializers import EmptySerializer
|
||||
|
||||
from .serializers import GenericStateClassSerializer
|
||||
@ -36,7 +36,7 @@ class StatusView(GenericAPIView):
|
||||
all available 'StockStatus' codes
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = GenericStateClassSerializer
|
||||
|
||||
# Override status_class for implementing subclass
|
||||
@ -96,7 +96,7 @@ class StatusView(GenericAPIView):
|
||||
class AllStatusViews(StatusView):
|
||||
"""Endpoint for listing all defined status models."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = EmptySerializer
|
||||
|
||||
@extend_schema(operation_id='generic_status_retrieve_all')
|
||||
@ -136,7 +136,7 @@ class CustomStateList(DataExportViewMixin, ListCreateAPI):
|
||||
|
||||
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
|
||||
serializer_class = common.serializers.CustomStateSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
|
||||
filter_backends = SEARCH_ORDER_FILTER
|
||||
ordering_fields = ['key']
|
||||
search_fields = ['key', 'name', 'label', 'reference_status']
|
||||
@ -148,7 +148,7 @@ class CustomStateDetail(RetrieveUpdateDestroyAPI):
|
||||
|
||||
queryset = common.models.InvenTreeCustomUserStateModel.objects.all()
|
||||
serializer_class = common.serializers.CustomStateSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
|
||||
|
||||
|
||||
urlpattern = [
|
||||
|
@ -12,6 +12,7 @@ from rest_framework.views import APIView
|
||||
import importer.models
|
||||
import importer.registry
|
||||
import importer.serializers
|
||||
import InvenTree.permissions
|
||||
from InvenTree.api import BulkDeleteMixin
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import (
|
||||
@ -28,7 +29,7 @@ class DataImporterPermission(permissions.BasePermission):
|
||||
"""Mixin class for determining if the user has correct permissions."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Class level permission checks are handled via permissions.IsAuthenticated."""
|
||||
"""Class level permission checks are handled via InvenTree.permissions.IsAuthenticatedOrReadScope."""
|
||||
return True
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
@ -53,7 +54,10 @@ class DataImporterPermissionMixin:
|
||||
"""Mixin class for checking permissions on DataImporter objects."""
|
||||
|
||||
# Default permissions: User must be authenticated
|
||||
permission_classes = [permissions.IsAuthenticated, DataImporterPermission]
|
||||
permission_classes = [
|
||||
InvenTree.permissions.IsAuthenticatedOrReadScope,
|
||||
DataImporterPermission,
|
||||
]
|
||||
|
||||
|
||||
class DataImporterModelSerializer(serializers.Serializer):
|
||||
@ -67,7 +71,7 @@ class DataImporterModelSerializer(serializers.Serializer):
|
||||
class DataImporterModelList(APIView):
|
||||
"""API endpoint for displaying a list of models available for import."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = DataImporterModelSerializer(many=True)
|
||||
|
||||
def get(self, request):
|
||||
@ -110,7 +114,7 @@ class DataImportSessionDetail(DataImporterPermission, RetrieveUpdateDestroyAPI):
|
||||
class DataImportSessionAcceptFields(APIView):
|
||||
"""API endpoint to accept the field mapping for a DataImportSession."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = None
|
||||
|
||||
@extend_schema(
|
||||
|
@ -3,11 +3,11 @@
|
||||
from django.urls import include, path, re_path
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import permissions
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import InvenTree.permissions
|
||||
import machine.serializers as MachineSerializers
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.mixins import ListCreateAPI, RetrieveUpdateAPI, RetrieveUpdateDestroyAPI
|
||||
@ -78,7 +78,7 @@ class MachineSettingList(APIView):
|
||||
- GET: return all settings for a machine config
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
@extend_schema(
|
||||
responses={200: MachineSerializers.MachineSettingSerializer(many=True)}
|
||||
@ -142,7 +142,7 @@ class MachineRestart(APIView):
|
||||
- POST: restart machine by pk
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
@extend_schema(
|
||||
request=None, responses={200: MachineSerializers.MachineRestartSerializer()}
|
||||
@ -162,7 +162,7 @@ class MachineTypesList(APIView):
|
||||
- GET: List all machine types
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
@extend_schema(responses={200: MachineSerializers.MachineTypeSerializer(many=True)})
|
||||
def get(self, request):
|
||||
@ -180,7 +180,7 @@ class MachineDriverList(APIView):
|
||||
- GET: List all machine drivers
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
@extend_schema(
|
||||
responses={200: MachineSerializers.MachineDriverSerializer(many=True)}
|
||||
@ -203,7 +203,7 @@ class RegistryStatusView(APIView):
|
||||
- GET: Provide status data for the machine registry
|
||||
"""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
serializer_class = MachineSerializers.MachineRegistryStatusSerializer
|
||||
|
||||
|
@ -12,10 +12,11 @@ from django_filters import rest_framework as rest_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.response import Response
|
||||
|
||||
import InvenTree.permissions
|
||||
import order.models
|
||||
import part.filters
|
||||
from build.models import Build, BuildItem
|
||||
@ -41,7 +42,6 @@ from InvenTree.mixins import (
|
||||
RetrieveUpdateDestroyAPI,
|
||||
UpdateAPI,
|
||||
)
|
||||
from InvenTree.permissions import RolePermission
|
||||
from InvenTree.serializers import EmptySerializer
|
||||
from order.status_codes import PurchaseOrderStatusGroups, SalesOrderStatusGroups
|
||||
from stock.models import StockLocation
|
||||
@ -1738,7 +1738,10 @@ class PartStocktakeReportGenerate(CreateAPI):
|
||||
|
||||
serializer_class = part_serializers.PartStocktakeReportGenerateSerializer
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated, RolePermission]
|
||||
permission_classes = [
|
||||
InvenTree.permissions.IsAuthenticatedOrReadScope,
|
||||
InvenTree.permissions.RolePermission,
|
||||
]
|
||||
|
||||
role_required = 'stocktake'
|
||||
|
||||
|
@ -9,13 +9,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import InvenTree.permissions
|
||||
import plugin.serializers as PluginSerializers
|
||||
from common.api import GlobalSettingsPermissions
|
||||
from InvenTree.api import MetadataView
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.helpers import str2bool
|
||||
@ -27,7 +27,6 @@ from InvenTree.mixins import (
|
||||
RetrieveUpdateAPI,
|
||||
UpdateAPI,
|
||||
)
|
||||
from InvenTree.permissions import IsSuperuser, IsSuperuserOrReadOnly
|
||||
from plugin.base.action.api import ActionPluginView
|
||||
from plugin.base.barcodes.api import barcode_api_urls
|
||||
from plugin.base.locate.api import LocatePluginView
|
||||
@ -138,7 +137,7 @@ class PluginList(ListAPI):
|
||||
# Allow any logged in user to read this endpoint
|
||||
# This is necessary to allow certain functionality,
|
||||
# e.g. determining which label printing plugins are available
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
filterset_class = PluginFilter
|
||||
|
||||
@ -169,7 +168,7 @@ class PluginDetail(RetrieveDestroyAPI):
|
||||
|
||||
queryset = PluginConfig.objects.all()
|
||||
serializer_class = PluginSerializers.PluginConfigSerializer
|
||||
permission_classes = [IsSuperuserOrReadOnly]
|
||||
permission_classes = [InvenTree.permissions.IsSuperuserOrReadOnlyOrScope]
|
||||
lookup_field = 'key'
|
||||
lookup_url_kwarg = 'plugin'
|
||||
|
||||
@ -228,7 +227,7 @@ class PluginUninstall(UpdateAPI):
|
||||
|
||||
queryset = PluginConfig.objects.all()
|
||||
serializer_class = PluginSerializers.PluginUninstallSerializer
|
||||
permission_classes = [IsSuperuser]
|
||||
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
|
||||
lookup_field = 'key'
|
||||
lookup_url_kwarg = 'plugin'
|
||||
|
||||
@ -249,7 +248,7 @@ class PluginActivate(UpdateAPI):
|
||||
|
||||
queryset = PluginConfig.objects.all()
|
||||
serializer_class = PluginSerializers.PluginActivateSerializer
|
||||
permission_classes = [IsSuperuser]
|
||||
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
|
||||
lookup_field = 'key'
|
||||
lookup_url_kwarg = 'plugin'
|
||||
|
||||
@ -269,7 +268,7 @@ class PluginReload(CreateAPI):
|
||||
|
||||
queryset = PluginConfig.objects.none()
|
||||
serializer_class = PluginSerializers.PluginReloadSerializer
|
||||
permission_classes = [IsSuperuser]
|
||||
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Saving the serializer instance performs plugin installation."""
|
||||
@ -286,7 +285,7 @@ class PluginSettingList(ListAPI):
|
||||
queryset = PluginSetting.objects.all()
|
||||
serializer_class = PluginSerializers.PluginSettingSerializer
|
||||
|
||||
permission_classes = [GlobalSettingsPermissions]
|
||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
|
||||
@ -359,7 +358,7 @@ class PluginAllSettingList(APIView):
|
||||
- GET: return all settings for a plugin config
|
||||
"""
|
||||
|
||||
permission_classes = [GlobalSettingsPermissions]
|
||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||
|
||||
@extend_schema(
|
||||
responses={200: PluginSerializers.PluginSettingSerializer(many=True)}
|
||||
@ -413,7 +412,7 @@ class PluginSettingDetail(RetrieveUpdateAPI):
|
||||
)
|
||||
|
||||
# Staff permission required
|
||||
permission_classes = [GlobalSettingsPermissions]
|
||||
permission_classes = [InvenTree.permissions.GlobalSettingsPermissions]
|
||||
|
||||
|
||||
class RegistryStatusView(APIView):
|
||||
@ -422,7 +421,7 @@ class RegistryStatusView(APIView):
|
||||
- GET: Provide status data for the plugin registry
|
||||
"""
|
||||
|
||||
permission_classes = [IsSuperuser]
|
||||
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
|
||||
|
||||
serializer_class = PluginSerializers.PluginRegistryStatusSerializer
|
||||
|
||||
|
@ -2,10 +2,11 @@
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework import serializers
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
import InvenTree.permissions
|
||||
from InvenTree.exceptions import log_error
|
||||
from plugin import PluginMixinEnum, registry
|
||||
|
||||
@ -20,7 +21,7 @@ class ActionPluginSerializer(serializers.Serializer):
|
||||
class ActionPluginView(GenericAPIView):
|
||||
"""Endpoint for running custom action plugins."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = ActionPluginSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
@ -7,12 +7,13 @@ from django.utils.translation import gettext_lazy as _
|
||||
import structlog
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.generics import CreateAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
import common.models
|
||||
import InvenTree.permissions
|
||||
import order.models
|
||||
import plugin.base.barcodes.helper
|
||||
import stock.models
|
||||
@ -22,7 +23,6 @@ from InvenTree.exceptions import log_error
|
||||
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||
from InvenTree.helpers import hash_barcode
|
||||
from InvenTree.mixins import ListAPI, RetrieveDestroyAPI
|
||||
from InvenTree.permissions import IsStaffOrReadOnly
|
||||
from plugin import PluginMixinEnum, registry
|
||||
from users.permissions import check_user_permission
|
||||
|
||||
@ -107,7 +107,7 @@ class BarcodeView(CreateAPIView):
|
||||
return None
|
||||
|
||||
# Default permission classes (can be overridden)
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Handle create method - override default create."""
|
||||
@ -236,7 +236,7 @@ class BarcodeGenerate(CreateAPIView):
|
||||
return None
|
||||
|
||||
# Default permission classes (can be overridden)
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""Perform the barcode generation action."""
|
||||
@ -758,7 +758,7 @@ class BarcodeScanResultMixin:
|
||||
|
||||
queryset = common.models.BarcodeScanResult.objects.all()
|
||||
serializer_class = barcode_serializers.BarcodeScanResultSerializer
|
||||
permission_classes = [permissions.IsAuthenticated, IsStaffOrReadOnly]
|
||||
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset for the BarcodeScan API."""
|
||||
|
@ -1,10 +1,11 @@
|
||||
"""API for location plugins."""
|
||||
|
||||
from rest_framework import permissions, serializers
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import NotFound, ParseError, ValidationError
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
import InvenTree.permissions
|
||||
from InvenTree.exceptions import log_error
|
||||
from InvenTree.tasks import offload_task
|
||||
from plugin import PluginMixinEnum, registry
|
||||
@ -27,7 +28,7 @@ class LocatePluginSerializer(serializers.Serializer):
|
||||
class LocatePluginView(GenericAPIView):
|
||||
"""Endpoint for using a custom plugin to identify or 'locate' a stock item or location."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = LocatePluginSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
@ -3,10 +3,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
import InvenTree.permissions
|
||||
import plugin.base.ui.serializers as UIPluginSerializers
|
||||
from common.settings import get_global_setting
|
||||
from InvenTree.exceptions import log_error
|
||||
@ -16,7 +16,7 @@ from plugin import PluginMixinEnum, registry
|
||||
class PluginUIFeatureList(APIView):
|
||||
"""API endpoint for listing all available plugin ui features."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = UIPluginSerializers.PluginUIFeatureSerializer
|
||||
|
||||
@extend_schema(
|
||||
|
@ -8,7 +8,6 @@ from django.views.decorators.cache import never_cache
|
||||
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework import permissions
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
@ -30,10 +29,7 @@ class TemplatePermissionMixin:
|
||||
"""Permission mixin for report and label templates."""
|
||||
|
||||
# Read only for non-staff users
|
||||
permission_classes = [
|
||||
permissions.IsAuthenticated,
|
||||
InvenTree.permissions.IsStaffOrReadOnly,
|
||||
]
|
||||
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
|
||||
|
||||
|
||||
class ReportFilterBase(rest_filters.FilterSet):
|
||||
@ -100,7 +96,7 @@ class LabelPrint(GenericAPIView):
|
||||
"""API endpoint for printing labels."""
|
||||
|
||||
# Any authenticated user can print labels
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = report.serializers.LabelPrintSerializer
|
||||
|
||||
def get_plugin_class(self, plugin_slug: str, raise_error=False):
|
||||
@ -253,7 +249,7 @@ class ReportPrint(GenericAPIView):
|
||||
"""API endpoint for printing reports."""
|
||||
|
||||
# Any authenticated user can print reports
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = report.serializers.ReportPrintSerializer
|
||||
|
||||
@method_decorator(never_cache)
|
||||
|
@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_filters import rest_framework as rest_filters
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework import status
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import ValidationError
|
||||
@ -20,6 +20,7 @@ from rest_framework.serializers import ValidationError
|
||||
import common.models
|
||||
import common.settings
|
||||
import InvenTree.helpers
|
||||
import InvenTree.permissions
|
||||
import stock.serializers as StockSerializers
|
||||
from build.models import Build
|
||||
from build.serializers import BuildSerializer
|
||||
@ -65,7 +66,7 @@ from stock.status_codes import StockHistoryCode, StockStatus
|
||||
class GenerateBatchCode(GenericAPIView):
|
||||
"""API endpoint for generating batch codes."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = StockSerializers.GenerateBatchCodeSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
@ -81,7 +82,7 @@ class GenerateBatchCode(GenericAPIView):
|
||||
class GenerateSerialNumber(GenericAPIView):
|
||||
"""API endpoint for generating serial numbers."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = StockSerializers.GenerateSerialNumberSerializer
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
@ -10,9 +10,8 @@ from django.views.generic.base import RedirectView
|
||||
|
||||
import structlog
|
||||
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema
|
||||
from rest_framework import exceptions, permissions
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.generics import DestroyAPIView, GenericAPIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
import InvenTree.helpers
|
||||
@ -51,6 +50,7 @@ class OwnerList(ListAPI):
|
||||
|
||||
queryset = Owner.objects.all()
|
||||
serializer_class = OwnerSerializer
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Implement text search for the "owner" model.
|
||||
@ -114,12 +114,13 @@ class OwnerDetail(RetrieveAPI):
|
||||
|
||||
queryset = Owner.objects.all()
|
||||
serializer_class = OwnerSerializer
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
|
||||
class RoleDetails(RetrieveAPI):
|
||||
"""API endpoint which lists the available role permissions for the current user."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = RoleSerializer
|
||||
|
||||
def get_object(self):
|
||||
@ -149,7 +150,7 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail):
|
||||
"""
|
||||
|
||||
serializer_class = MeUserSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'}
|
||||
|
||||
@ -206,7 +207,7 @@ class GroupMixin:
|
||||
|
||||
queryset = Group.objects.all()
|
||||
serializer_class = GroupSerializer
|
||||
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
|
||||
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Return serializer instance for this endpoint."""
|
||||
@ -254,7 +255,7 @@ class RuleSetMixin:
|
||||
|
||||
queryset = RuleSet.objects.all()
|
||||
serializer_class = RuleSetSerializer
|
||||
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
|
||||
permission_classes = [InvenTree.permissions.IsStaffOrReadOnlyScope]
|
||||
|
||||
|
||||
class RuleSetList(RuleSetMixin, ListAPI):
|
||||
@ -274,7 +275,7 @@ class RuleSetDetail(RuleSetMixin, RetrieveUpdateDestroyAPI):
|
||||
class GetAuthToken(GenericAPIView):
|
||||
"""Return authentication token for an authenticated user."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
serializer_class = GetAuthTokenSerializer
|
||||
|
||||
@extend_schema(
|
||||
@ -343,7 +344,7 @@ class GetAuthToken(GenericAPIView):
|
||||
class TokenMixin:
|
||||
"""Mixin for API token endpoints."""
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
permission_classes = (InvenTree.permissions.IsAuthenticatedOrReadScope,)
|
||||
serializer_class = ApiTokenSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
@ -426,7 +427,7 @@ class UserProfileDetail(RetrieveUpdateAPI):
|
||||
|
||||
queryset = UserProfile.objects.all()
|
||||
serializer_class = UserProfileSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
permission_classes = [InvenTree.permissions.IsAuthenticatedOrReadScope]
|
||||
|
||||
def get_object(self):
|
||||
"""Return the profile of the current user."""
|
||||
|
@ -4,6 +4,7 @@ import datetime
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
|
||||
from rest_framework import exceptions
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
|
||||
@ -37,3 +38,7 @@ class ApiTokenAuthentication(TokenAuthentication):
|
||||
token.save()
|
||||
|
||||
return (user, token)
|
||||
|
||||
|
||||
class ExtendedOAuth2Authentication(OAuth2Authentication):
|
||||
"""Custom implementation of OAuth2Authentication class to support custom scope rendering."""
|
||||
|
44
src/backend/InvenTree/users/oauth2_scopes.py
Normal file
44
src/backend/InvenTree/users/oauth2_scopes.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Static scope definitions for OAuth2 scopes."""
|
||||
|
||||
|
||||
def get_granular_scope(method, role=None, type='r'):
|
||||
"""Generate a granular scope string for a given method and role."""
|
||||
if role:
|
||||
return f'{type}:{method}:{role}'
|
||||
return f'{type}:{method}'
|
||||
|
||||
|
||||
# region generated stuff
|
||||
_roles = {
|
||||
'admin': 'Role Admin',
|
||||
'part_category': 'Role Part Categories',
|
||||
'part': 'Role Parts',
|
||||
'stocktake': 'Role Stocktake',
|
||||
'stock_location': 'Role Stock Locations',
|
||||
'stock': 'Role Stock Items',
|
||||
'build': 'Role Build Orders',
|
||||
'purchase_order': 'Role Purchase Orders',
|
||||
'sales_order': 'Role Sales Orders',
|
||||
'return_order': 'Role Return Orders',
|
||||
}
|
||||
_methods = {'view': 'GET', 'add': 'POST', 'change': 'PUT / PATCH', 'delete': 'DELETE'}
|
||||
|
||||
calculated = {
|
||||
get_granular_scope(method[0], role[0]): f'{method[1]} for {role[1]}'
|
||||
for method in _methods.items()
|
||||
for role in _roles.items()
|
||||
}
|
||||
# endregion
|
||||
|
||||
|
||||
DEFAULT_READ = get_granular_scope('read', type='g')
|
||||
DEFAULT_STAFF = get_granular_scope('staff', type='a')
|
||||
DEFAULT_SUPERUSER = get_granular_scope('superuser', type='a')
|
||||
# This is actually used
|
||||
oauth2_scopes = {
|
||||
DEFAULT_READ: 'General Read scope',
|
||||
'openid': 'OpenID Connect scope',
|
||||
# Admin scopes
|
||||
DEFAULT_STAFF: 'User Role Staff',
|
||||
DEFAULT_SUPERUSER: 'User Role Superuser',
|
||||
} | calculated
|
@ -74,9 +74,17 @@ def get_ruleset_models() -> dict:
|
||||
'otp_static_statictoken',
|
||||
'otp_static_staticdevice',
|
||||
'mfa_authenticator',
|
||||
# Oauth
|
||||
'oauth2_provider_application',
|
||||
'oauth2_provider_grant',
|
||||
'oauth2_provider_idtoken',
|
||||
'oauth2_provider_accesstoken',
|
||||
'oauth2_provider_refreshtoken',
|
||||
# Plugins
|
||||
'plugin_pluginconfig',
|
||||
'plugin_pluginsetting',
|
||||
'plugin_notificationusersetting',
|
||||
# Misc
|
||||
'common_barcodescanresult',
|
||||
'common_newsfeedentry',
|
||||
'taggit_tag',
|
||||
|
@ -8,6 +8,7 @@ from django.urls import reverse
|
||||
from common.settings import set_global_setting
|
||||
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
|
||||
from users.models import ApiToken, Owner
|
||||
from users.oauth2_scopes import _roles
|
||||
from users.ruleset import (
|
||||
RULESET_CHOICES,
|
||||
RULESET_NAMES,
|
||||
@ -15,20 +16,24 @@ from users.ruleset import (
|
||||
get_ruleset_models,
|
||||
)
|
||||
|
||||
G_RULESETS = get_ruleset_models()
|
||||
G_RULESETS_IG = get_ruleset_ignore()
|
||||
G_SCOPES = _roles.keys()
|
||||
|
||||
|
||||
class RuleSetModelTest(TestCase):
|
||||
"""Some simplistic tests to ensure the RuleSet model is setup correctly."""
|
||||
|
||||
def test_ruleset_models(self):
|
||||
"""Test that the role rulesets work as intended."""
|
||||
keys = get_ruleset_models().keys()
|
||||
keys = G_RULESETS.keys()
|
||||
|
||||
# Check if there are any rulesets which do not have models defined
|
||||
|
||||
missing = [name for name in RULESET_NAMES if name not in keys]
|
||||
|
||||
if len(missing) > 0: # pragma: no cover
|
||||
print('The following rulesets do not have models assigned:')
|
||||
print('INVE-E5: The following rulesets do not have models assigned:')
|
||||
for m in missing:
|
||||
print('-', m)
|
||||
|
||||
@ -37,22 +42,24 @@ class RuleSetModelTest(TestCase):
|
||||
|
||||
if len(extra) > 0: # pragma: no cover
|
||||
print(
|
||||
'The following rulesets have been improperly added to get_ruleset_models():'
|
||||
'INVE-E5: The following rulesets have been improperly added to get_ruleset_models():'
|
||||
)
|
||||
for e in extra:
|
||||
print('-', e)
|
||||
|
||||
# Check that each ruleset has models assigned
|
||||
empty = [key for key in keys if len(get_ruleset_models()[key]) == 0]
|
||||
empty = [key for key in keys if len(G_RULESETS[key]) == 0]
|
||||
|
||||
if len(empty) > 0: # pragma: no cover
|
||||
print('The following rulesets have empty entries in get_ruleset_models():')
|
||||
print(
|
||||
'INVE-E5: The following rulesets have empty entries in get_ruleset_models():'
|
||||
)
|
||||
for e in empty:
|
||||
print('-', e)
|
||||
|
||||
self.assertEqual(len(missing), 0)
|
||||
self.assertEqual(len(extra), 0)
|
||||
self.assertEqual(len(empty), 0)
|
||||
self.assertEqual(len(missing), 0, 'See INVE-E5 in the docs')
|
||||
self.assertEqual(len(extra), 0, 'See INVE-E5 in the docs')
|
||||
self.assertEqual(len(empty), 0, 'See INVE-E5 in the docs')
|
||||
|
||||
def test_model_names(self):
|
||||
"""Test that each model defined in the rulesets is valid, based on the database schema!"""
|
||||
@ -69,7 +76,9 @@ class RuleSetModelTest(TestCase):
|
||||
assigned_models = set()
|
||||
|
||||
# Now check that each defined model is a valid table name
|
||||
for models in get_ruleset_models().values():
|
||||
for key in G_RULESETS:
|
||||
models = G_RULESETS[key]
|
||||
|
||||
for m in models:
|
||||
assigned_models.add(m)
|
||||
|
||||
@ -77,13 +86,13 @@ class RuleSetModelTest(TestCase):
|
||||
|
||||
for model in available_tables:
|
||||
if (
|
||||
model not in assigned_models and model not in get_ruleset_ignore()
|
||||
model not in assigned_models and model not in G_RULESETS_IG
|
||||
): # pragma: no cover
|
||||
missing_models.add(model)
|
||||
|
||||
if len(missing_models) > 0: # pragma: no cover
|
||||
print(
|
||||
'The following database models are not covered by the defined RuleSet permissions:'
|
||||
'INVE-E5: The following database models are not covered by the defined RuleSet permissions:'
|
||||
)
|
||||
for m in missing_models:
|
||||
print('-', m)
|
||||
@ -95,7 +104,7 @@ class RuleSetModelTest(TestCase):
|
||||
for model in assigned_models:
|
||||
defined_models.add(model)
|
||||
|
||||
for model in get_ruleset_ignore():
|
||||
for model in G_RULESETS_IG:
|
||||
defined_models.add(model)
|
||||
|
||||
for model in defined_models: # pragma: no cover
|
||||
@ -103,12 +112,35 @@ class RuleSetModelTest(TestCase):
|
||||
extra_models.add(model)
|
||||
|
||||
if len(extra_models) > 0: # pragma: no cover
|
||||
print('The following RuleSet permissions do not match a database model:')
|
||||
print(
|
||||
'INVE-E5: The following RuleSet permissions do not match a database model:'
|
||||
)
|
||||
for m in extra_models:
|
||||
print('-', m)
|
||||
|
||||
self.assertEqual(len(missing_models), 0)
|
||||
self.assertEqual(len(extra_models), 0)
|
||||
self.assertEqual(len(missing_models), 0, 'See INVE-E5 in the docs')
|
||||
self.assertEqual(len(extra_models), 0, 'See INVE-E5 in the docs')
|
||||
|
||||
def test_scope_names(self):
|
||||
"""Ensure that the rulesets map to scopes and vice versa."""
|
||||
# Check that each scope has a corresponding ruleset
|
||||
missing = [scope for scope in G_SCOPES if scope not in G_RULESETS]
|
||||
|
||||
if len(missing) > 0: # pragma: no cover
|
||||
print('INVE-E6: The following scopes do not have corresponding rulesets:')
|
||||
for m in missing:
|
||||
print('-', m)
|
||||
|
||||
# Check that each ruleset has a corresponding scope
|
||||
extra = [scope for scope in G_RULESETS if scope not in G_SCOPES]
|
||||
|
||||
if len(extra) > 0: # pragma: no cover
|
||||
print('INVE-E6: The following rulesets do not have corresponding scopes:')
|
||||
for m in extra:
|
||||
print('-', m)
|
||||
|
||||
self.assertEqual(len(missing), 0, 'See INVE-E6 in the docs')
|
||||
self.assertEqual(len(extra), 0, 'See INVE-E6 in the docs')
|
||||
|
||||
def test_permission_assign(self):
|
||||
"""Test that the permission assigning works!"""
|
||||
@ -123,7 +155,7 @@ class RuleSetModelTest(TestCase):
|
||||
# Check that all permissions have been assigned permissions?
|
||||
permission_set = set()
|
||||
|
||||
for models in get_ruleset_models().values():
|
||||
for models in G_RULESETS.values():
|
||||
for model in models:
|
||||
permission_set.add(model)
|
||||
|
||||
|
@ -26,6 +26,7 @@ django-structlog # Structured logging
|
||||
django-stdimage # Advanced ImageField management
|
||||
django-taggit # Tagging support
|
||||
django-otp==1.3.0 # Two-factor authentication (legacy to ensure migrations) https://github.com/inventree/InvenTree/pull/6293
|
||||
django-oauth-toolkit # OAuth2 provider
|
||||
djangorestframework # DRF framework
|
||||
djangorestframework-simplejwt[crypto] # JWT authentication
|
||||
django-xforwardedfor-middleware # IP forwarding metadata
|
||||
|
@ -372,6 +372,7 @@ cryptography==44.0.2 \
|
||||
# -r src/backend/requirements.in
|
||||
# djangorestframework-simplejwt
|
||||
# fido2
|
||||
# jwcrypto
|
||||
# pyjwt
|
||||
cssselect2==0.8.0 \
|
||||
--hash=sha256:46fc70ebc41ced7a32cd42d58b1884d72ade23d21e5a4eaaf022401c13f0e76e \
|
||||
@ -404,6 +405,7 @@ django==4.2.20 \
|
||||
# django-js-asset
|
||||
# django-markdownify
|
||||
# django-money
|
||||
# django-oauth-toolkit
|
||||
# django-otp
|
||||
# django-picklefield
|
||||
# django-q2
|
||||
@ -474,6 +476,10 @@ django-mptt==0.17.0 \
|
||||
--hash=sha256:e2dca00536450b91bdc80d8fc1454993b84307728394ae42c72947fc09574d3d \
|
||||
--hash=sha256:e77dd4b4d0de14f50239b0900def44c1d31403664ab3682b80b27fa1319fe7f0
|
||||
# via -r src/backend/requirements.in
|
||||
django-oauth-toolkit==3.0.1 \
|
||||
--hash=sha256:3ef00b062a284f2031b0732b32dc899e3bbf0eac221bbb1cffcb50b8932e55ed \
|
||||
--hash=sha256:7200e4a9fb229b145a6d808cbf0423b6d69a87f68557437733eec3c0cf71db02
|
||||
# via -r src/backend/requirements.in
|
||||
django-otp==1.3.0 \
|
||||
--hash=sha256:5277731bc05b6cdbf96aa84ac46018e30ed5fb248086053b0146f925de059060 \
|
||||
--hash=sha256:8f4156a3c14ce2aaa31379385eadf388925cd50fc4b5d20a3b944f454c98ff7c
|
||||
@ -771,6 +777,10 @@ jsonschema-specifications==2024.10.1 \
|
||||
--hash=sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272 \
|
||||
--hash=sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf
|
||||
# via jsonschema
|
||||
jwcrypto==1.5.6 \
|
||||
--hash=sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789 \
|
||||
--hash=sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039
|
||||
# via django-oauth-toolkit
|
||||
lxml==5.3.0 \
|
||||
--hash=sha256:01220dca0d066d1349bd6a1726856a78f7929f3878f7e2ee83c296c69495309e \
|
||||
--hash=sha256:02ced472497b8362c8e902ade23e3300479f4f43e45f4105c85ef43b8db85229 \
|
||||
@ -983,7 +993,9 @@ markupsafe==3.0.2 \
|
||||
oauthlib==3.2.2 \
|
||||
--hash=sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca \
|
||||
--hash=sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918
|
||||
# via requests-oauthlib
|
||||
# via
|
||||
# django-oauth-toolkit
|
||||
# requests-oauthlib
|
||||
openpyxl==3.1.5 \
|
||||
--hash=sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2 \
|
||||
--hash=sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050
|
||||
@ -1446,6 +1458,7 @@ requests==2.32.3 \
|
||||
# via
|
||||
# coreapi
|
||||
# django-allauth
|
||||
# django-oauth-toolkit
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
# requests-oauthlib
|
||||
requests-oauthlib==2.0.0 \
|
||||
@ -1645,6 +1658,7 @@ typing-extensions==4.12.2 \
|
||||
# drf-spectacular
|
||||
# flexcache
|
||||
# flexparser
|
||||
# jwcrypto
|
||||
# opentelemetry-sdk
|
||||
# pint
|
||||
# py-moneyed
|
||||
|
Loading…
x
Reference in New Issue
Block a user