From a284e0c2f739da9e3f7937318c44f6826b44917d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 28 Mar 2018 14:16:58 +0200 Subject: [PATCH] Add auditable superuser mode (#824) * Remove is_superuser everywhere * Session handling * List of sessions, relative timeout * Absolute timeout * Optionally pseudo-force audit comments * Fix failing tests * Add tests * Add docs * Rebsae migration * Typos * Fix tests --- doc/admin/config.rst | 4 + doc/development/implementation/index.rst | 1 + doc/development/implementation/models.rst | 3 + .../implementation/permissions.rst | 194 ++++++++++++++++++ doc/spelling_wordlist.txt | 1 + doc/user/organizers/teams.rst | 2 + src/pretix/api/views/organizer.py | 2 +- src/pretix/base/__init__.py | 2 +- .../migrations/0087_auto_20180317_1952.py | 64 ++++++ src/pretix/base/models/__init__.py | 4 +- src/pretix/base/models/auth.py | 115 ++++++++--- src/pretix/base/models/event.py | 4 +- src/pretix/base/models/organizer.py | 6 +- src/pretix/base/services/auth.py | 19 ++ src/pretix/control/context.py | 13 +- src/pretix/control/forms/filter.py | 8 +- src/pretix/control/forms/users.py | 9 +- src/pretix/control/middleware.py | 48 ++++- src/pretix/control/permissions.py | 45 +++- .../control/templates/pretixcontrol/base.html | 44 +++- .../templates/pretixcontrol/event/index.html | 2 +- .../templates/pretixcontrol/event/logs.html | 2 +- .../pretixcontrol/event/plugins.html | 4 +- .../pretixcontrol/events/create_base.html | 2 +- .../pretixcontrol/includes/logs.html | 2 +- .../pretixcontrol/organizers/index.html | 2 +- .../user/staff_session_edit.html | 47 +++++ .../user/staff_session_list.html | 58 ++++++ .../user/staff_session_start.html | 22 ++ .../templates/pretixcontrol/users/form.html | 2 +- .../templates/pretixcontrol/users/index.html | 2 +- src/pretix/control/urls.py | 4 + src/pretix/control/views/dashboards.py | 24 ++- src/pretix/control/views/event.py | 8 +- src/pretix/control/views/global_settings.py | 6 +- src/pretix/control/views/main.py | 2 +- src/pretix/control/views/organizer.py | 6 +- src/pretix/control/views/search.py | 2 +- src/pretix/control/views/typeahead.py | 6 +- src/pretix/control/views/user.py | 73 ++++++- src/pretix/control/views/users.py | 9 + src/pretix/control/views/waitinglist.py | 3 +- src/pretix/plugins/banktransfer/signals.py | 2 +- src/pretix/plugins/banktransfer/views.py | 2 +- src/pretix/plugins/pretixdroid/signals.py | 2 +- src/pretix/plugins/sendmail/signals.py | 2 +- src/pretix/plugins/statistics/signals.py | 2 +- src/pretix/presale/utils.py | 2 +- src/pretix/settings.py | 4 + .../static/pretixcontrol/scss/main.scss | 2 +- src/tests/base/test_permissions.py | 59 +++--- src/tests/control/test_auth.py | 103 ++++++++++ src/tests/control/test_permissions.py | 27 ++- src/tests/control/test_search.py | 5 +- src/tests/control/test_updatecheck.py | 6 +- src/tests/control/test_views.py | 1 + 56 files changed, 965 insertions(+), 130 deletions(-) create mode 100644 doc/development/implementation/permissions.rst create mode 100644 src/pretix/base/migrations/0087_auto_20180317_1952.py create mode 100644 src/pretix/base/services/auth.py create mode 100644 src/pretix/control/templates/pretixcontrol/user/staff_session_edit.html create mode 100644 src/pretix/control/templates/pretixcontrol/user/staff_session_list.html create mode 100644 src/pretix/control/templates/pretixcontrol/user/staff_session_start.html diff --git a/doc/admin/config.rst b/doc/admin/config.rst index 37a4a96cb..b059314d5 100644 --- a/doc/admin/config.rst +++ b/doc/admin/config.rst @@ -70,6 +70,10 @@ Example:: that are used to print tax amounts in the customer currency on invoices for some currencies. Set to ``off`` to disable this feature. Defaults to ``on``. +``audit_comments`` + Enables or disables nagging staff users for leaving comments on their sessions for auditability. + Defaults to ``off``. + Locale settings --------------- diff --git a/doc/development/implementation/index.rst b/doc/development/implementation/index.rst index 7faf4f235..a9c70e0c8 100644 --- a/doc/development/implementation/index.rst +++ b/doc/development/implementation/index.rst @@ -16,4 +16,5 @@ Contents: settings background email + permissions logging diff --git a/doc/development/implementation/models.rst b/doc/development/implementation/models.rst index 38730d60a..2c6c7e22b 100644 --- a/doc/development/implementation/models.rst +++ b/doc/development/implementation/models.rst @@ -31,6 +31,9 @@ Organizers and events .. autoclass:: pretix.base.models.Team :members: +.. autoclass:: pretix.base.models.TeamAPIToken + :members: + .. autoclass:: pretix.base.models.RequiredAction :members: diff --git a/doc/development/implementation/permissions.rst b/doc/development/implementation/permissions.rst new file mode 100644 index 000000000..9ea76ad2b --- /dev/null +++ b/doc/development/implementation/permissions.rst @@ -0,0 +1,194 @@ +Permissions +=========== + +pretix uses a fine-grained permission system to control who is allowed to control what parts of the system. +The central concept here is the concept of *Teams*. You can read more on `configuring teams and permissions `_ +and the :class:`pretix.base.models.Team` model in the respective parts of the documentation. The basic digest is: +An organizer account can have any number of teams, and any number of users can be part of a team. A team can be +assigned a set of permissions and connected to some or all of the events of the organizer. + +A second way to access pretix is via the REST API, which allows authentication via tokens that are bound to a team, +but not to a user. You can read more at :class:`pretix.base.models.TeamAPIToken`. This page will show you how to +work with permissions in plugins and within the pretix code base. + +Requiring permissions for a view +-------------------------------- + +pretix provides a number of useful mixins and decorators that allow you to specify that a user needs a certain +permission level to access a view:: + + from pretix.control.permissions import ( + OrganizerPermissionRequiredMixin, organizer_permission_required + ) + + + class MyOrgaView(OrganizerPermissionRequiredMixin, View): + permission = 'can_change_organizer_settings' + # Only users with the permission ``can_change_organizer_settings`` on + # this organizer can access this + + + class MyOtherOrgaView(OrganizerPermissionRequiredMixin, View): + permission = None + # Only users with *any* permission on this organizer can access this + + + @organizer_permission_required('can_change_organizer_settings') + def my_orga_view(request, organizer, **kwargs): + # Only users with the permission ``can_change_organizer_settings`` on + # this organizer can access this + + + @organizer_permission_required() + def my_other_orga_view(request, organizer, **kwargs): + # Only users with *any* permission on this organizer can access this + + +Of course, the same is available on event level:: + + from pretix.control.permissions import ( + EventPermissionRequiredMixin, event_permission_required + ) + + + class MyEventView(EventPermissionRequiredMixin, View): + permission = 'can_change_event_settings' + # Only users with the permission ``can_change_event_settings`` on + # this event can access this + + + class MyOtherEventView(EventPermissionRequiredMixin, View): + permission = None + # Only users with *any* permission on this event can access this + + + @event_permission_required('can_change_event_settings') + def my_event_view(request, organizer, **kwargs): + # Only users with the permission ``can_change_event_settings`` on + # this event can access this + + + @event_permission_required() + def my_other_event_view(request, organizer, **kwargs): + # Only users with *any* permission on this event can access this + +You can also require that this view is only accessible by system administrators with an active "admin session" +(see below for what this means):: + + from pretix.control.permissions import ( + AdministratorPermissionRequiredMixin, administrator_permission_required + ) + + + class MyGlobalView(AdministratorPermissionRequiredMixin, View): + # ... + + + @administrator_permission_required + def my_global_view(request, organizer, **kwargs): + # ... + +In rare cases it might also be useful to expose a feature only to people who have a staff account but do not +necessarily have an active admin session:: + + from pretix.control.permissions import ( + StaffMemberRequiredMixin, staff_member_required + ) + + + class MyGlobalView(StaffMemberRequiredMixin, View): + # ... + + + @staff_member_required + def my_global_view(request, organizer, **kwargs): + # ... + + + +Requiring permissions in the REST API +------------------------------------- + +When creating your own ``viewset`` using Django REST framework, you just need to set the ``permission`` attribute +and pretix will check it automatically for you:: + + class MyModelViewSet(viewsets.ReadOnlyModelViewSet): + permission = 'can_view_orders' + +Checking permission in code +--------------------------- + +If you need to work with permissions manually, there are a couple of useful helper methods on the :class:`pretix.base.models.Event`, +:class:`pretix.base.models.User` and :class:`pretix.base.models.TeamAPIToken` classes. Here's a quick overview. + +Return all users that are in any team that is connected to this event:: + + >>> event.get_users_with_any_permission() + + +Return all users that are in a team with a specific permission for this event:: + + >>> event.get_users_with_permission('can_change_event_settings') + + +Determine if a user has a certain permission for a specific event:: + + >>> user.has_event_permission(organizer, event, 'can_change_event_settings', request=request) + True + +Determine if a user has any permission for a specific event:: + + >>> user.has_event_permission(organizer, event, request=request) + True + +In the two previous commands, the ``request`` argument is optional, but required to support staff sessions (see below). + +The same method exists for organizer-level permissions:: + + >>> user.has_organizer_permission(organizer, 'can_change_event_settings', request=request) + True + +Sometimes, it might be more useful to get the set of permissions at once:: + + >>> user.get_event_permission_set(organizer, event) + {'can_change_event_settings', 'can_view_orders', 'can_change_orders'} + + >>> user.get_organizer_permission_set(organizer, event) + {'can_change_organizer_settings', 'can_create_events'} + +Within a view on the ``/control`` subpath, the results of these two methods are already available in the +``request.eventpermset`` and ``request.orgapermset`` properties. This makes it convenient to query them in templates:: + + {% if "can_change_orders" in request.eventpermset %} + … + {% endif %} + +You can also do the reverse to get any events a user has access to:: + + >>> user.get_events_with_permission('can_change_event_settings', request=request) + + + >>> user.get_events_with_any_permission(request=request) + + +Most of these methods work identically on :class:`pretix.base.models.TeamAPIToken`. + +Staff sessions +-------------- + +.. versionchanged:: 1.14 + + In 1.14, the ``User.is_superuser`` attribute has been deprecated and statically set to return ``False``. Staff + sessions have been newly introduced. + +System administrators of a pretix instance are identified by the ``is_staff`` attribute on the user model. By default, +the regular permission rules apply for users with ``is_staff = True``. The only difference is that such users can +temporarily turn on "staff mode" via a button in the user interface that grants them **all permissions** as long as +staff mode is active. You can check if a user is in staff mode using their session key: + + >>> user.has_active_staff_session(request.session.session_key) + False + +Staff mode has a hard time limit and during staff mode, a middleware will log all requests made by that user. Later, +the user is able to also save a message to comment on what they did in their administrative session. This feature is +intended to help compliance with data protection rules as imposed e.g. by GDPR. diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 4da02b86a..7ba3ebde5 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -1,6 +1,7 @@ addon addons api +auditability auth autobuild backend diff --git a/doc/user/organizers/teams.rst b/doc/user/organizers/teams.rst index 620d6fb68..0cc252602 100644 --- a/doc/user/organizers/teams.rst +++ b/doc/user/organizers/teams.rst @@ -1,3 +1,5 @@ +.. _user-teams: + Teams ===== diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index 2bdcedc75..c62a66656 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -12,7 +12,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): if self.request.user.is_authenticated(): - if self.request.user.is_superuser: + if self.request.user.has_active_staff_session(self.request.session.session_key): return Organizer.objects.all() else: return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True)) diff --git a/src/pretix/base/__init__.py b/src/pretix/base/__init__.py index ea6b54a5a..7f8f5fb88 100644 --- a/src/pretix/base/__init__.py +++ b/src/pretix/base/__init__.py @@ -12,7 +12,7 @@ class PretixBaseConfig(AppConfig): from . import exporters # NOQA from . import invoice # NOQA from . import notifications # NOQA - from .services import export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA + from .services import auth, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA try: from .celery_app import app as celery_app # NOQA diff --git a/src/pretix/base/migrations/0087_auto_20180317_1952.py b/src/pretix/base/migrations/0087_auto_20180317_1952.py new file mode 100644 index 000000000..5b7df5646 --- /dev/null +++ b/src/pretix/base/migrations/0087_auto_20180317_1952.py @@ -0,0 +1,64 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.10 on 2018-03-17 19:52 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models + + +def set_is_staff(apps, schema_editor): + User = apps.get_model('pretixbase', 'User') + User.objects.filter(is_superuser=True).update(is_staff=True) + + +class Migration(migrations.Migration): + dependencies = [ + ('pretixbase', '0086_auto_20180320_1219'), + ] + + operations = [ + migrations.RunPython( + set_is_staff, + migrations.RunPython.noop, + ), + migrations.RemoveField( + model_name='user', + name='is_superuser', + ), + migrations.CreateModel( + name='StaffSession', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_start', models.DateTimeField(auto_now_add=True)), + ('date_end', models.DateTimeField(blank=True, null=True)), + ('session_key', models.CharField(max_length=255)), + ('comment', models.TextField()), + ], + ), + migrations.CreateModel( + name='StaffSessionAuditLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('datetime', models.DateTimeField(auto_now_add=True)), + ('url', models.CharField(max_length=255)), + ('session', models.ForeignKey(on_delete=models.deletion.CASCADE, related_name='logs', to='pretixbase.StaffSession')), + ], + ), + migrations.AddField( + model_name='staffsession', + name='user', + field=models.ForeignKey(default=None, on_delete=models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='staffsessionauditlog', + name='impersonating', + field=models.ForeignKey(blank=True, null=True, on_delete=models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='staffsessionauditlog', + name='method', + field=models.CharField(default='GET', max_length=255), + preserve_default=False, + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 7b465f59f..d95b441a4 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -19,7 +19,9 @@ from .orders import ( cachedcombinedticket_name, cachedticket_name, generate_position_secret, generate_secret, ) -from .organizer import Organizer, Organizer_SettingsStore, Team, TeamInvite +from .organizer import ( + Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite, +) from .tax import TaxRule from .vouchers import Voucher from .waitinglist import WaitingListEntry diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index 20ac71f90..8adb1958f 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -1,4 +1,4 @@ -from typing import Union +from datetime import timedelta from django.conf import settings from django.contrib.auth.models import ( @@ -9,6 +9,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import Q from django.utils.crypto import get_random_string +from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from django_otp.models import Device @@ -36,7 +37,6 @@ class UserManager(BaseUserManager): raise Exception("You must provide a password") user = self.model(email=email) user.is_staff = True - user.is_superuser = True user.set_password(password) user.save() return user @@ -46,6 +46,11 @@ def generate_notifications_token(): return get_random_string(length=32) +class SuperuserPermissionSet: + def __contains__(self, item): + return True + + class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): """ This is the user model used by pretix for authentication. @@ -114,6 +119,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): def __str__(self): return self.email + @property + def is_superuser(self): + return False + def get_short_name(self) -> str: """ Returns the first of the following user properties that is found to exist: @@ -194,40 +203,36 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): )) return self._teamcache['e{}'.format(event.pk)] - class SuperuserPermissionSet: - def __contains__(self, item): - return True - - def get_event_permission_set(self, organizer, event) -> Union[set, SuperuserPermissionSet]: + def get_event_permission_set(self, organizer, event) -> set: """ Gets a set of permissions (as strings) that a user holds for a particular event :param organizer: The organizer of the event :param event: The event to check - :return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where - a in b always returns true). + :return: set """ - if self.is_superuser: - return self.SuperuserPermissionSet() - teams = self._get_teams_for_event(organizer, event) - return set.union(*[t.permission_set() for t in teams]) + sets = [t.permission_set() for t in teams] + if sets: + return set.union(*sets) + else: + return set() - def get_organizer_permission_set(self, organizer) -> Union[set, SuperuserPermissionSet]: + def get_organizer_permission_set(self, organizer) -> set: """ Gets a set of permissions (as strings) that a user holds for a particular organizer :param organizer: The organizer of the event - :return: set in case of a normal user and a SuperuserPermissionSet in case of a superuser (fake object where - a in b always returns true). + :return: set """ - if self.is_superuser: - return self.SuperuserPermissionSet() - teams = self._get_teams_for_organizer(organizer) - return set.union(*[t.permission_set() for t in teams]) + sets = [t.permission_set() for t in teams] + if sets: + return set.union(*sets) + else: + return set() - def has_event_permission(self, organizer, event, perm_name=None) -> bool: + def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool: """ Checks if this user is part of any team that grants access of type ``perm_name`` to the event ``event``. @@ -235,9 +240,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): :param organizer: The organizer of the event :param event: The event to check :param perm_name: The permission, e.g. ``can_change_teams`` + :param request: The current request (optional). Required to detect staff sessions properly. :return: bool """ - if self.is_superuser: + if request and self.has_active_staff_session(request.session.session_key): return True teams = self._get_teams_for_event(organizer, event) if teams: @@ -246,16 +252,17 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): return True return False - def has_organizer_permission(self, organizer, perm_name=None): + def has_organizer_permission(self, organizer, perm_name=None, request=None): """ Checks if this user is part of any team that grants access of type ``perm_name`` to the organizer ``organizer``. :param organizer: The organizer to check :param perm_name: The permission, e.g. ``can_change_teams`` + :param request: The current request (optional). Required to detect staff sessions properly. :return: bool """ - if self.is_superuser: + if request and self.has_active_staff_session(request.session.session_key): return True teams = self._get_teams_for_organizer(organizer) if teams: @@ -263,15 +270,16 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): return True return False - def get_events_with_any_permission(self): + def get_events_with_any_permission(self, request=None): """ Returns a queryset of events the user has any permissions to. + :param request: The current request (optional). Required to detect staff sessions properly. :return: Iterable of Events """ from .event import Event - if self.is_superuser: + if request and self.has_active_staff_session(request.session.session_key): return Event.objects.all() return Event.objects.filter( @@ -279,15 +287,16 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): | Q(id__in=self.teams.values_list('limit_events__id', flat=True)) ) - def get_events_with_permission(self, permission): + def get_events_with_permission(self, permission, request=None): """ Returns a queryset of events the user has a specific permissions to. + :param request: The current request (optional). Required to detect staff sessions properly. :return: Iterable of Events """ from .event import Event - if self.is_superuser: + if request and self.has_active_staff_session(request.session.session_key): return Event.objects.all() kwargs = {permission: True} @@ -297,6 +306,56 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): | Q(id__in=self.teams.filter(**kwargs).values_list('limit_events__id', flat=True)) ) + def has_active_staff_session(self, session_key=None): + """ + Returns whether or not a user has an active staff session (formerly known as superuser session) + with the given session key. + """ + return self.get_active_staff_session(session_key) is not None + + def get_active_staff_session(self, session_key=None): + if not self.is_staff: + return None + if not hasattr(self, '_staff_session_cache'): + self._staff_session_cache = {} + if session_key not in self._staff_session_cache: + qs = StaffSession.objects.filter( + user=self, date_end__isnull=True + ) + if session_key: + qs = qs.filter(session_key=session_key) + sess = qs.first() + if sess: + if sess.date_start < now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_ABSOLUTE): + sess.date_end = now() + sess.save() + sess = None + + self._staff_session_cache[session_key] = sess + return self._staff_session_cache[session_key] + + +class StaffSession(models.Model): + user = models.ForeignKey('User') + date_start = models.DateTimeField(auto_now_add=True) + date_end = models.DateTimeField(null=True, blank=True) + session_key = models.CharField(max_length=255) + comment = models.TextField() + + class Meta: + ordering = ('date_start',) + + +class StaffSessionAuditLog(models.Model): + session = models.ForeignKey('StaffSession', related_name='logs') + datetime = models.DateTimeField(auto_now_add=True) + url = models.CharField(max_length=255) + method = models.CharField(max_length=255) + impersonating = models.ForeignKey('User', null=True, blank=True) + + class Meta: + ordering = ('datetime',) + class U2FDevice(Device): json_data = models.TextField() diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index c64d90f82..8190139d9 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -554,9 +554,7 @@ class Event(EventMixin, LoggedModel): Q(all_events=True) | Q(limit_events__pk=self.pk) ) - return User.objects.annotate(twp=Exists(team_with_perm)).filter( - Q(is_superuser=True) | Q(twp=True) - ) + return User.objects.annotate(twp=Exists(team_with_perm)).filter(twp=True) def allow_delete(self): return not self.orders.exists() and not self.invoices.exists() diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index 07607fbb6..8a6e380cd 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -264,7 +264,7 @@ class TeamAPIToken(models.Model): """ return self.team.permission_set() if self.team.organizer == organizer else set() - def has_event_permission(self, organizer, event, perm_name=None) -> bool: + def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool: """ Checks if this token is part of a team that grants access of type ``perm_name`` to the event ``event``. @@ -272,6 +272,7 @@ class TeamAPIToken(models.Model): :param organizer: The organizer of the event :param event: The event to check :param perm_name: The permission, e.g. ``can_change_teams`` + :param request: This parameter is ignored and only defined for compatibility reasons. :return: bool """ has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( @@ -279,13 +280,14 @@ class TeamAPIToken(models.Model): ) return has_event_access and (not perm_name or self.team.has_permission(perm_name)) - def has_organizer_permission(self, organizer, perm_name=None): + def has_organizer_permission(self, organizer, perm_name=None, request=None): """ Checks if this token is part of a team that grants access of type ``perm_name`` to the organizer ``organizer``. :param organizer: The organizer to check :param perm_name: The permission, e.g. ``can_change_teams`` + :param request: This parameter is ignored and only defined for compatibility reasons. :return: bool """ return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name)) diff --git a/src/pretix/base/services/auth.py b/src/pretix/base/services/auth.py new file mode 100644 index 000000000..6359ec763 --- /dev/null +++ b/src/pretix/base/services/auth.py @@ -0,0 +1,19 @@ +from datetime import timedelta + +from django.conf import settings +from django.db.models import Max, Q +from django.dispatch import receiver +from django.utils.timezone import now + +from pretix.base.models.auth import StaffSession + +from ..signals import periodic_task + + +@receiver(signal=periodic_task) +def close_inactive_staff_sessions(sender, **kwargs): + StaffSession.objects.annotate(last_used=Max('logs__datetime')).filter( + Q(last_used__lte=now() - timedelta(seconds=settings.PRETIX_SESSION_TIMEOUT_RELATIVE)) & Q(date_end__isnull=True) + ).update( + date_end=now() + ) diff --git a/src/pretix/control/context.py b/src/pretix/control/context.py index 1aa21026d..ddf44437c 100644 --- a/src/pretix/control/context.py +++ b/src/pretix/control/context.py @@ -3,8 +3,10 @@ from importlib import import_module from django.conf import settings from django.core.urlresolvers import Resolver404, get_script_prefix, resolve +from django.db.models import Q from django.utils.translation import get_language +from pretix.base.models.auth import StaffSession from pretix.base.settings import GlobalSettingsObject from ..helpers.i18n import get_javascript_format, get_moment_locale @@ -93,10 +95,19 @@ def contextprocessor(request): ctx['warning_update_check_active'] = False gs = GlobalSettingsObject() ctx['global_settings'] = gs.settings - if request.user.is_superuser: + if request.user.is_staff: if gs.settings.update_check_result_warning: ctx['warning_update_available'] = True if not gs.settings.update_check_ack and 'runserver' not in sys.argv: ctx['warning_update_check_active'] = True + if request.user.is_authenticated: + ctx['staff_session'] = request.user.has_active_staff_session(request.session.session_key) + ctx['staff_need_to_explain'] = ( + StaffSession.objects.filter(user=request.user, date_end__isnull=False).filter( + Q(comment__isnull=True) | Q(comment="") + ) + if request.user.is_staff and settings.PRETIX_ADMIN_AUDIT_COMMENTS else StaffSession.objects.none() + ) + return ctx diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 3a042a724..ecb4a22b4 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -234,7 +234,7 @@ class OrderSearchFilterForm(OrderFilterForm): def __init__(self, *args, **kwargs): request = kwargs.pop('request') super().__init__(*args, **kwargs) - if request.user.is_superuser: + if request.user.has_active_staff_session(request.session.session_key): self.fields['organizer'].queryset = Organizer.objects.all() else: self.fields['organizer'].queryset = Organizer.objects.filter( @@ -393,7 +393,7 @@ class EventFilterForm(FilterForm): def __init__(self, *args, **kwargs): request = kwargs.pop('request') super().__init__(*args, **kwargs) - if request.user.is_superuser: + if request.user.has_active_staff_session(request.session.session_key): self.fields['organizer'].queryset = Organizer.objects.all() else: self.fields['organizer'].queryset = Organizer.objects.filter( @@ -583,9 +583,9 @@ class UserFilterForm(FilterForm): qs = qs.filter(is_active=False) if fdata.get('superuser') == 'yes': - qs = qs.filter(is_superuser=True) + qs = qs.filter(is_staff=True) elif fdata.get('superuser') == 'no': - qs = qs.filter(is_superuser=False) + qs = qs.filter(is_staff=False) if fdata.get('query'): qs = qs.filter( diff --git a/src/pretix/control/forms/users.py b/src/pretix/control/forms/users.py index a413e27e0..8282e917e 100644 --- a/src/pretix/control/forms/users.py +++ b/src/pretix/control/forms/users.py @@ -8,6 +8,13 @@ from django.utils.translation import ugettext_lazy as _ from pytz import common_timezones from pretix.base.models import User +from pretix.base.models.auth import StaffSession + + +class StaffSessionForm(forms.ModelForm): + class Meta: + model = StaffSession + fields = ['comment'] class UserEditForm(forms.ModelForm): @@ -41,7 +48,7 @@ class UserEditForm(forms.ModelForm): 'email', 'require_2fa', 'is_active', - 'is_superuser' + 'is_staff' ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/control/middleware.py b/src/pretix/control/middleware.py index bf4253db5..5edc17347 100644 --- a/src/pretix/control/middleware.py +++ b/src/pretix/control/middleware.py @@ -4,12 +4,14 @@ from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME, logout from django.core.urlresolvers import get_script_prefix, resolve, reverse from django.http import Http404 -from django.shortcuts import redirect, resolve_url +from django.shortcuts import get_object_or_404, redirect, resolve_url from django.utils.deprecation import MiddlewareMixin from django.utils.encoding import force_str from django.utils.translation import ugettext as _ +from hijack.templatetags.hijack_tags import is_hijacked from pretix.base.models import Event, Organizer +from pretix.base.models.auth import SuperuserPermissionSet, User from pretix.helpers.security import ( SessionInvalid, SessionReauthRequired, assert_session_valid, ) @@ -81,16 +83,52 @@ class PermissionMiddleware(MiddlewareMixin): slug=url.kwargs['event'], organizer__slug=url.kwargs['organizer'], ).select_related('organizer').first() - if not request.event or not request.user.has_event_permission(request.event.organizer, request.event): + if not request.event or not request.user.has_event_permission(request.event.organizer, request.event, + request=request): raise Http404(_("The selected event was not found or you " "have no permission to administrate it.")) request.organizer = request.event.organizer - request.eventpermset = request.user.get_event_permission_set(request.organizer, request.event) + 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) elif 'organizer' in url.kwargs: request.organizer = Organizer.objects.filter( slug=url.kwargs['organizer'], ).first() - if not request.organizer or not request.user.has_organizer_permission(request.organizer): + if not request.organizer or not request.user.has_organizer_permission(request.organizer, request=request): raise Http404(_("The selected organizer was not found or you " "have no permission to administrate it.")) - request.orgapermset = request.user.get_organizer_permission_set(request.organizer) + 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) + + +class AuditLogMiddleware: + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if request.path.startswith(get_script_prefix() + 'control') and request.user.is_authenticated: + if is_hijacked(request): + hijack_history = request.session.get('hijack_history', False) + hijacker = get_object_or_404(User, pk=hijack_history[0]) + ss = hijacker.get_active_staff_session(request.session.get('hijacker_session')) + if ss: + ss.logs.create( + url=request.path, + method=request.method, + impersonating=request.user + ) + else: + ss = request.user.get_active_staff_session(request.session.session_key) + if ss: + ss.logs.create( + url=request.path, + method=request.method + ) + + response = self.get_response(request) + return response diff --git a/src/pretix/control/permissions.py b/src/pretix/control/permissions.py index 80b5339dc..4ca5bbf2f 100644 --- a/src/pretix/control/permissions.py +++ b/src/pretix/control/permissions.py @@ -1,4 +1,7 @@ from django.core.exceptions import PermissionDenied +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.http import urlquote from django.utils.translation import ugettext as _ @@ -18,8 +21,7 @@ def event_permission_required(permission): raise PermissionDenied() allowed = ( - request.user.is_superuser - or request.user.has_event_permission(request.organizer, request.event, permission) + request.user.has_event_permission(request.organizer, request.event, permission, request=request) ) if allowed: return function(request, *args, **kw) @@ -57,10 +59,7 @@ def organizer_permission_required(permission): # just a double check, should not ever happen raise PermissionDenied() - allowed = ( - request.user.is_superuser - or request.user.has_organizer_permission(request.organizer, permission) - ) + allowed = request.user.has_organizer_permission(request.organizer, permission, request=request) if allowed: return function(request, *args, **kw) @@ -85,14 +84,33 @@ class OrganizerPermissionRequiredMixin: def administrator_permission_required(): """ This view decorator rejects all requests with a 403 response which are not from - users with the is_superuser flag. + users with a current staff member session. """ def decorator(function): def wrapper(request, *args, **kw): if not request.user.is_authenticated: # NOQA # just a double check, should not ever happen raise PermissionDenied() - if not request.user.is_superuser: + if not request.user.has_active_staff_session(request.session.session_key): + if request.user.is_staff: + return redirect(reverse('control:user.sudo') + '?next=' + urlquote(request.path)) + raise PermissionDenied(_('You do not have permission to view this content.')) + return function(request, *args, **kw) + return wrapper + return decorator + + +def staff_member_required(): + """ + This view decorator rejects all requests with a 403 response which are not staff + members (but do not need to have an active session). + """ + def decorator(function): + def wrapper(request, *args, **kw): + if not request.user.is_authenticated: # NOQA + # just a double check, should not ever happen + raise PermissionDenied() + if not request.user.is_staff: raise PermissionDenied(_('You do not have permission to view this content.')) return function(request, *args, **kw) return wrapper @@ -108,3 +126,14 @@ class AdministratorPermissionRequiredMixin: def as_view(cls, **initkwargs): view = super(AdministratorPermissionRequiredMixin, cls).as_view(**initkwargs) return administrator_permission_required()(view) + + +class StaffMemberRequiredMixin: + """ + This mixin is equivalent to the staff_memer_required view decorator but + is in a form suitable for class-based views. + """ + @classmethod + def as_view(cls, **initkwargs): + view = super(StaffMemberRequiredMixin, cls).as_view(**initkwargs) + return staff_member_required()(view) diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 6949bc55b..5fd55f379 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -151,6 +151,22 @@ {% endfor %} + {% if request.user.is_staff and not staff_session %} +
  • +
    + {% csrf_token %} + +
    +
  • + {% elif request.user.is_staff and staff_session %} +
  • + + {% trans "End admin session" %} + +
  • + {% endif %} {% if warning_update_available %}
  • @@ -191,7 +207,7 @@ {% trans "Dashboard" %}
  • - {% if request.user.is_superuser %} + {% if staff_session %}
  • @@ -219,14 +235,21 @@ {% trans "Order search" %}
  • - {% if request.user.is_superuser %} + {% if staff_session %}
  • + {% if "users" in url_name %}class="active"{% endif %}> {% trans "Users" %}
  • +
  • + + + {% trans "Admin sessions" %} + +
  • {% endif %} {% for nav in nav_global %}
  • @@ -260,6 +283,21 @@ + {% if staff_need_to_explain %} +
    + + {% blocktrans trimmed %} + Please leave a short comment on what you did in the following admin sessions: + {% endblocktrans %} +
      + {% for s in staff_need_to_explain %} +
    • + #{{ s.pk }} +
    • + {% endfor %} +
    +
    + {% endif %} {% if request|is_hijacked %}
    diff --git a/src/pretix/control/templates/pretixcontrol/event/index.html b/src/pretix/control/templates/pretixcontrol/event/index.html index 19810f896..2d0f4fe88 100644 --- a/src/pretix/control/templates/pretixcontrol/event/index.html +++ b/src/pretix/control/templates/pretixcontrol/event/index.html @@ -109,7 +109,7 @@
    {% if log.user %} - {% if log.user.is_superuser %} + {% if log.user.is_staff %} diff --git a/src/pretix/control/templates/pretixcontrol/event/logs.html b/src/pretix/control/templates/pretixcontrol/event/logs.html index 81af0c75b..f79e3d63d 100644 --- a/src/pretix/control/templates/pretixcontrol/event/logs.html +++ b/src/pretix/control/templates/pretixcontrol/event/logs.html @@ -34,7 +34,7 @@
    {% if log.user %} - {% if log.user.is_superuser %} + {% if log.user.is_staff %} diff --git a/src/pretix/control/templates/pretixcontrol/event/plugins.html b/src/pretix/control/templates/pretixcontrol/event/plugins.html index 54aec322a..89b522095 100644 --- a/src/pretix/control/templates/pretixcontrol/event/plugins.html +++ b/src/pretix/control/templates/pretixcontrol/event/plugins.html @@ -23,7 +23,7 @@
    {% if plugin.app.compatibility_errors %} - {% elif plugin.restricted and not request.user.is_superuser %} + {% elif plugin.restricted and not request.user.is_staff %} {% elif plugin.module in plugins_active %} @@ -44,7 +44,7 @@ {% endblocktrans %}

    {% endif %}

    {{ plugin.description }}

    - {% if plugin.restricted and not request.user.is_superuser %} + {% if plugin.restricted and not request.user.is_staff %}
    {% trans "This plugin needs to be enabled by a system administrator for your event." %}
    diff --git a/src/pretix/control/templates/pretixcontrol/events/create_base.html b/src/pretix/control/templates/pretixcontrol/events/create_base.html index 7b2307c89..4e0fa772e 100644 --- a/src/pretix/control/templates/pretixcontrol/events/create_base.html +++ b/src/pretix/control/templates/pretixcontrol/events/create_base.html @@ -29,7 +29,7 @@
    {% trans "Every event needs to be created as part of an organizer account. Currently, you do not have access to any organizer accounts." %}
    - {% if request.user.is_superuser %} + {% if staff_session %} {% trans "Create a new organizer" %} diff --git a/src/pretix/control/templates/pretixcontrol/includes/logs.html b/src/pretix/control/templates/pretixcontrol/includes/logs.html index fa326f806..2cffc9fc4 100644 --- a/src/pretix/control/templates/pretixcontrol/includes/logs.html +++ b/src/pretix/control/templates/pretixcontrol/includes/logs.html @@ -6,7 +6,7 @@

    {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }} {% if log.user %} - {% if log.user.is_superuser %} + {% if log.user.is_staff %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/index.html b/src/pretix/control/templates/pretixcontrol/organizers/index.html index 9a41c4665..4f9bfb492 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/index.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/index.html @@ -20,7 +20,7 @@

    - {% if request.user.is_superuser %} + {% if staff_session %}

    diff --git a/src/pretix/control/templates/pretixcontrol/user/staff_session_edit.html b/src/pretix/control/templates/pretixcontrol/user/staff_session_edit.html new file mode 100644 index 000000000..daf54a28b --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/user/staff_session_edit.html @@ -0,0 +1,47 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Staff session" %}{% endblock %} +{% block content %} +

    {% trans "Session notes" %}

    +
    + {% csrf_token %} + {% bootstrap_form_errors form %} + {% bootstrap_field form.comment layout='horizontal' %} +
    + +
    +
    +

    {% trans "Audit log" %}

    +
    +
    {% trans "Start date" %}
    +
    {{ session.date_start|date:"SHORT_DATETIME_FORMAT" }}
    +
    {% trans "End date" %}
    +
    {{ session.date_end|date:"SHORT_DATETIME_FORMAT" }}
    +
    {% trans "User" %}
    +
    {{ session.user.email }}
    +
    + + + + + + + + + + + {% for log in logs %} + + + + + + + {% endfor %} + + +
    {% trans "Timestamp" %}{% trans "Method" %}{% trans "URL" %}{% trans "On behalf of" %}
    {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}{{ log.method }}{{ log.url }}{{ log.impersonating|default:"" }}
    +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/user/staff_session_list.html b/src/pretix/control/templates/pretixcontrol/user/staff_session_list.html new file mode 100644 index 000000000..3724d5a48 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/user/staff_session_list.html @@ -0,0 +1,58 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load urlreplace %} +{% block title %}{% trans "Admin sessions" %}{% endblock %} +{% block content %} +

    {% trans "Admin sessions" %}

    + + + + + + + + + + + + + {% for s in sessions %} + + + + + + + + + {% endfor %} + +
    + # + + {% trans "User" %} + + {% trans "Start date" %} + {% trans "End date" %}{% trans "Comment" %}
    + {{ s.pk }} + + {{ s.user.email }} + + {{ s.date_start|date:"SHORT_DATETIME_FORMAT" }} + + {% if s.date_end %} + {{ s.date_end|date:"SHORT_DATETIME_FORMAT" }} + {% endif %} + + {% if s.comment %} + + {% else %} + + {% endif %} + + +
    + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/user/staff_session_start.html b/src/pretix/control/templates/pretixcontrol/user/staff_session_start.html new file mode 100644 index 000000000..93594d4c9 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/user/staff_session_start.html @@ -0,0 +1,22 @@ +{% extends "pretixcontrol/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Admin mode" %}{% endblock %} +{% block content %} +

    {% trans "Admin mode" %}

    +

    + {% blocktrans trimmed %} + To perform this action, you need to start an administrative session. Everything you do in that session + will be logged and you will later be asked to fill in a comment on what you did in your session for later + reference. + {% endblocktrans %} +

    +
    + {% csrf_token %} +
    + +
    +
    +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/users/form.html b/src/pretix/control/templates/pretixcontrol/users/form.html index ad1c9c758..2acf2c46f 100644 --- a/src/pretix/control/templates/pretixcontrol/users/form.html +++ b/src/pretix/control/templates/pretixcontrol/users/form.html @@ -25,7 +25,7 @@ {% bootstrap_field form.fullname layout='control' %} {% bootstrap_field form.locale layout='control' %} {% bootstrap_field form.timezone layout='control' %} - {% bootstrap_field form.is_superuser layout='control' %} + {% bootstrap_field form.is_staff layout='control' %}
    {% trans "Log-in settings" %} diff --git a/src/pretix/control/templates/pretixcontrol/users/index.html b/src/pretix/control/templates/pretixcontrol/users/index.html index 74d68de4a..d295c8333 100644 --- a/src/pretix/control/templates/pretixcontrol/users/index.html +++ b/src/pretix/control/templates/pretixcontrol/users/index.html @@ -56,7 +56,7 @@ {{ u.fullname|default_if_none:"" }} {% if u.is_active %}{% endif %} - {% if u.is_superuser %}{% endif %} + {% if u.is_staff %}{% endif %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 37055efd3..c3a0849b3 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -19,6 +19,10 @@ urlpatterns = [ url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'), url(r'^global/message/$', global_settings.MessageView.as_view(), name='global.message'), url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'), + url(r'^sudo/$', user.StartStaffSession.as_view(), name='user.sudo'), + url(r'^sudo/stop/$', user.StopStaffSession.as_view(), name='user.sudo.stop'), + url(r'^sudo/(?P\d+)/$', user.EditStaffSession.as_view(), name='user.sudo.edit'), + url(r'^sudo/sessions/$', user.StaffSessionList.as_view(), name='user.sudo.list'), url(r'^users/$', users.UserListView.as_view(), name='users'), url(r'^users/select2$', typeahead.users_select2, name='users.select2'), url(r'^users/add$', users.UserCreateView.as_view(), name='users.add'), diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index 7b2cde07c..e25eb81d4 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -248,12 +248,13 @@ def event_index(request, organizer, event): for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent): widgets.extend(result) - can_change_orders = request.user.has_event_permission(request.organizer, request.event, 'can_change_orders') + can_change_orders = request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', + request=request) qs = request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime') qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST) - if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders'): + if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request): qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order)) - if not request.user.has_event_permission(request.organizer, request.event, 'can_view_vouchers'): + if not request.user.has_event_permission(request.organizer, request.event, 'can_view_vouchers', request=request): qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher)) a_qs = request.event.requiredaction_set.filter(done=False) @@ -271,7 +272,7 @@ def event_index(request, organizer, event): return render(request, 'pretixcontrol/event/index.html', ctx) -def annotated_event_query(user): +def annotated_event_query(request): active_orders = Order.objects.filter( event=OuterRef('pk'), status__in=[Order.STATUS_PENDING, Order.STATUS_PAID] @@ -285,7 +286,7 @@ def annotated_event_query(user): event=OuterRef('pk'), done=False ) - qs = user.get_events_with_any_permission().annotate( + qs = request.user.get_events_with_any_permission(request).annotate( order_count=Subquery(active_orders, output_field=IntegerField()), has_ra=Exists(required_actions) ).annotate( @@ -299,7 +300,7 @@ def annotated_event_query(user): return qs -def widgets_for_event_qs(qs, user, nmax): +def widgets_for_event_qs(request, qs, user, nmax): widgets = [] # Get set of events where we have the permission to show the # of orders @@ -370,7 +371,7 @@ def widgets_for_event_qs(qs, user, nmax): orders_text=ungettext('{num} order', '{num} orders', event.order_count or 0).format( num=event.order_count or 0 ) - ) if user.is_superuser or event.pk in events_with_orders else '' + ) if user.has_active_staff_session(request.session.session_key) or event.pk in events_with_orders else '' ), daterange=dr, status=status[1], @@ -402,7 +403,8 @@ def user_index(request): ctx = { 'widgets': rearrange(widgets), 'upcoming': widgets_for_event_qs( - annotated_event_query(request.user).filter( + request, + annotated_event_query(request).filter( Q(has_subevents=False) & Q( Q(Q(date_to__isnull=True) & Q(date_from__gte=now())) @@ -413,7 +415,8 @@ def user_index(request): 7 ), 'past': widgets_for_event_qs( - annotated_event_query(request.user).filter( + request, + annotated_event_query(request).filter( Q(has_subevents=False) & Q( Q(Q(date_to__isnull=True) & Q(date_from__lt=now())) @@ -424,7 +427,8 @@ def user_index(request): 8 ), 'series': widgets_for_event_qs( - annotated_event_query(request.user).filter( + request, + annotated_event_query(request).filter( has_subevents=True ).order_by('-order_to'), request.user, diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 10ea1de89..5f3a91f7f 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -212,7 +212,7 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat module = key.split(":")[1] if value == "enable" and module in plugins_available: if getattr(plugins_available[module], 'restricted', False): - if not request.user.is_superuser: + if not request.user.has_active_staff_session(request.session.session_key): continue if hasattr(plugins_available[module].app, 'installed'): @@ -854,9 +854,11 @@ class EventLog(EventPermissionRequiredMixin, ListView): def get_queryset(self): qs = self.request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime') qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST) - if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_orders'): + if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_orders', + request=self.request): qs = qs.exclude(content_type=ContentType.objects.get_for_model(Order)) - if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_vouchers'): + if not self.request.user.has_event_permission(self.request.organizer, self.request.event, 'can_view_vouchers', + request=self.request): qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher)) if self.request.GET.get('user') == 'yes': diff --git a/src/pretix/control/views/global_settings.py b/src/pretix/control/views/global_settings.py index 9108cad51..930155518 100644 --- a/src/pretix/control/views/global_settings.py +++ b/src/pretix/control/views/global_settings.py @@ -8,7 +8,9 @@ from pretix.base.settings import GlobalSettingsObject from pretix.control.forms.global_settings import ( GlobalSettingsForm, UpdateSettingsForm, ) -from pretix.control.permissions import AdministratorPermissionRequiredMixin +from pretix.control.permissions import ( + AdministratorPermissionRequiredMixin, StaffMemberRequiredMixin, +) class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView): @@ -28,7 +30,7 @@ class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView): return reverse('control:global.settings') -class UpdateCheckView(AdministratorPermissionRequiredMixin, FormView): +class UpdateCheckView(StaffMemberRequiredMixin, FormView): template_name = 'pretixcontrol/global_update.html' form_class = UpdateSettingsForm diff --git a/src/pretix/control/views/main.py b/src/pretix/control/views/main.py index 628b50cfe..ad4d6f058 100644 --- a/src/pretix/control/views/main.py +++ b/src/pretix/control/views/main.py @@ -31,7 +31,7 @@ class EventList(PaginationMixin, ListView): template_name = 'pretixcontrol/events/index.html' def get_queryset(self): - qs = self.request.user.get_events_with_any_permission().select_related('organizer').prefetch_related( + qs = self.request.user.get_events_with_any_permission(self.request).select_related('organizer').prefetch_related( '_settings_objects', 'organizer___settings_objects' ).order_by('-date_from') diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index b18417e95..712cc07af 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -38,7 +38,7 @@ class OrganizerList(PaginationMixin, ListView): qs = Organizer.objects.all() if self.filter_form.is_valid(): qs = self.filter_form.filter_qs(qs) - if self.request.user.is_superuser: + if self.request.user.has_active_staff_session(self.request.session.session_key): return qs else: return qs.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True)) @@ -219,7 +219,7 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() - if self.request.user.is_superuser: + if self.request.user.has_active_staff_session(self.request.session.session_key): kwargs['domain'] = True return kwargs @@ -271,7 +271,7 @@ class OrganizerCreate(CreateView): context_object_name = 'organizer' def dispatch(self, request, *args, **kwargs): - if not request.user.is_superuser: + if not request.user.has_active_staff_session(self.request.session.session_key): raise PermissionDenied() # TODO return super().dispatch(request, *args, **kwargs) diff --git a/src/pretix/control/views/search.py b/src/pretix/control/views/search.py index 774656f4c..77eda7b51 100644 --- a/src/pretix/control/views/search.py +++ b/src/pretix/control/views/search.py @@ -24,7 +24,7 @@ class OrderSearch(PaginationMixin, ListView): def get_queryset(self): qs = Order.objects.select_related('invoice_address') - if not self.request.user.is_superuser: + if not self.request.user.has_active_staff_session(self.request.session.session_key): qs = qs.filter( Q(event__organizer_id__in=self.request.user.teams.filter( all_events=True, can_view_orders=True).values_list('organizer', flat=True)) diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py index 4410d2d04..a331e5e0d 100644 --- a/src/pretix/control/views/typeahead.py +++ b/src/pretix/control/views/typeahead.py @@ -18,7 +18,7 @@ def event_list(request): page = int(request.GET.get('page', '1')) except ValueError: page = 1 - qs = request.user.get_events_with_any_permission().filter( + qs = request.user.get_events_with_any_permission(request).filter( Q(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) | Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query) ).annotate( @@ -107,7 +107,7 @@ def organizer_select2(request): qs = Organizer.objects.all() if term: qs = qs.filter(Q(name__icontains=term) | Q(slug__icontains=term)) - if not request.user.is_superuser: + if not request.user.has_active_staff_session(request.session.session_key): qs = qs.filter(pk__in=request.user.teams.values_list('organizer', flat=True)) total = qs.count() @@ -130,7 +130,7 @@ def organizer_select2(request): def users_select2(request): - if not request.user.is_superuser: + if not request.user.has_active_staff_session(request.session.session_key): raise PermissionDenied() term = request.GET.get('query', '') diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py index 6119bf2ae..b043ef38a 100644 --- a/src/pretix/control/views/user.py +++ b/src/pretix/control/views/user.py @@ -12,8 +12,10 @@ from django.shortcuts import get_object_or_404, redirect from django.utils.crypto import get_random_string from django.utils.functional import cached_property from django.utils.http import is_safe_url +from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ -from django.views.generic import FormView, TemplateView, UpdateView +from django.views import View +from django.views.generic import FormView, ListView, TemplateView, UpdateView from django_otp.plugins.otp_static.models import StaticDevice from django_otp.plugins.otp_totp.models import TOTPDevice from u2flib_server import u2f @@ -21,7 +23,12 @@ from u2flib_server.jsapi import DeviceRegistration from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm from pretix.base.models import Event, NotificationSetting, U2FDevice, User +from pretix.base.models.auth import StaffSession from pretix.base.notifications import get_all_notification_types +from pretix.control.forms.users import StaffSessionForm +from pretix.control.permissions import ( + AdministratorPermissionRequiredMixin, StaffMemberRequiredMixin, +) from pretix.control.views.auth import get_u2f_appid REAL_DEVICE_TYPES = (TOTPDevice, U2FDevice) @@ -472,3 +479,67 @@ class UserNotificationsEditView(TemplateView): if self.event: ctx['permset'] = self.request.user.get_event_permission_set(self.event.organizer, self.event) return ctx + + +class StartStaffSession(StaffMemberRequiredMixin, RecentAuthenticationRequiredMixin, TemplateView): + template_name = 'pretixcontrol/user/staff_session_start.html' + + def post(self, request, *args, **kwargs): + if not request.user.has_active_staff_session(request.session.session_key): + StaffSession.objects.create( + user=request.user, + session_key=request.session.session_key + ) + + if "next" in request.GET and is_safe_url(request.GET.get("next")): + return redirect(request.GET.get("next")) + else: + return redirect(reverse("control:index")) + + +class StopStaffSession(StaffMemberRequiredMixin, View): + + def get(self, request, *args, **kwargs): + session = StaffSession.objects.filter( + date_end__isnull=True, session_key=request.session.session_key, user=request.user, + ).first() + if not session: + return redirect(reverse("control:index")) + + session.date_end = now() + session.save() + return redirect(reverse("control:user.sudo.edit", kwargs={'id': session.pk})) + + +class StaffSessionList(AdministratorPermissionRequiredMixin, ListView): + context_object_name = 'sessions' + template_name = 'pretixcontrol/user/staff_session_list.html' + paginate_by = 25 + model = StaffSession + + def get_queryset(self): + return StaffSession.objects.select_related('user').order_by('-date_start') + + +class EditStaffSession(StaffMemberRequiredMixin, UpdateView): + context_object_name = 'session' + template_name = 'pretixcontrol/user/staff_session_edit.html' + form_class = StaffSessionForm + + def get_success_url(self): + return reverse("control:user.sudo.edit", kwargs={'id': self.object.pk}) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['logs'] = self.object.logs.select_related('impersonating') + return ctx + + def form_valid(self, form): + messages.success(self.request, _('Your comment has been saved.')) + return super().form_valid(form) + + def get_object(self, queryset=None): + if self.request.user.has_active_staff_session(self.request.session.session_key): + return get_object_or_404(StaffSession, pk=self.kwargs['id']) + else: + return get_object_or_404(StaffSession, pk=self.kwargs['id'], user=self.request.user) diff --git a/src/pretix/control/views/users.py b/src/pretix/control/views/users.py index 6c558690f..757cc4094 100644 --- a/src/pretix/control/views/users.py +++ b/src/pretix/control/views/users.py @@ -112,7 +112,9 @@ class UserImpersonateView(AdministratorPermissionRequiredMixin, RecentAuthentica 'other': self.kwargs.get("id"), 'other_email': self.object.email }) + oldkey = request.session.session_key login_user(request, self.object) + request.session['hijacker_session'] = oldkey return redirect(reverse('control:index')) @@ -120,7 +122,14 @@ class UserImpersonateStopView(LoginRequiredMixin, View): def post(self, request, *args, **kwargs): impersonated = request.user + hijs = request.session['hijacker_session'] release_hijack(request) + ss = request.user.get_active_staff_session(hijs) + if ss: + request.session.save() + ss.session_key = request.session.session_key + ss.save() + request.user.log_action('pretix.control.auth.user.impersonate_stopped', user=request.user, data={ diff --git a/src/pretix/control/views/waitinglist.py b/src/pretix/control/views/waitinglist.py index e5894b586..0d75c1ba4 100644 --- a/src/pretix/control/views/waitinglist.py +++ b/src/pretix/control/views/waitinglist.py @@ -48,7 +48,8 @@ class WaitingListView(EventPermissionRequiredMixin, PaginationMixin, ListView): def post(self, request, *args, **kwargs): if 'assign' in request.POST: - if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders'): + if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', + request=request): messages.error(request, _('You do not have permission to do this')) return redirect(reverse('control:event.orders.waitinglist', kwargs={ 'event': request.event.slug, diff --git a/src/pretix/plugins/banktransfer/signals.py b/src/pretix/plugins/banktransfer/signals.py index 423caa24c..7a491a68c 100644 --- a/src/pretix/plugins/banktransfer/signals.py +++ b/src/pretix/plugins/banktransfer/signals.py @@ -35,7 +35,7 @@ def control_nav_import(sender, request=None, **kwargs): @receiver(nav_organizer, dispatch_uid="payment_banktransfer_organav") def control_nav_orga_import(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_organizer_permission(request.organizer, 'can_change_orders'): + if not request.user.has_organizer_permission(request.organizer, 'can_change_orders', request=request): return [] if not request.organizer.events.filter(plugins__icontains='pretix.plugins.banktransfer'): return [] diff --git a/src/pretix/plugins/banktransfer/views.py b/src/pretix/plugins/banktransfer/views.py index 62411076a..fdd4a0785 100644 --- a/src/pretix/plugins/banktransfer/views.py +++ b/src/pretix/plugins/banktransfer/views.py @@ -488,7 +488,7 @@ class OrganizerActionView(OrganizerBanktransferView, OrganizerPermissionRequired def order_qs(self): all = self.request.user.teams.filter(organizer=self.request.organizer, can_change_orders=True, can_view_orders=True, all_events=True).exists() - if self.request.user.is_superuser or all: + if self.request.user.has_active_staff_session(self.request.session.session_key) or all: return Order.objects.filter(event__organizer=self.request.organizer) else: return Order.objects.filter( diff --git a/src/pretix/plugins/pretixdroid/signals.py b/src/pretix/plugins/pretixdroid/signals.py index ea7b23e00..b24a000e6 100644 --- a/src/pretix/plugins/pretixdroid/signals.py +++ b/src/pretix/plugins/pretixdroid/signals.py @@ -15,7 +15,7 @@ from pretix.control.signals import nav_event @receiver(nav_event, dispatch_uid="pretixdroid_nav") def control_nav_import(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders'): + if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request): return [] return [ { diff --git a/src/pretix/plugins/sendmail/signals.py b/src/pretix/plugins/sendmail/signals.py index 7fe852f35..f35d799f5 100644 --- a/src/pretix/plugins/sendmail/signals.py +++ b/src/pretix/plugins/sendmail/signals.py @@ -9,7 +9,7 @@ from pretix.control.signals import nav_event @receiver(nav_event, dispatch_uid="sendmail_nav") def control_nav_import(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders'): + if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request): return [] return [ { diff --git a/src/pretix/plugins/statistics/signals.py b/src/pretix/plugins/statistics/signals.py index 8d5881a77..a08edf1f5 100644 --- a/src/pretix/plugins/statistics/signals.py +++ b/src/pretix/plugins/statistics/signals.py @@ -9,7 +9,7 @@ from pretix.control.signals import nav_event @receiver(nav_event, dispatch_uid="statistics_nav") def control_nav_import(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders'): + if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request): return [] return [ { diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py index b75177895..77aac6102 100644 --- a/src/pretix/presale/utils.py +++ b/src/pretix/presale/utils.py @@ -71,7 +71,7 @@ def _detect_event(request, require_live=True, require_plugin=None): url.url_name == 'event.auth' or ( request.user.is_authenticated - and request.user.has_event_permission(request.organizer, request.event) + and request.user.has_event_permission(request.organizer, request.event, request=request) ) ) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 51837c6f6..c9a241cd6 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -87,6 +87,7 @@ PRETIX_INSTANCE_NAME = config.get('pretix', 'instance_name', fallback='pretix.de PRETIX_REGISTRATION = config.getboolean('pretix', 'registration', fallback=True) PRETIX_PASSWORD_RESET = config.getboolean('pretix', 'password_reset', fallback=True) PRETIX_LONG_SESSIONS = config.getboolean('pretix', 'long_sessions', fallback=True) +PRETIX_ADMIN_AUDIT_COMMENTS = config.getboolean('pretix', 'audit_comments', fallback=False) PRETIX_SESSION_TIMEOUT_RELATIVE = 3600 * 3 PRETIX_SESSION_TIMEOUT_ABSOLUTE = 3600 * 12 @@ -261,6 +262,8 @@ for entry_point in iter_entry_points(group='pretix.plugin', name=None): PLUGINS.append(entry_point.module_name) INSTALLED_APPS.append(entry_point.module_name) +HIJACK_AUTHORIZE_STAFF = True + REST_FRAMEWORK = { 'DEFAULT_PERMISSION_CLASSES': [ @@ -297,6 +300,7 @@ MIDDLEWARE = [ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', 'pretix.control.middleware.PermissionMiddleware', + 'pretix.control.middleware.AuditLogMiddleware', 'pretix.base.middleware.LocaleMiddleware', 'pretix.base.middleware.SecurityMiddleware', 'pretix.presale.middleware.EventMiddleware', diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index f5c457866..76c9aba94 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -53,7 +53,7 @@ nav.navbar .danger, nav.navbar .danger:hover, nav.navbar .danger:active { margin-bottom: 10px; } -#button-shop { +#button-shop, #button-sudo { padding: 15px; min-height: 50px; border: none; diff --git a/src/tests/base/test_permissions.py b/src/tests/base/test_permissions.py index a4d4b8b3b..f7a8acefc 100644 --- a/src/tests/base/test_permissions.py +++ b/src/tests/base/test_permissions.py @@ -1,7 +1,9 @@ import pytest +from django.test import RequestFactory from django.utils.timezone import now from pretix.base.models import Event, Organizer, Team, User +from pretix.multidomain.middlewares import SessionMiddleware @pytest.fixture @@ -25,7 +27,19 @@ def user(): @pytest.fixture def admin(): - return User.objects.create_user('admin@dummy.dummy', 'dummy', is_superuser=True) + u = User.objects.create_user('admin@dummy.dummy', 'dummy', is_staff=True) + return u + + +@pytest.fixture +def admin_request(admin, client): + factory = RequestFactory() + r = factory.get('/') + SessionMiddleware().process_request(r) + r.session.save() + admin.staffsession_set.create(date_start=now(), session_key=r.session.session_key) + admin.staffsession_set.create(date_start=now(), session_key=client.session.session_key) + return r @pytest.mark.django_db @@ -193,23 +207,20 @@ def test_organizer_permissions_multiple_teams(event, user): @pytest.mark.django_db -def test_superuser(event, user): - user.is_superuser = True - user.save() +def test_superuser(event, admin, admin_request): + assert admin.has_organizer_permission(event.organizer, request=admin_request) + assert admin.has_organizer_permission(event.organizer, 'can_create_events', request=admin_request) + assert admin.has_event_permission(event.organizer, event, request=admin_request) + assert admin.has_event_permission(event.organizer, event, 'can_change_event_settings', request=admin_request) - assert user.has_organizer_permission(event.organizer) - assert user.has_organizer_permission(event.organizer, 'can_create_events') - assert user.has_event_permission(event.organizer, event) - assert user.has_event_permission(event.organizer, event, 'can_change_event_settings') + assert 'arbitrary' not in admin.get_event_permission_set(event.organizer, event) + assert 'arbitrary' not in admin.get_organizer_permission_set(event.organizer) - assert 'arbitrary' in user.get_event_permission_set(event.organizer, event) - assert 'arbitrary' in user.get_organizer_permission_set(event.organizer) - - assert event in user.get_events_with_any_permission() + assert event in admin.get_events_with_any_permission(request=admin_request) @pytest.mark.django_db -def test_list_of_events(event, user, admin): +def test_list_of_events(event, user, admin, admin_request): orga2 = Organizer.objects.create(slug='d2', name='d2') event2 = Event.objects.create( organizer=event.organizer, name='Dummy', slug='dummy2', @@ -236,25 +247,25 @@ def test_list_of_events(event, user, admin): team2.limit_events.add(event) team3.limit_events.add(event3) - events = list(user.get_events_with_any_permission()) + events = list(user.get_events_with_any_permission(request=admin_request)) assert event in events assert event2 in events assert event3 in events assert event4 not in events - events = list(user.get_events_with_permission('can_change_event_settings')) + events = list(user.get_events_with_permission('can_change_event_settings', request=admin_request)) assert event not in events assert event2 not in events assert event3 in events assert event4 not in events - assert set(event.get_users_with_any_permission()) == {user, admin} - assert set(event2.get_users_with_any_permission()) == {user, admin} - assert set(event3.get_users_with_any_permission()) == {user, admin} - assert set(event4.get_users_with_any_permission()) == {admin} + assert set(event.get_users_with_any_permission()) == {user} + assert set(event2.get_users_with_any_permission()) == {user} + assert set(event3.get_users_with_any_permission()) == {user} + assert set(event4.get_users_with_any_permission()) == set() - assert set(event.get_users_with_permission('can_change_event_settings')) == {admin} - assert set(event2.get_users_with_permission('can_change_event_settings')) == {admin} - assert set(event3.get_users_with_permission('can_change_event_settings')) == {user, admin} - assert set(event4.get_users_with_permission('can_change_event_settings')) == {admin} - assert set(event.get_users_with_permission('can_change_orders')) == {admin, user} + assert set(event.get_users_with_permission('can_change_event_settings')) == set() + assert set(event2.get_users_with_permission('can_change_event_settings')) == set() + assert set(event3.get_users_with_permission('can_change_event_settings')) == {user} + assert set(event4.get_users_with_permission('can_change_event_settings')) == set() + assert set(event.get_users_with_permission('can_change_orders')) == {user} diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py index ed2e0eb1b..87cb8e82e 100644 --- a/src/tests/control/test_auth.py +++ b/src/tests/control/test_auth.py @@ -8,6 +8,7 @@ from django.contrib.auth.tokens import ( ) from django.core import mail as djmail from django.test import TestCase, override_settings +from django.utils.timezone import now from django_otp.oath import TOTP from django_otp.plugins.otp_totp.models import TOTPDevice from u2flib_server.jsapi import JSONDict @@ -607,3 +608,105 @@ class SessionTimeOutTest(TestCase): self.client.defaults['HTTP_USER_AGENT'] = 'Mozilla/5.0 (X11; Linux x86_64) Something else' response = self.client.get('/control/') self.assertEqual(response.status_code, 302) + + +@pytest.fixture +def user(): + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + return user + + +@pytest.mark.django_db +def test_impersonate(user, client): + client.login(email='dummy@dummy.dummy', password='dummy') + user.is_staff = True + user.save() + ss = user.staffsession_set.create(date_start=now(), session_key=client.session.session_key) + t1 = int(time.time()) - 5 + session = client.session + session['pretix_auth_long_session'] = False + session['pretix_auth_login_time'] = t1 + session['pretix_auth_last_used'] = t1 + session.save() + user2 = User.objects.create_user('dummy2@dummy.dummy', 'dummy') + response = client.post('/control/users/{user}/impersonate'.format(user=user2.pk), follow=True) + assert b'dummy2@' in response.content + response = client.get('/control/global/settings/') + assert response.status_code == 403 + response = client.get('/control/') + response = client.post('/control/users/impersonate/stop', follow=True) + assert b'dummy@' in response.content + assert b'dummy2@' not in response.content + response = client.get('/control/global/settings/') + assert response.status_code == 200 # staff session is preserved + assert ss.logs.filter(url='/control/', impersonating=user2).exists() + + +@pytest.mark.django_db +def test_impersonate_require_recent_auth(user, client): + client.login(email='dummy@dummy.dummy', password='dummy') + user.is_staff = True + user.save() + user.staffsession_set.create(date_start=now(), session_key=client.session.session_key) + t1 = int(time.time()) - 5 * 3600 + session = client.session + session['pretix_auth_long_session'] = False + session['pretix_auth_login_time'] = t1 + session['pretix_auth_last_used'] = t1 + session.save() + user2 = User.objects.create_user('dummy2@dummy.dummy', 'dummy') + response = client.post('/control/users/{user}/impersonate'.format(user=user2.pk), follow=True) + assert b'dummy2@' not in response.content + + +@pytest.mark.django_db +def test_staff_session(user, client): + client.login(email='dummy@dummy.dummy', password='dummy') + user.is_staff = True + user.save() + t1 = int(time.time()) - 5 + session = client.session + session['pretix_auth_long_session'] = False + session['pretix_auth_login_time'] = t1 + session['pretix_auth_last_used'] = t1 + session.save() + response = client.get('/control/global/settings/') + assert response.status_code == 302 + response = client.post('/control/sudo/') + assert response['Location'] == '/control/' + response = client.get('/control/global/settings/') + assert response.status_code == 200 + client.post('/control/sudo/stop', follow=True) + response = client.get('/control/global/settings/') + assert response.status_code == 302 + assert user.staffsession_set.last().logs.filter(url='/control/global/settings/').exists() + + +@pytest.mark.django_db +def test_staff_session_require_recent_auth(user, client): + client.login(email='dummy@dummy.dummy', password='dummy') + user.is_staff = True + user.save() + t1 = int(time.time()) - 5 * 3600 + session = client.session + session['pretix_auth_long_session'] = False + session['pretix_auth_login_time'] = t1 + session['pretix_auth_last_used'] = t1 + session.save() + response = client.post('/control/sudo/') + assert response['Location'].startswith('/control/reauth/') + + +@pytest.mark.django_db +def test_staff_session_require_staff(user, client): + user.is_staff = False + user.save() + client.login(email='dummy@dummy.dummy', password='dummy') + t1 = int(time.time()) - 5 + session = client.session + session['pretix_auth_long_session'] = False + session['pretix_auth_login_time'] = t1 + session['pretix_auth_last_used'] = t1 + session.save() + response = client.post('/control/sudo/') + assert response.status_code == 403 diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 6fdfcf93b..0649ac42e 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -26,13 +26,20 @@ def env(): superuser_urls = [ "global/settings/", - "global/update/", "users/select2", "users/", "users/add", "users/1/", "users/1/impersonate", "users/1/reset", + "sudo/sessions/", +] + + +staff_urls = [ + "global/update/", + "sudo/", + "sudo/2/", ] event_urls = [ @@ -146,10 +153,26 @@ def test_logged_out(client, env, url): @pytest.mark.django_db @pytest.mark.parametrize("url", superuser_urls) def test_superuser_required(perf_patch, client, env, url): + client.login(email='dummy@dummy.dummy', password='dummy') + env[1].is_staff = True + env[1].save() + response = client.get('/control/' + url) + if response.status_code == 302: + assert '/sudo/' in response['Location'] + else: + assert response.status_code == 403 + env[1].staffsession_set.create(date_start=now(), session_key=client.session.session_key) + response = client.get('/control/' + url) + assert response.status_code in (200, 302, 404) + + +@pytest.mark.django_db +@pytest.mark.parametrize("url", staff_urls) +def test_staff_required(perf_patch, client, env, url): client.login(email='dummy@dummy.dummy', password='dummy') response = client.get('/control/' + url) assert response.status_code == 403 - env[1].is_superuser = True + env[1].is_staff = True env[1].save() response = client.get('/control/' + url) assert response.status_code in (200, 302, 404) diff --git a/src/tests/control/test_search.py b/src/tests/control/test_search.py index a8cefe923..44a1025ec 100644 --- a/src/tests/control/test_search.py +++ b/src/tests/control/test_search.py @@ -99,8 +99,9 @@ class OrderSearchTest(SoupTest): assert 'FO1' not in resp assert 'FO2' not in resp - def test_suberuser(self): - self.user.is_superuser = True + def test_superuser(self): + self.user.is_staff = True + self.user.staffsession_set.create(date_start=now(), session_key=self.client.session.session_key) self.user.save() self.team.members.clear() resp = self.client.get('/control/search/orders/').rendered_content diff --git a/src/tests/control/test_updatecheck.py b/src/tests/control/test_updatecheck.py index a78badfc2..c8f2e158d 100644 --- a/src/tests/control/test_updatecheck.py +++ b/src/tests/control/test_updatecheck.py @@ -34,7 +34,7 @@ def test_update_notice_displayed(client, user): r = client.get('/control/') assert 'pretix automatically checks for updates in the background' not in r.content.decode() - user.is_superuser = True + user.is_staff = True user.save() r = client.get('/control/') assert 'pretix automatically checks for updates in the background' in r.content.decode() @@ -46,7 +46,7 @@ def test_update_notice_displayed(client, user): @pytest.mark.django_db def test_settings(client, user): - user.is_superuser = True + user.is_staff = True user.save() client.login(email='dummy@dummy.dummy', password='dummy') @@ -71,7 +71,7 @@ def test_trigger(client, user): content_type='application/json', ) - user.is_superuser = True + user.is_staff = True user.save() client.login(email='dummy@dummy.dummy', password='dummy') diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index 3583ba3e6..f4151c7b0 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -65,6 +65,7 @@ def logged_in_client(client, event): ) t.members.add(user) client.force_login(user) + user.staffsession_set.create(date_start=now(), session_key=client.session.session_key) return client