diff --git a/src/pretix/api/auth/permission.py b/src/pretix/api/auth/permission.py index 093fc09789..c08205fc4a 100644 --- a/src/pretix/api/auth/permission.py +++ b/src/pretix/api/auth/permission.py @@ -36,7 +36,9 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission from pretix.api.models import OAuthAccessToken from pretix.base.models import Device, Event, User -from pretix.base.models.auth import SuperuserPermissionSet +from pretix.base.models.auth import ( + EventPermissionSet, OrganizerPermissionSet, SuperuserPermissionSet, +) from pretix.base.models.organizer import TeamAPIToken from pretix.helpers.security import ( Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired, @@ -85,7 +87,7 @@ class EventPermission(BasePermission): if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): request.eventpermset = SuperuserPermissionSet() else: - request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event) + request.eventpermset = EventPermissionSet(perm_holder.get_event_permission_set(request.organizer, request.event)) if isinstance(required_permission, (list, tuple)): if not any(p in request.eventpermset for p in required_permission): @@ -100,7 +102,7 @@ class EventPermission(BasePermission): if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): request.orgapermset = SuperuserPermissionSet() else: - request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer) + request.orgapermset = OrganizerPermissionSet(perm_holder.get_organizer_permission_set(request.organizer)) if isinstance(required_permission, (list, tuple)): if not any(p in request.eventpermset for p in required_permission): diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index bf54b690cc..6876e38f5a 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -212,6 +212,28 @@ class SuperuserPermissionSet: return True +class EventPermissionSet(set): + def __contains__(self, item): + from pretix.base.permissions import assert_valid_event_permission + + if super().__contains__(item): + return True + + assert_valid_event_permission(item, allow_tuple=False) + return False + + +class OrganizerPermissionSet(set): + def __contains__(self, item): + from pretix.base.permissions import assert_valid_organizer_permission + + if super().__contains__(item): + return True + + assert_valid_organizer_permission(item, allow_tuple=False) + return False + + class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): """ This is the user model used by pretix for authentication. diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index 1a9350401e..1193a4d8a7 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -29,6 +29,7 @@ from django.utils.translation import gettext_lazy as _ from django_scopes import ScopedManager, scopes_disabled from pretix.base.models import LoggedModel +from pretix.base.permissions import assert_valid_event_permission @scopes_disabled() @@ -276,6 +277,7 @@ class Device(LoggedModel): :param request: Ignored, for compatibility with User model :return: Iterable of Events """ + assert_valid_event_permission(permission) if ( isinstance(permission, (list, tuple)) and any(p in self._event_permission_set() for p in permission) ) or (isinstance(permission, str) and permission in self._event_permission_set()): diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index 70e6c7d3ce..354007b4d5 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -317,8 +317,11 @@ def generate_api_token(): class TeamQuerySet(models.QuerySet): @classmethod def event_permission_q(cls, perm_name): + from ..permissions import assert_valid_event_permission + if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_EVENT_COMPAT: # legacy return reduce(operator.and_, [cls.event_permission_q(p) for p in OLD_TO_NEW_EVENT_COMPAT[perm_name]]) + assert_valid_event_permission(perm_name, allow_legacy=False) return ( Q(all_event_permissions=True) | Q(**{f'limit_event_permissions__{perm_name}': True}) @@ -326,8 +329,11 @@ class TeamQuerySet(models.QuerySet): @classmethod def organizer_permission_q(cls, perm_name): + from ..permissions import assert_valid_organizer_permission + if perm_name.startswith('can_') and perm_name in OLD_TO_NEW_ORGANIZER_COMPAT: # legacy return reduce(operator.and_, [cls.organizer_permission_q(p) for p in OLD_TO_NEW_ORGANIZER_COMPAT[perm_name]]) + assert_valid_organizer_permission(perm_name, allow_legacy=False) return ( Q(all_organizer_permissions=True) | Q(**{f'limit_organizer_permissions__{perm_name}': True}) @@ -436,13 +442,19 @@ class Team(LoggedModel): return self.can_change_event_settings def has_event_permission(self, perm_name): + from ..permissions import assert_valid_event_permission + if perm_name.startswith('can_') and hasattr(self, perm_name): # legacy return getattr(self, perm_name) + assert_valid_event_permission(perm_name, allow_legacy=False) return self.all_event_permissions or self.limit_event_permissions.get(perm_name, False) def has_organizer_permission(self, perm_name): + from ..permissions import assert_valid_organizer_permission + if perm_name.startswith('can_') and hasattr(self, perm_name): # legacy return getattr(self, perm_name) + assert_valid_organizer_permission(perm_name, allow_legacy=False) return self.all_organizer_permissions or self.limit_organizer_permissions.get(perm_name, False) def permission_for_event(self, event): diff --git a/src/pretix/base/permissions.py b/src/pretix/base/permissions.py index 8e2814c825..7ddbf2d72a 100644 --- a/src/pretix/base/permissions.py +++ b/src/pretix/base/permissions.py @@ -21,8 +21,9 @@ # import logging +import warnings from collections import OrderedDict -from typing import Dict, List, NamedTuple, Tuple +from typing import Dict, List, NamedTuple, Set, Tuple from django.dispatch import receiver from django.utils.functional import Promise @@ -33,6 +34,8 @@ from pretix.base.signals import ( ) logger = logging.getLogger(__name__) +_ALL_EVENT_PERMISSION_GROUPS = None +_ALL_ORGANIZER_PERMISSION_GROUPS = None _ALL_EVENT_PERMISSIONS = None _ALL_ORGANIZER_PERMISSIONS = None @@ -52,10 +55,10 @@ class PermissionGroup(NamedTuple): def get_all_event_permission_groups() -> Dict[str, PermissionGroup]: - global _ALL_EVENT_PERMISSIONS + global _ALL_EVENT_PERMISSION_GROUPS - if _ALL_EVENT_PERMISSIONS: - return _ALL_EVENT_PERMISSIONS + if _ALL_EVENT_PERMISSION_GROUPS: + return _ALL_EVENT_PERMISSION_GROUPS types = OrderedDict() for recv, ret in register_event_permission_groups.send(None): @@ -64,15 +67,15 @@ def get_all_event_permission_groups() -> Dict[str, PermissionGroup]: types[r.name] = r else: types[ret.name] = ret - _ALL_EVENT_PERMISSIONS = types + _ALL_EVENT_PERMISSION_GROUPS = types return types def get_all_organizer_permission_groups() -> Dict[str, PermissionGroup]: - global _ALL_ORGANIZER_PERMISSIONS + global _ALL_ORGANIZER_PERMISSION_GROUPS - if _ALL_ORGANIZER_PERMISSIONS: - return _ALL_ORGANIZER_PERMISSIONS + if _ALL_ORGANIZER_PERMISSION_GROUPS: + return _ALL_ORGANIZER_PERMISSION_GROUPS types = OrderedDict() for recv, ret in register_organizer_permission_groups.send(None): @@ -81,10 +84,74 @@ def get_all_organizer_permission_groups() -> Dict[str, PermissionGroup]: types[r.name] = r else: types[ret.name] = ret - _ALL_ORGANIZER_PERMISSIONS = types + _ALL_ORGANIZER_PERMISSION_GROUPS = types return types +def get_all_event_permissions() -> Set[str]: + from pretix.helpers.permission_migration import OLD_TO_NEW_EVENT_COMPAT + global _ALL_EVENT_PERMISSIONS + + if _ALL_EVENT_PERMISSIONS: + return _ALL_EVENT_PERMISSIONS + + res = set(OLD_TO_NEW_EVENT_COMPAT.keys()) + for pg in get_all_event_permission_groups().values(): + for a in pg.actions: + res.add(f"{pg.name}:{a}") + + _ALL_EVENT_PERMISSIONS = res + return res + + +def get_all_organizer_permissions() -> Set[str]: + from pretix.helpers.permission_migration import OLD_TO_NEW_ORGANIZER_COMPAT + global _ALL_ORGANIZER_PERMISSIONS + + if _ALL_ORGANIZER_PERMISSIONS: + return _ALL_ORGANIZER_PERMISSIONS + + res = set(OLD_TO_NEW_ORGANIZER_COMPAT.keys()) + for pg in get_all_organizer_permission_groups().values(): + for a in pg.actions: + res.add(f"{pg.name}:{a}") + + _ALL_ORGANIZER_PERMISSIONS = res + return res + + +def assert_valid_event_permission(permission, allow_legacy=True, allow_tuple=True): + if permission is None: + return + if isinstance(permission, (list, tuple)) and allow_tuple: + for p in permission: + assert_valid_event_permission(p) + return + if not allow_legacy and ':' not in permission: + raise ValueError(f"Not allowed to use legacy permission '{permission}'") + all_permissions = get_all_event_permissions() + if permission not in all_permissions: + # Warning *and* exception because warning is silently caught when used in if statements in Django templates + warnings.warn(f"Use of undefined permission '{permission}'") + raise Exception(f"Undefined permission '{permission}'") + + +def assert_valid_organizer_permission(permission, allow_legacy=True, allow_tuple=True): + if permission is None: + return + if isinstance(permission, (list, tuple)) and allow_tuple: + for p in permission: + assert_valid_organizer_permission(p) + return + if not allow_legacy and ':' not in permission: + raise ValueError(f"Not allowed to use legacy permission '{permission}'") + all_permissions = get_all_organizer_permissions() + if permission not in all_permissions: + # Warning *and* exception because warning is silently caught when used in if statements in Django templates + warnings.warn(f"Use of undefined permission '{permission}'") + raise Exception(f"Undefined permission '{permission}'") + + OPTS_ALL_READ = [ PermissionOption(actions=tuple(), label=pgettext_lazy("permission_level", "View")), PermissionOption(actions=("write",), label=pgettext_lazy("permission_level", "View and change")), diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index 9b02152656..4c77671ab4 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -45,7 +45,9 @@ from django.utils.translation import gettext as _ from django_scopes import scope from pretix.base.models import Event, Organizer -from pretix.base.models.auth import SuperuserPermissionSet, User +from pretix.base.models.auth import ( + EventPermissionSet, OrganizerPermissionSet, SuperuserPermissionSet, User, +) from pretix.helpers.http import redirect_to_url from pretix.helpers.security import ( Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired, @@ -170,7 +172,7 @@ class PermissionMiddleware: if request.user.has_active_staff_session(request.session.session_key): request.eventpermset = SuperuserPermissionSet() else: - request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event) + request.eventpermset = EventPermissionSet(request.user.get_event_permission_set(request.organizer, request.event)) elif 'organizer' in url.kwargs: if url.kwargs['organizer'] == '-': # This is a hack that just takes the user to ANY organizer. It's useful to link to features in support @@ -192,7 +194,7 @@ class PermissionMiddleware: if request.user.has_active_staff_session(request.session.session_key): request.orgapermset = SuperuserPermissionSet() else: - request.orgapermset = request.user.get_organizer_permission_set(request.organizer) + request.orgapermset = OrganizerPermissionSet(request.user.get_organizer_permission_set(request.organizer)) with scope(organizer=getattr(request, 'organizer', None)): r = self.get_response(request) diff --git a/src/pretix/control/permissions.py b/src/pretix/control/permissions.py index 1e3155785c..141b06f895 100644 --- a/src/pretix/control/permissions.py +++ b/src/pretix/control/permissions.py @@ -38,6 +38,9 @@ from django.core.exceptions import PermissionDenied from django.urls import reverse from django.utils.translation import gettext as _ +from pretix.base.permissions import ( + assert_valid_event_permission, assert_valid_organizer_permission, +) from pretix.helpers.http import redirect_to_url @@ -57,6 +60,8 @@ def event_permission_required(permission): # Legacy support permission = 'event.settings.general:write' + assert_valid_event_permission(permission) + def decorator(function): def wrapper(request, *args, **kw): if not request.user.is_authenticated: # NOQA @@ -79,7 +84,7 @@ class EventPermissionRequiredMixin: This mixin is equivalent to the event_permission_required view decorator but is in a form suitable for class-based views. """ - permission = '' + permission = None # None means "any permission" @classmethod def as_view(cls, **initkwargs): @@ -92,10 +97,12 @@ def organizer_permission_required(permission): This view decorator rejects all requests with a 403 response which are not from users having the given permission for the event the request is associated with. """ - if permission == 'event.settings.general:write': + if permission in ('event.settings.general:write', 'can_change_settings', 'can_change_event_settings'): # Legacy support permission = 'organizer.settings.general:write' + assert_valid_organizer_permission(permission) + def decorator(function): def wrapper(request, *args, **kw): if not request.user.is_authenticated: # NOQA @@ -116,7 +123,7 @@ class OrganizerPermissionRequiredMixin: This mixin is equivalent to the organizer_permission_required view decorator but is in a form suitable for class-based views. """ - permission = '' + permission = None # None means "any permission" @classmethod def as_view(cls, **initkwargs):