Coverage for src/backend/InvenTree/InvenTree/permissions.py: 70%
201 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""Permission set for InvenTree."""
3from functools import wraps
4from typing import Optional
6from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements
7from oauth2_provider.contrib.rest_framework.authentication import OAuth2Authentication
8from rest_framework import permissions
10import users.permissions
11import users.ruleset
12from users.oauth2_scopes import (
13 DEFAULT_READ,
14 DEFAULT_STAFF,
15 DEFAULT_SUPERUSER,
16 _roles,
17 get_granular_scope,
18)
20ACTION_MAP = {
21 'GET': 'view',
22 'POST': 'add',
23 'PUT': 'change',
24 'PATCH': 'change',
25 'DELETE': 'delete',
26 'OPTIONS': DEFAULT_READ,
27}
30def get_model_for_view(view):
31 """Attempt to introspect the 'model' type for an API view."""
32 if hasattr(view, 'get_permission_model'):
33 return view.get_permission_model()
35 if hasattr(view, 'serializer_class'):
36 return view.serializer_class.Meta.model
38 if hasattr(view, 'get_serializer_class'): 38 ↛ 39line 38 didn't jump to line 39 because the condition on line 38 was never true
39 return view.get_serializer_class().Meta.model
41 raise AttributeError(f'Serializer class not specified for {view.__class__}')
44def map_scope(
45 roles: Optional[list[str]] = None,
46 only_read=False,
47 read_name=DEFAULT_READ,
48 map_read: Optional[list[str]] = None,
49 map_read_name=DEFAULT_READ,
50 override_all_actions: Optional[str] = None,
51) -> dict:
52 """Generate the required scopes for OAS permission views.
54 Args:
55 roles (Optional[list[str]]): A list of roles or tables to generate granular scopes for.
56 only_read (bool): If True, only the read scope will be returned for all actions.
57 read_name (str): The read scope name to use when `only_read` is True.
58 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).
59 map_read_name (str): The read scope name to use for methods specified in `map_read` when `map_read` is specified.
60 override_all_actions (Optional[str]): If specified, all actions will be overridden to use the provided action name instead of the default action names.
62 Returns:
63 dict: A dictionary mapping HTTP methods to their corresponding scopes.
64 Each scope is represented as a list of lists of strings.
65 """
67 def scope_name(action):
68 if only_read:
69 return [[read_name]]
70 if roles: 70 ↛ 72line 70 didn't jump to line 72 because the condition on line 70 was always true
71 return [[get_granular_scope(action, table) for table in roles]]
72 return [[action]]
74 def get_scope(method, action):
75 if map_read and method in map_read:
76 return [[map_read_name]]
77 return scope_name(override_all_actions if override_all_actions else action)
79 return {
80 method: get_scope(method, action) if method != 'OPTIONS' else [[DEFAULT_READ]]
81 for method, action in ACTION_MAP.items()
82 }
85# Precalculate the roles mapping
86roles = users.ruleset.get_ruleset_models()
87precalculated_roles = {}
88for role, tables in roles.items():
89 for table in tables:
90 if table not in precalculated_roles:
91 precalculated_roles[table] = []
92 precalculated_roles[table].append(role)
95class OASTokenMixin:
96 """Mixin that combines the permissions of normal classes and token classes."""
98 ENFORCE_USER_PERMS: bool = False
100 def has_permission(self, request, view):
101 """Check if the user has the required scopes or was authenticated another way."""
102 if self.ENFORCE_USER_PERMS: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 return super().has_permission(request, view)
104 return self.check_oauth2_authentication(
105 request, view
106 ) or super().has_permission(request, view)
108 def check_oauth2_authentication(self, request, view):
109 """Check if the user is authenticated using OAuth2 and has the required scopes."""
110 return self.is_oauth2ed(
111 request
112 ) and TokenMatchesOASRequirements().has_permission(request, view)
114 def is_oauth2ed(self, request):
115 """Check if the user is authenticated using OAuth2."""
116 oauth2authenticated = False
117 if bool(request.user and request.user.is_authenticated):
118 oauth2authenticated = isinstance(
119 request.successful_authenticator, OAuth2Authentication
120 )
121 return oauth2authenticated
124class InvenTreeRoleScopeMixin(OASTokenMixin):
125 """Permission that discovers the required scopes from the OpenAPI schema."""
127 def get_required_alternate_scopes(self, request, view):
128 """Return the required scopes for the current request."""
129 if hasattr(view, 'required_alternate_scopes'): 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 return view.required_alternate_scopes
131 try:
132 # Extract the model name associated with this request
133 model = get_model_for_view(view)
134 calc = precalculated_roles.get(
135 f'{model._meta.app_label}_{model._meta.model_name}', []
136 )
138 if model is None or not calc: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 return map_scope(only_read=True)
140 return map_scope(roles=calc)
141 except AttributeError:
142 # We will assume that if the serializer class does *not* have a Meta,
143 # then we don't need a permission
144 return map_scope(only_read=True)
145 except Exception:
146 return map_scope(only_read=True)
149class InvenTreeTokenMatchesOASRequirements(InvenTreeRoleScopeMixin):
150 """Combines InvenTree role-based scope handling with OpenAPI schema token requirements.
152 Used as default permission class.
153 """
155 def has_permission(self, request, view):
156 """Check if the user has the required scopes or was authenticated another way."""
157 if self.is_oauth2ed(request): 157 ↛ 159line 157 didn't jump to line 159 because the condition on line 157 was never true
158 # Check if the user is authenticated using OAuth2 and has the required scopes
159 return super().has_permission(request, view)
161 # If the user is authenticated using another method, check if they have the required permissions
162 return bool(request.user and request.user.is_authenticated)
164 def has_object_permission(self, request, view, obj):
165 """Return `True` if permission is granted, `False` otherwise."""
166 return True
169class ModelPermission(permissions.DjangoModelPermissions):
170 """Custom ModelPermission implementation which provides cached lookup of queryset.
172 This is entirely for optimization purposes.
173 """
175 def _queryset(self, view):
176 """Return the queryset associated with this view, with caching.
178 This is because in a metadata OPTIONS request, the view is copied multiple times.
179 We can cache the queryset to avoid repeated calculation.
180 """
181 if getattr(view, '_cached_queryset', None) is not None:
182 return view._cached_queryset
184 queryset = super()._queryset(view)
186 if queryset is not None: 186 ↛ 189line 186 didn't jump to line 189 because the condition on line 186 was always true
187 view._cached_queryset = queryset
189 return queryset
192class RolePermission(InvenTreeRoleScopeMixin, permissions.BasePermission):
193 """Role mixin for API endpoints, allowing us to specify the user "role" which is required for certain operations.
195 Each endpoint can have one or more of the following actions:
196 - GET
197 - POST
198 - PUT
199 - PATCH
200 - DELETE
202 Specify the required "role" using the role_required attribute.
204 e.g.
206 role_required = "part"
208 The RoleMixin class will then determine if the user has the required permission
209 to perform the specified action.
211 For example, a DELETE action will be rejected unless the user has the "part.remove" permission
212 """
214 def has_permission(self, request, view):
215 """Determine if the current user has the specified permissions."""
216 user = request.user
218 # Superuser can do it all
219 if user.is_superuser: 219 ↛ 223line 219 didn't jump to line 223 because the condition on line 219 was always true
220 return True
222 # Map the request method to a permission type
223 rolemap = {**ACTION_MAP, 'OPTIONS': 'view'}
225 # let the view define a custom rolemap
226 if hasattr(view, 'rolemap'):
227 rolemap.update(view.rolemap)
229 permission = rolemap[request.method]
231 # The required role may be defined for the view class
232 if role := getattr(view, 'role_required', None):
233 # If the role is specified as "role.permission", split it
234 if '.' in role:
235 role, permission = role.split('.')
237 return users.permissions.check_user_role(user, role, permission)
239 try:
240 # Extract the model name associated with this request
241 model = get_model_for_view(view)
243 if model is None:
244 return True
246 except AttributeError:
247 # We will assume that if the serializer class does *not* have a Meta,
248 # then we don't need a permission
249 return True
251 return users.permissions.check_user_permission(user, model, permission)
254class RolePermissionOrReadOnly(RolePermission):
255 """RolePermission which also allows read access for any authenticated user."""
257 REQUIRE_STAFF = False
259 def has_permission(self, request, view):
260 """Determine if the current user has the specified permissions.
262 - If the user does have the required role, then allow the request
263 - If the user does not have the required role, but is authenticated, then allow read-only access
264 """
265 user = getattr(request, 'user', None)
267 if not user or not user.is_active or not user.is_authenticated:
268 return False
270 if user.is_superuser: 270 ↛ 273line 270 didn't jump to line 273 because the condition on line 270 was always true
271 return True
273 if not self.REQUIRE_STAFF or user.is_staff:
274 if super().has_permission(request, view):
275 return True
277 return request.method in permissions.SAFE_METHODS
279 def get_required_alternate_scopes(self, request, view):
280 """Return the required scopes for the current request."""
281 scopes = map_scope(
282 only_read=True,
283 read_name=DEFAULT_STAFF,
284 map_read=list(permissions.SAFE_METHODS),
285 )
286 return scopes
289class StaffRolePermissionOrReadOnly(RolePermissionOrReadOnly):
290 """RolePermission which requires staff AND role access, or read-only."""
292 REQUIRE_STAFF = True
295class IsSuperuserOrSuperScope(OASTokenMixin, permissions.IsAdminUser):
296 """Allows access only to superuser users."""
298 def has_permission(self, request, view):
299 """Check if the user is a superuser."""
300 return bool(request.user and request.user.is_superuser)
302 def get_required_alternate_scopes(self, request, view):
303 """Return the required scopes for the current request."""
304 return map_scope(only_read=True, read_name=DEFAULT_SUPERUSER)
307class IsSuperuserOrReadOnlyOrScope(OASTokenMixin, permissions.IsAdminUser):
308 """Allow read-only access to any user, but write access is restricted to superuser users."""
310 def has_permission(self, request, view):
311 """Check if the user is a superuser."""
312 return bool(
313 (request.user and request.user.is_superuser)
314 or request.method in permissions.SAFE_METHODS
315 )
317 def get_required_alternate_scopes(self, request, view):
318 """Return the required scopes for the current request."""
319 return map_scope(
320 only_read=True,
321 read_name=DEFAULT_SUPERUSER,
322 map_read=list(permissions.SAFE_METHODS),
323 )
326class IsAuthenticatedOrReadScope(OASTokenMixin, permissions.IsAuthenticated):
327 """Allows access only to authenticated users or read scope tokens."""
329 def get_required_alternate_scopes(self, request, view):
330 """Return the required scopes for the current request."""
331 return map_scope(only_read=True)
334class IsStaffOrReadOnlyScope(OASTokenMixin, permissions.IsAuthenticated):
335 """Allows read-only access to any authenticated user, but write access is restricted to staff users."""
337 def has_permission(self, request, view):
338 """Check if the user is a staff."""
339 return bool(permissions.IsAuthenticated().has_permission(request, view)) and (
340 (request.user and request.user.is_staff)
341 or request.method in permissions.SAFE_METHODS
342 )
344 def get_required_alternate_scopes(self, request, view):
345 """Return the required scopes for the current request."""
346 return map_scope(
347 only_read=True,
348 read_name=DEFAULT_STAFF,
349 map_read=list(permissions.SAFE_METHODS),
350 )
353class IsAdminOrAdminScope(OASTokenMixin, permissions.IsAdminUser):
354 """Allows access only to admin users or admin scope tokens."""
356 def get_required_alternate_scopes(self, request, view):
357 """Return the required scopes for the current request."""
358 return map_scope(only_read=True, read_name=DEFAULT_STAFF)
361class AllowAnyOrReadScope(OASTokenMixin, permissions.AllowAny):
362 """Allows access to any user or read scope tokens."""
364 def has_permission(self, request, view):
365 """Anyone is allowed."""
366 return True
368 def get_required_alternate_scopes(self, request, view):
369 """Return the required scopes for the current request."""
370 return map_scope(only_read=True)
373def auth_exempt(view_func):
374 """Mark a view function as being exempt from auth requirements."""
376 def wrapped_view(*args, **kwargs):
377 return view_func(*args, **kwargs)
379 wrapped_view.auth_exempt = True
380 return wraps(view_func)(wrapped_view)
383class UserSettingsPermissionsOrScope(OASTokenMixin, permissions.BasePermission):
384 """Special permission class to determine if the user can view / edit a particular setting."""
386 def has_object_permission(self, request, view, obj):
387 """Check if the user that requested is also the object owner."""
388 try:
389 user = request.user
390 except AttributeError: # pragma: no cover
391 return False
393 if not user.is_authenticated: 393 ↛ 394line 393 didn't jump to line 394 because the condition on line 393 was never true
394 return False
396 return user == obj.user
398 def has_permission(self, request, view):
399 """Check that the requesting user is authenticated."""
400 try:
401 user = request.user
402 return user.is_authenticated
403 except AttributeError:
404 return False
406 def get_required_alternate_scopes(self, request, view):
407 """Return the required scopes for the current request."""
408 return map_scope(only_read=True)
411class GlobalSettingsPermissions(OASTokenMixin, permissions.BasePermission):
412 """Special permission class to determine if the user is "staff"."""
414 def has_permission(self, request, view):
415 """Check that the requesting user is 'admin'."""
416 try:
417 user = request.user
418 if request.method in permissions.SAFE_METHODS:
419 return True
420 # Any other methods require staff access permissions
421 return user.is_staff
423 except AttributeError: # pragma: no cover
424 return False
426 def get_required_alternate_scopes(self, request, view):
427 """Return the required scopes for the current request."""
428 return map_scope(
429 only_read=True,
430 read_name=DEFAULT_STAFF,
431 map_read=list(permissions.SAFE_METHODS),
432 )
435class DataImporterPermission(OASTokenMixin, permissions.BasePermission):
436 """Mixin class for determining if the user has correct permissions."""
438 ENFORCE_USER_PERMS = True
440 def has_permission(self, request, view):
441 """Class level permission checks are handled via InvenTree.permissions.IsAuthenticatedOrReadScope."""
442 return request.user and request.user.is_authenticated
444 def get_required_alternate_scopes(self, request, view):
445 """Return the required scopes for the current request."""
446 return map_scope(
447 roles=_roles,
448 map_read=permissions.SAFE_METHODS,
449 override_all_actions='change', # this is done to match the custom has_object_permission method
450 )
452 def has_object_permission(self, request, view, obj):
453 """Check if the user has permission to access the imported object."""
454 import importer.models
456 # For safe methods (GET, HEAD, OPTIONS), allow access
457 if request.method in permissions.SAFE_METHODS:
458 return True
460 if isinstance(obj, importer.models.DataImportSession):
461 session = obj
462 else:
463 session = getattr(obj, 'session', None)
465 if session:
466 if model_class := session.model_class:
467 return users.permissions.check_user_permission(
468 request.user, model_class, 'change'
469 )
471 return True
474class ContentTypePermission(OASTokenMixin, permissions.BasePermission):
475 """Mixin class for determining if the user has correct permissions."""
477 ENFORCE_USER_PERMS = True
479 def has_permission(self, request, view):
480 """Class level permission checks are handled via InvenTree.permissions.IsAuthenticatedOrReadScope."""
481 return request.user and request.user.is_authenticated
483 def get_required_alternate_scopes(self, request, view):
484 """Return the required scopes for the current request."""
485 return map_scope(roles=_roles)
487 def has_object_permission(self, request, view, obj):
488 """Check if the user has permission to access the object."""
489 if model_class := obj.__class__: 489 ↛ 493line 489 didn't jump to line 493 because the condition on line 489 was always true
490 return users.permissions.check_user_permission(
491 request.user, model_class, 'change'
492 )
493 return False