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
This commit is contained in:
Raphael Michel
2018-03-28 14:16:58 +02:00
committed by GitHub
parent 558c920181
commit a284e0c2f7
56 changed files with 965 additions and 130 deletions

View File

@@ -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 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``. 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 Locale settings
--------------- ---------------

View File

@@ -16,4 +16,5 @@ Contents:
settings settings
background background
email email
permissions
logging logging

View File

@@ -31,6 +31,9 @@ Organizers and events
.. autoclass:: pretix.base.models.Team .. autoclass:: pretix.base.models.Team
:members: :members:
.. autoclass:: pretix.base.models.TeamAPIToken
:members:
.. autoclass:: pretix.base.models.RequiredAction .. autoclass:: pretix.base.models.RequiredAction
:members: :members:

View File

@@ -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 <user-teams>`_
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()
<QuerySet: …>
Return all users that are in a team with a specific permission for this event::
>>> event.get_users_with_permission('can_change_event_settings')
<QuerySet: …>
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)
<QuerySet: …>
>>> user.get_events_with_any_permission(request=request)
<QuerySet: …>
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.

View File

@@ -1,6 +1,7 @@
addon addon
addons addons
api api
auditability
auth auth
autobuild autobuild
backend backend

View File

@@ -1,3 +1,5 @@
.. _user-teams:
Teams Teams
===== =====

View File

@@ -12,7 +12,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self): def get_queryset(self):
if self.request.user.is_authenticated(): 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() return Organizer.objects.all()
else: else:
return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True)) return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True))

View File

@@ -12,7 +12,7 @@ class PretixBaseConfig(AppConfig):
from . import exporters # NOQA from . import exporters # NOQA
from . import invoice # NOQA from . import invoice # NOQA
from . import notifications # 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: try:
from .celery_app import app as celery_app # NOQA from .celery_app import app as celery_app # NOQA

View File

@@ -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,
),
]

View File

@@ -19,7 +19,9 @@ from .orders import (
cachedcombinedticket_name, cachedticket_name, generate_position_secret, cachedcombinedticket_name, cachedticket_name, generate_position_secret,
generate_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 .tax import TaxRule
from .vouchers import Voucher from .vouchers import Voucher
from .waitinglist import WaitingListEntry from .waitinglist import WaitingListEntry

View File

@@ -1,4 +1,4 @@
from typing import Union from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import ( 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 import models
from django.db.models import Q from django.db.models import Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_otp.models import Device from django_otp.models import Device
@@ -36,7 +37,6 @@ class UserManager(BaseUserManager):
raise Exception("You must provide a password") raise Exception("You must provide a password")
user = self.model(email=email) user = self.model(email=email)
user.is_staff = True user.is_staff = True
user.is_superuser = True
user.set_password(password) user.set_password(password)
user.save() user.save()
return user return user
@@ -46,6 +46,11 @@ def generate_notifications_token():
return get_random_string(length=32) return get_random_string(length=32)
class SuperuserPermissionSet:
def __contains__(self, item):
return True
class User(AbstractBaseUser, PermissionsMixin, LoggingMixin): class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
""" """
This is the user model used by pretix for authentication. This is the user model used by pretix for authentication.
@@ -114,6 +119,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
def __str__(self): def __str__(self):
return self.email return self.email
@property
def is_superuser(self):
return False
def get_short_name(self) -> str: def get_short_name(self) -> str:
""" """
Returns the first of the following user properties that is found to exist: 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)] return self._teamcache['e{}'.format(event.pk)]
class SuperuserPermissionSet: def get_event_permission_set(self, organizer, event) -> set:
def __contains__(self, item):
return True
def get_event_permission_set(self, organizer, event) -> Union[set, SuperuserPermissionSet]:
""" """
Gets a set of permissions (as strings) that a user holds for a particular event Gets a set of permissions (as strings) that a user holds for a particular event
:param organizer: The organizer of the event :param organizer: The organizer of the event
:param event: The event to check :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 :return: set
a in b always returns true).
""" """
if self.is_superuser:
return self.SuperuserPermissionSet()
teams = self._get_teams_for_event(organizer, event) 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 Gets a set of permissions (as strings) that a user holds for a particular organizer
:param organizer: The organizer of the event :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 :return: set
a in b always returns true).
""" """
if self.is_superuser:
return self.SuperuserPermissionSet()
teams = self._get_teams_for_organizer(organizer) 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`` Checks if this user is part of any team that grants access of type ``perm_name``
to the event ``event``. to the event ``event``.
@@ -235,9 +240,10 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
:param organizer: The organizer of the event :param organizer: The organizer of the event
:param event: The event to check :param event: The event to check
:param perm_name: The permission, e.g. ``can_change_teams`` :param perm_name: The permission, e.g. ``can_change_teams``
:param request: The current request (optional). Required to detect staff sessions properly.
:return: bool :return: bool
""" """
if self.is_superuser: if request and self.has_active_staff_session(request.session.session_key):
return True return True
teams = self._get_teams_for_event(organizer, event) teams = self._get_teams_for_event(organizer, event)
if teams: if teams:
@@ -246,16 +252,17 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return True return True
return False 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`` Checks if this user is part of any team that grants access of type ``perm_name``
to the organizer ``organizer``. to the organizer ``organizer``.
:param organizer: The organizer to check :param organizer: The organizer to check
:param perm_name: The permission, e.g. ``can_change_teams`` :param perm_name: The permission, e.g. ``can_change_teams``
:param request: The current request (optional). Required to detect staff sessions properly.
:return: bool :return: bool
""" """
if self.is_superuser: if request and self.has_active_staff_session(request.session.session_key):
return True return True
teams = self._get_teams_for_organizer(organizer) teams = self._get_teams_for_organizer(organizer)
if teams: if teams:
@@ -263,15 +270,16 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return True return True
return False 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. 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 :return: Iterable of Events
""" """
from .event import Event 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.all()
return Event.objects.filter( 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)) | 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. 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 :return: Iterable of Events
""" """
from .event import Event 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.all()
kwargs = {permission: True} 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)) | 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): class U2FDevice(Device):
json_data = models.TextField() json_data = models.TextField()

View File

@@ -554,9 +554,7 @@ class Event(EventMixin, LoggedModel):
Q(all_events=True) | Q(limit_events__pk=self.pk) Q(all_events=True) | Q(limit_events__pk=self.pk)
) )
return User.objects.annotate(twp=Exists(team_with_perm)).filter( return User.objects.annotate(twp=Exists(team_with_perm)).filter(twp=True)
Q(is_superuser=True) | Q(twp=True)
)
def allow_delete(self): def allow_delete(self):
return not self.orders.exists() and not self.invoices.exists() return not self.orders.exists() and not self.invoices.exists()

View File

@@ -264,7 +264,7 @@ class TeamAPIToken(models.Model):
""" """
return self.team.permission_set() if self.team.organizer == organizer else set() 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`` Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``. to the event ``event``.
@@ -272,6 +272,7 @@ class TeamAPIToken(models.Model):
:param organizer: The organizer of the event :param organizer: The organizer of the event
:param event: The event to check :param event: The event to check
:param perm_name: The permission, e.g. ``can_change_teams`` :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: bool
""" """
has_event_access = (self.team.all_events and organizer == self.team.organizer) or ( 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)) 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`` Checks if this token is part of a team that grants access of type ``perm_name``
to the organizer ``organizer``. to the organizer ``organizer``.
:param organizer: The organizer to check :param organizer: The organizer to check
:param perm_name: The permission, e.g. ``can_change_teams`` :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: bool
""" """
return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name)) return organizer == self.team.organizer and (not perm_name or self.team.has_permission(perm_name))

View File

@@ -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()
)

View File

@@ -3,8 +3,10 @@ from importlib import import_module
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import Resolver404, get_script_prefix, resolve from django.core.urlresolvers import Resolver404, get_script_prefix, resolve
from django.db.models import Q
from django.utils.translation import get_language from django.utils.translation import get_language
from pretix.base.models.auth import StaffSession
from pretix.base.settings import GlobalSettingsObject from pretix.base.settings import GlobalSettingsObject
from ..helpers.i18n import get_javascript_format, get_moment_locale from ..helpers.i18n import get_javascript_format, get_moment_locale
@@ -93,10 +95,19 @@ def contextprocessor(request):
ctx['warning_update_check_active'] = False ctx['warning_update_check_active'] = False
gs = GlobalSettingsObject() gs = GlobalSettingsObject()
ctx['global_settings'] = gs.settings ctx['global_settings'] = gs.settings
if request.user.is_superuser: if request.user.is_staff:
if gs.settings.update_check_result_warning: if gs.settings.update_check_result_warning:
ctx['warning_update_available'] = True ctx['warning_update_available'] = True
if not gs.settings.update_check_ack and 'runserver' not in sys.argv: if not gs.settings.update_check_ack and 'runserver' not in sys.argv:
ctx['warning_update_check_active'] = True 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 return ctx

View File

@@ -234,7 +234,7 @@ class OrderSearchFilterForm(OrderFilterForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
request = kwargs.pop('request') request = kwargs.pop('request')
super().__init__(*args, **kwargs) 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() self.fields['organizer'].queryset = Organizer.objects.all()
else: else:
self.fields['organizer'].queryset = Organizer.objects.filter( self.fields['organizer'].queryset = Organizer.objects.filter(
@@ -393,7 +393,7 @@ class EventFilterForm(FilterForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
request = kwargs.pop('request') request = kwargs.pop('request')
super().__init__(*args, **kwargs) 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() self.fields['organizer'].queryset = Organizer.objects.all()
else: else:
self.fields['organizer'].queryset = Organizer.objects.filter( self.fields['organizer'].queryset = Organizer.objects.filter(
@@ -583,9 +583,9 @@ class UserFilterForm(FilterForm):
qs = qs.filter(is_active=False) qs = qs.filter(is_active=False)
if fdata.get('superuser') == 'yes': if fdata.get('superuser') == 'yes':
qs = qs.filter(is_superuser=True) qs = qs.filter(is_staff=True)
elif fdata.get('superuser') == 'no': elif fdata.get('superuser') == 'no':
qs = qs.filter(is_superuser=False) qs = qs.filter(is_staff=False)
if fdata.get('query'): if fdata.get('query'):
qs = qs.filter( qs = qs.filter(

View File

@@ -8,6 +8,13 @@ from django.utils.translation import ugettext_lazy as _
from pytz import common_timezones from pytz import common_timezones
from pretix.base.models import User 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): class UserEditForm(forms.ModelForm):
@@ -41,7 +48,7 @@ class UserEditForm(forms.ModelForm):
'email', 'email',
'require_2fa', 'require_2fa',
'is_active', 'is_active',
'is_superuser' 'is_staff'
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

@@ -4,12 +4,14 @@ from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, logout from django.contrib.auth import REDIRECT_FIELD_NAME, logout
from django.core.urlresolvers import get_script_prefix, resolve, reverse from django.core.urlresolvers import get_script_prefix, resolve, reverse
from django.http import Http404 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.deprecation import MiddlewareMixin
from django.utils.encoding import force_str from django.utils.encoding import force_str
from django.utils.translation import ugettext as _ 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 import Event, Organizer
from pretix.base.models.auth import SuperuserPermissionSet, User
from pretix.helpers.security import ( from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid, SessionInvalid, SessionReauthRequired, assert_session_valid,
) )
@@ -81,16 +83,52 @@ class PermissionMiddleware(MiddlewareMixin):
slug=url.kwargs['event'], slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'], organizer__slug=url.kwargs['organizer'],
).select_related('organizer').first() ).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 " raise Http404(_("The selected event was not found or you "
"have no permission to administrate it.")) "have no permission to administrate it."))
request.organizer = request.event.organizer 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: elif 'organizer' in url.kwargs:
request.organizer = Organizer.objects.filter( request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'], slug=url.kwargs['organizer'],
).first() ).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 " raise Http404(_("The selected organizer was not found or you "
"have no permission to administrate it.")) "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

View File

@@ -1,4 +1,7 @@
from django.core.exceptions import PermissionDenied 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 _ from django.utils.translation import ugettext as _
@@ -18,8 +21,7 @@ def event_permission_required(permission):
raise PermissionDenied() raise PermissionDenied()
allowed = ( allowed = (
request.user.is_superuser request.user.has_event_permission(request.organizer, request.event, permission, request=request)
or request.user.has_event_permission(request.organizer, request.event, permission)
) )
if allowed: if allowed:
return function(request, *args, **kw) return function(request, *args, **kw)
@@ -57,10 +59,7 @@ def organizer_permission_required(permission):
# just a double check, should not ever happen # just a double check, should not ever happen
raise PermissionDenied() raise PermissionDenied()
allowed = ( allowed = request.user.has_organizer_permission(request.organizer, permission, request=request)
request.user.is_superuser
or request.user.has_organizer_permission(request.organizer, permission)
)
if allowed: if allowed:
return function(request, *args, **kw) return function(request, *args, **kw)
@@ -85,14 +84,33 @@ class OrganizerPermissionRequiredMixin:
def administrator_permission_required(): def administrator_permission_required():
""" """
This view decorator rejects all requests with a 403 response which are not from 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 decorator(function):
def wrapper(request, *args, **kw): def wrapper(request, *args, **kw):
if not request.user.is_authenticated: # NOQA if not request.user.is_authenticated: # NOQA
# just a double check, should not ever happen # just a double check, should not ever happen
raise PermissionDenied() 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.')) raise PermissionDenied(_('You do not have permission to view this content.'))
return function(request, *args, **kw) return function(request, *args, **kw)
return wrapper return wrapper
@@ -108,3 +126,14 @@ class AdministratorPermissionRequiredMixin:
def as_view(cls, **initkwargs): def as_view(cls, **initkwargs):
view = super(AdministratorPermissionRequiredMixin, cls).as_view(**initkwargs) view = super(AdministratorPermissionRequiredMixin, cls).as_view(**initkwargs)
return administrator_permission_required()(view) 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)

View File

@@ -151,6 +151,22 @@
</li> </li>
{% endfor %} {% endfor %}
{% if request.user.is_staff and not staff_session %}
<li>
<form action="{% url 'control:user.sudo' %}?next={{ request.path|urlencode }}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-link" id="button-sudo">
<i class="fa fa-id-card"></i> {% trans "Admin mode" %}
</button>
</form>
</li>
{% elif request.user.is_staff and staff_session %}
<li>
<a href="{% url 'control:user.sudo.stop' %}" class="danger">
<i class="fa fa-id-card"></i> {% trans "End admin session" %}
</a>
</li>
{% endif %}
{% if warning_update_available %} {% if warning_update_available %}
<li> <li>
<a href="{% url 'control:global.update' %}" class="danger"> <a href="{% url 'control:global.update' %}" class="danger">
@@ -191,7 +207,7 @@
{% trans "Dashboard" %} {% trans "Dashboard" %}
</a> </a>
</li> </li>
{% if request.user.is_superuser %} {% if staff_session %}
<li> <li>
<a href="{% url 'control:global.settings' %}" <a href="{% url 'control:global.settings' %}"
{% if "global.settings" in url_name %}class="active"{% endif %}> {% if "global.settings" in url_name %}class="active"{% endif %}>
@@ -219,14 +235,21 @@
{% trans "Order search" %} {% trans "Order search" %}
</a> </a>
</li> </li>
{% if request.user.is_superuser %} {% if staff_session %}
<li> <li>
<a href="{% url 'control:users' %}" <a href="{% url 'control:users' %}"
{% if "users" in url_name %}class="active"{% endif %}> {% if "users" in url_name %}class="active"{% endif %}>
<i class="fa fa-user fa-fw"></i> <i class="fa fa-user fa-fw"></i>
{% trans "Users" %} {% trans "Users" %}
</a> </a>
</li> </li>
<li>
<a href="{% url 'control:user.sudo.list' %}"
{% if "sudo" in url_name %}class="active"{% endif %}>
<i class="fa fa-id-card fa-fw"></i>
{% trans "Admin sessions" %}
</a>
</li>
{% endif %} {% endif %}
{% for nav in nav_global %} {% for nav in nav_global %}
<li> <li>
@@ -260,6 +283,21 @@
</div> </div>
</div> </div>
</nav> </nav>
{% if staff_need_to_explain %}
<div class="impersonate-warning">
<span class="fa fa-id-card"></span>
{% blocktrans trimmed %}
Please leave a short comment on what you did in the following admin sessions:
{% endblocktrans %}
<ul>
{% for s in staff_need_to_explain %}
<li>
<a href="{% url "control:user.sudo.edit" id=s.pk %}">#{{ s.pk }}</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if request|is_hijacked %} {% if request|is_hijacked %}
<div class="impersonate-warning"> <div class="impersonate-warning">
<span class="fa fa-user-secret"></span> <span class="fa fa-user-secret"></span>

View File

@@ -109,7 +109,7 @@
</div> </div>
<div class="col-lg-2 col-sm-6 col-xs-12"> <div class="col-lg-2 col-sm-6 col-xs-12">
{% if log.user %} {% if log.user %}
{% if log.user.is_superuser %} {% if log.user.is_staff %}
<span class="fa fa-id-card fa-danger fa-fw" <span class="fa fa-id-card fa-danger fa-fw"
data-toggle="tooltip" data-toggle="tooltip"
title="{% trans "This change was performed by a pretix administrator." %}"> title="{% trans "This change was performed by a pretix administrator." %}">

View File

@@ -34,7 +34,7 @@
</div> </div>
<div class="col-lg-2 col-sm-6 col-xs-12"> <div class="col-lg-2 col-sm-6 col-xs-12">
{% if log.user %} {% if log.user %}
{% if log.user.is_superuser %} {% if log.user.is_staff %}
<span class="fa fa-id-card fa-danger fa-fw" <span class="fa fa-id-card fa-danger fa-fw"
data-toggle="tooltip" data-toggle="tooltip"
title="{% trans "This change was performed by a pretix administrator." %}"> title="{% trans "This change was performed by a pretix administrator." %}">

View File

@@ -23,7 +23,7 @@
<div class="col-sm-4"> <div class="col-sm-4">
{% if plugin.app.compatibility_errors %} {% if plugin.app.compatibility_errors %}
<button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Incompatible" %}</button> <button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Incompatible" %}</button>
{% elif plugin.restricted and not request.user.is_superuser %} {% elif plugin.restricted and not request.user.is_staff %}
<button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Not available" %}</button> <button class="btn disabled btn-block btn-default" disabled="disabled">{% trans "Not available" %}</button>
{% elif plugin.module in plugins_active %} {% elif plugin.module in plugins_active %}
<button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}" value="disable">{% trans "Disable" %}</button> <button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}" value="disable">{% trans "Disable" %}</button>
@@ -44,7 +44,7 @@
{% endblocktrans %}</p> {% endblocktrans %}</p>
{% endif %} {% endif %}
<p>{{ plugin.description }}</p> <p>{{ plugin.description }}</p>
{% if plugin.restricted and not request.user.is_superuser %} {% if plugin.restricted and not request.user.is_staff %}
<div class="alert alert-warning"> <div class="alert alert-warning">
{% trans "This plugin needs to be enabled by a system administrator for your event." %} {% trans "This plugin needs to be enabled by a system administrator for your event." %}
</div> </div>

View File

@@ -29,7 +29,7 @@
<div class="alert alert-danger"> <div class="alert alert-danger">
{% trans "Every event needs to be created as part of an organizer account. Currently, you do not have access to any organizer accounts." %} {% trans "Every event needs to be created as part of an organizer account. Currently, you do not have access to any organizer accounts." %}
</div> </div>
{% if request.user.is_superuser %} {% if staff_session %}
<a href="{% url "control:organizers.add" %}" class="btn btn-default"> <a href="{% url "control:organizers.add" %}" class="btn btn-default">
{% trans "Create a new organizer" %} {% trans "Create a new organizer" %}
</a> </a>

View File

@@ -6,7 +6,7 @@
<p class="meta"> <p class="meta">
<span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }} <span class="fa fa-clock-o"></span> {{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if log.user %} {% if log.user %}
{% if log.user.is_superuser %} {% if log.user.is_staff %}
<span class="fa fa-id-card fa-danger fa-fw" <span class="fa fa-id-card fa-danger fa-fw"
data-toggle="tooltip" data-toggle="tooltip"
title="{% trans "This change was performed by a pretix administrator." %}"> title="{% trans "This change was performed by a pretix administrator." %}">

View File

@@ -20,7 +20,7 @@
</button> </button>
</div> </div>
</form> </form>
{% if request.user.is_superuser %} {% if staff_session %}
<p> <p>
<a href="{% url "control:organizers.add" %}" class="btn btn-default"> <a href="{% url "control:organizers.add" %}" class="btn btn-default">
<span class="fa fa-plus"></span> <span class="fa fa-plus"></span>

View File

@@ -0,0 +1,47 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Staff session" %}{% endblock %}
{% block content %}
<h1>{% trans "Session notes" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
{% bootstrap_field form.comment layout='horizontal' %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
<h1>{% trans "Audit log" %}</h1>
<dl class="dl-horizontal">
<dt>{% trans "Start date" %}</dt>
<dd>{{ session.date_start|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "End date" %}</dt>
<dd>{{ session.date_end|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "User" %}</dt>
<dd>{{ session.user.email }}</dd>
</dl>
<table class="table table-condensed">
<thead>
<tr>
<th>{% trans "Timestamp" %}</th>
<th>{% trans "Method" %}</th>
<th>{% trans "URL" %}</th>
<th>{% trans "On behalf of" %}</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.datetime|date:"SHORT_DATETIME_FORMAT" }}</td>
<td>{{ log.method }}</td>
<td>{{ log.url }}</td>
<td>{{ log.impersonating|default:"" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,58 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load urlreplace %}
{% block title %}{% trans "Admin sessions" %}{% endblock %}
{% block content %}
<h1>{% trans "Admin sessions" %}</h1>
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>
#
</th>
<th>
{% trans "User" %}
</th>
<th>
{% trans "Start date" %}
</th>
<th>{% trans "End date" %}</th>
<th>{% trans "Comment" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td><strong>
<a href="{% url "control:user.sudo.edit" id=s.pk %}">{{ s.pk }}</a>
</strong></td>
<td><strong>
<a href="{% url "control:users.edit" id=s.user.pk %}">{{ s.user.email }}</a>
</strong></td>
<td>
{{ s.date_start|date:"SHORT_DATETIME_FORMAT" }}
</td>
<td>
{% if s.date_end %}
{{ s.date_end|date:"SHORT_DATETIME_FORMAT" }}
{% endif %}
</td>
<td>
{% if s.comment %}
<span class="fa fa-check"></span>
{% else %}
<span class="fa fa-times text-danger"></span>
{% endif %}
</td>
<td class="text-right">
<a href="{% url "control:user.sudo.edit" id=s.id %}" class="btn btn-default btn-sm"><i
class="fa fa-edit"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -0,0 +1,22 @@
{% extends "pretixcontrol/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Admin mode" %}{% endblock %}
{% block content %}
<h1>{% trans "Admin mode" %}</h1>
<p>
{% 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 %}
</p>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Start session" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -25,7 +25,7 @@
{% bootstrap_field form.fullname layout='control' %} {% bootstrap_field form.fullname layout='control' %}
{% bootstrap_field form.locale layout='control' %} {% bootstrap_field form.locale layout='control' %}
{% bootstrap_field form.timezone layout='control' %} {% bootstrap_field form.timezone layout='control' %}
{% bootstrap_field form.is_superuser layout='control' %} {% bootstrap_field form.is_staff layout='control' %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Log-in settings" %}</legend> <legend>{% trans "Log-in settings" %}</legend>

View File

@@ -56,7 +56,7 @@
</strong></td> </strong></td>
<td>{{ u.fullname|default_if_none:"" }}</td> <td>{{ u.fullname|default_if_none:"" }}</td>
<td>{% if u.is_active %}<span class="fa fa-check-circle"></span>{% endif %}</td> <td>{% if u.is_active %}<span class="fa fa-check-circle"></span>{% endif %}</td>
<td>{% if u.is_superuser %}<span class="fa fa-check-circle"></span>{% endif %}</td> <td>{% if u.is_staff %}<span class="fa fa-check-circle"></span>{% endif %}</td>
<td class="text-right"> <td class="text-right">
<a href="{% url "control:users.edit" id=u.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> <a href="{% url "control:users.edit" id=u.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
</td> </td>

View File

@@ -19,6 +19,10 @@ urlpatterns = [
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='global.update'), 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'^global/message/$', global_settings.MessageView.as_view(), name='global.message'),
url(r'^reauth/$', user.ReauthView.as_view(), name='user.reauth'), 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<id>\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/$', users.UserListView.as_view(), name='users'),
url(r'^users/select2$', typeahead.users_select2, name='users.select2'), url(r'^users/select2$', typeahead.users_select2, name='users.select2'),
url(r'^users/add$', users.UserCreateView.as_view(), name='users.add'), url(r'^users/add$', users.UserCreateView.as_view(), name='users.add'),

View File

@@ -248,12 +248,13 @@ def event_index(request, organizer, event):
for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent): for r, result in event_dashboard_widgets.send(sender=request.event, subevent=subevent):
widgets.extend(result) 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 = request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST) 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)) 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)) qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher))
a_qs = request.event.requiredaction_set.filter(done=False) 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) return render(request, 'pretixcontrol/event/index.html', ctx)
def annotated_event_query(user): def annotated_event_query(request):
active_orders = Order.objects.filter( active_orders = Order.objects.filter(
event=OuterRef('pk'), event=OuterRef('pk'),
status__in=[Order.STATUS_PENDING, Order.STATUS_PAID] status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
@@ -285,7 +286,7 @@ def annotated_event_query(user):
event=OuterRef('pk'), event=OuterRef('pk'),
done=False 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()), order_count=Subquery(active_orders, output_field=IntegerField()),
has_ra=Exists(required_actions) has_ra=Exists(required_actions)
).annotate( ).annotate(
@@ -299,7 +300,7 @@ def annotated_event_query(user):
return qs return qs
def widgets_for_event_qs(qs, user, nmax): def widgets_for_event_qs(request, qs, user, nmax):
widgets = [] widgets = []
# Get set of events where we have the permission to show the # of orders # 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( orders_text=ungettext('{num} order', '{num} orders', event.order_count or 0).format(
num=event.order_count or 0 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, daterange=dr,
status=status[1], status=status[1],
@@ -402,7 +403,8 @@ def user_index(request):
ctx = { ctx = {
'widgets': rearrange(widgets), 'widgets': rearrange(widgets),
'upcoming': widgets_for_event_qs( 'upcoming': widgets_for_event_qs(
annotated_event_query(request.user).filter( request,
annotated_event_query(request).filter(
Q(has_subevents=False) & Q(has_subevents=False) &
Q( Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now())) Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
@@ -413,7 +415,8 @@ def user_index(request):
7 7
), ),
'past': widgets_for_event_qs( 'past': widgets_for_event_qs(
annotated_event_query(request.user).filter( request,
annotated_event_query(request).filter(
Q(has_subevents=False) & Q(has_subevents=False) &
Q( Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now())) Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
@@ -424,7 +427,8 @@ def user_index(request):
8 8
), ),
'series': widgets_for_event_qs( 'series': widgets_for_event_qs(
annotated_event_query(request.user).filter( request,
annotated_event_query(request).filter(
has_subevents=True has_subevents=True
).order_by('-order_to'), ).order_by('-order_to'),
request.user, request.user,

View File

@@ -212,7 +212,7 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat
module = key.split(":")[1] module = key.split(":")[1]
if value == "enable" and module in plugins_available: if value == "enable" and module in plugins_available:
if getattr(plugins_available[module], 'restricted', False): 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 continue
if hasattr(plugins_available[module].app, 'installed'): if hasattr(plugins_available[module].app, 'installed'):
@@ -854,9 +854,11 @@ class EventLog(EventPermissionRequiredMixin, ListView):
def get_queryset(self): def get_queryset(self):
qs = self.request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime') qs = self.request.event.logentry_set.all().select_related('user', 'content_type').order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BLACKLIST) 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)) 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)) qs = qs.exclude(content_type=ContentType.objects.get_for_model(Voucher))
if self.request.GET.get('user') == 'yes': if self.request.GET.get('user') == 'yes':

View File

@@ -8,7 +8,9 @@ from pretix.base.settings import GlobalSettingsObject
from pretix.control.forms.global_settings import ( from pretix.control.forms.global_settings import (
GlobalSettingsForm, UpdateSettingsForm, GlobalSettingsForm, UpdateSettingsForm,
) )
from pretix.control.permissions import AdministratorPermissionRequiredMixin from pretix.control.permissions import (
AdministratorPermissionRequiredMixin, StaffMemberRequiredMixin,
)
class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView): class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView):
@@ -28,7 +30,7 @@ class GlobalSettingsView(AdministratorPermissionRequiredMixin, FormView):
return reverse('control:global.settings') return reverse('control:global.settings')
class UpdateCheckView(AdministratorPermissionRequiredMixin, FormView): class UpdateCheckView(StaffMemberRequiredMixin, FormView):
template_name = 'pretixcontrol/global_update.html' template_name = 'pretixcontrol/global_update.html'
form_class = UpdateSettingsForm form_class = UpdateSettingsForm

View File

@@ -31,7 +31,7 @@ class EventList(PaginationMixin, ListView):
template_name = 'pretixcontrol/events/index.html' template_name = 'pretixcontrol/events/index.html'
def get_queryset(self): 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' '_settings_objects', 'organizer___settings_objects'
).order_by('-date_from') ).order_by('-date_from')

View File

@@ -38,7 +38,7 @@ class OrganizerList(PaginationMixin, ListView):
qs = Organizer.objects.all() qs = Organizer.objects.all()
if self.filter_form.is_valid(): if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs) 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 return qs
else: else:
return qs.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True)) 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): def get_form_kwargs(self):
kwargs = super().get_form_kwargs() 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 kwargs['domain'] = True
return kwargs return kwargs
@@ -271,7 +271,7 @@ class OrganizerCreate(CreateView):
context_object_name = 'organizer' context_object_name = 'organizer'
def dispatch(self, request, *args, **kwargs): 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 raise PermissionDenied() # TODO
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)

View File

@@ -24,7 +24,7 @@ class OrderSearch(PaginationMixin, ListView):
def get_queryset(self): def get_queryset(self):
qs = Order.objects.select_related('invoice_address') 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( qs = qs.filter(
Q(event__organizer_id__in=self.request.user.teams.filter( Q(event__organizer_id__in=self.request.user.teams.filter(
all_events=True, can_view_orders=True).values_list('organizer', flat=True)) all_events=True, can_view_orders=True).values_list('organizer', flat=True))

View File

@@ -18,7 +18,7 @@ def event_list(request):
page = int(request.GET.get('page', '1')) page = int(request.GET.get('page', '1'))
except ValueError: except ValueError:
page = 1 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(name__icontains=i18ncomp(query)) | Q(slug__icontains=query) |
Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query) Q(organizer__name__icontains=i18ncomp(query)) | Q(organizer__slug__icontains=query)
).annotate( ).annotate(
@@ -107,7 +107,7 @@ def organizer_select2(request):
qs = Organizer.objects.all() qs = Organizer.objects.all()
if term: if term:
qs = qs.filter(Q(name__icontains=term) | Q(slug__icontains=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)) qs = qs.filter(pk__in=request.user.teams.values_list('organizer', flat=True))
total = qs.count() total = qs.count()
@@ -130,7 +130,7 @@ def organizer_select2(request):
def users_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() raise PermissionDenied()
term = request.GET.get('query', '') term = request.GET.get('query', '')

View File

@@ -12,8 +12,10 @@ from django.shortcuts import get_object_or_404, redirect
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.http import is_safe_url from django.utils.http import is_safe_url
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ 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_static.models import StaticDevice
from django_otp.plugins.otp_totp.models import TOTPDevice from django_otp.plugins.otp_totp.models import TOTPDevice
from u2flib_server import u2f 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.forms.user import User2FADeviceAddForm, UserSettingsForm
from pretix.base.models import Event, NotificationSetting, U2FDevice, User 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.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 from pretix.control.views.auth import get_u2f_appid
REAL_DEVICE_TYPES = (TOTPDevice, U2FDevice) REAL_DEVICE_TYPES = (TOTPDevice, U2FDevice)
@@ -472,3 +479,67 @@ class UserNotificationsEditView(TemplateView):
if self.event: if self.event:
ctx['permset'] = self.request.user.get_event_permission_set(self.event.organizer, self.event) ctx['permset'] = self.request.user.get_event_permission_set(self.event.organizer, self.event)
return ctx 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)

View File

@@ -112,7 +112,9 @@ class UserImpersonateView(AdministratorPermissionRequiredMixin, RecentAuthentica
'other': self.kwargs.get("id"), 'other': self.kwargs.get("id"),
'other_email': self.object.email 'other_email': self.object.email
}) })
oldkey = request.session.session_key
login_user(request, self.object) login_user(request, self.object)
request.session['hijacker_session'] = oldkey
return redirect(reverse('control:index')) return redirect(reverse('control:index'))
@@ -120,7 +122,14 @@ class UserImpersonateStopView(LoginRequiredMixin, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
impersonated = request.user impersonated = request.user
hijs = request.session['hijacker_session']
release_hijack(request) 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', request.user.log_action('pretix.control.auth.user.impersonate_stopped',
user=request.user, user=request.user,
data={ data={

View File

@@ -48,7 +48,8 @@ class WaitingListView(EventPermissionRequiredMixin, PaginationMixin, ListView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if 'assign' in request.POST: 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')) messages.error(request, _('You do not have permission to do this'))
return redirect(reverse('control:event.orders.waitinglist', kwargs={ return redirect(reverse('control:event.orders.waitinglist', kwargs={
'event': request.event.slug, 'event': request.event.slug,

View File

@@ -35,7 +35,7 @@ def control_nav_import(sender, request=None, **kwargs):
@receiver(nav_organizer, dispatch_uid="payment_banktransfer_organav") @receiver(nav_organizer, dispatch_uid="payment_banktransfer_organav")
def control_nav_orga_import(sender, request=None, **kwargs): def control_nav_orga_import(sender, request=None, **kwargs):
url = resolve(request.path_info) 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 [] return []
if not request.organizer.events.filter(plugins__icontains='pretix.plugins.banktransfer'): if not request.organizer.events.filter(plugins__icontains='pretix.plugins.banktransfer'):
return [] return []

View File

@@ -488,7 +488,7 @@ class OrganizerActionView(OrganizerBanktransferView, OrganizerPermissionRequired
def order_qs(self): def order_qs(self):
all = self.request.user.teams.filter(organizer=self.request.organizer, can_change_orders=True, all = self.request.user.teams.filter(organizer=self.request.organizer, can_change_orders=True,
can_view_orders=True, all_events=True).exists() 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) return Order.objects.filter(event__organizer=self.request.organizer)
else: else:
return Order.objects.filter( return Order.objects.filter(

View File

@@ -15,7 +15,7 @@ from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid="pretixdroid_nav") @receiver(nav_event, dispatch_uid="pretixdroid_nav")
def control_nav_import(sender, request=None, **kwargs): def control_nav_import(sender, request=None, **kwargs):
url = resolve(request.path_info) 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 []
return [ return [
{ {

View File

@@ -9,7 +9,7 @@ from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid="sendmail_nav") @receiver(nav_event, dispatch_uid="sendmail_nav")
def control_nav_import(sender, request=None, **kwargs): def control_nav_import(sender, request=None, **kwargs):
url = resolve(request.path_info) 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 []
return [ return [
{ {

View File

@@ -9,7 +9,7 @@ from pretix.control.signals import nav_event
@receiver(nav_event, dispatch_uid="statistics_nav") @receiver(nav_event, dispatch_uid="statistics_nav")
def control_nav_import(sender, request=None, **kwargs): def control_nav_import(sender, request=None, **kwargs):
url = resolve(request.path_info) 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 []
return [ return [
{ {

View File

@@ -71,7 +71,7 @@ def _detect_event(request, require_live=True, require_plugin=None):
url.url_name == 'event.auth' url.url_name == 'event.auth'
or ( or (
request.user.is_authenticated 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)
) )
) )

View File

@@ -87,6 +87,7 @@ PRETIX_INSTANCE_NAME = config.get('pretix', 'instance_name', fallback='pretix.de
PRETIX_REGISTRATION = config.getboolean('pretix', 'registration', fallback=True) PRETIX_REGISTRATION = config.getboolean('pretix', 'registration', fallback=True)
PRETIX_PASSWORD_RESET = config.getboolean('pretix', 'password_reset', fallback=True) PRETIX_PASSWORD_RESET = config.getboolean('pretix', 'password_reset', fallback=True)
PRETIX_LONG_SESSIONS = config.getboolean('pretix', 'long_sessions', 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_RELATIVE = 3600 * 3
PRETIX_SESSION_TIMEOUT_ABSOLUTE = 3600 * 12 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) PLUGINS.append(entry_point.module_name)
INSTALLED_APPS.append(entry_point.module_name) INSTALLED_APPS.append(entry_point.module_name)
HIJACK_AUTHORIZE_STAFF = True
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [ 'DEFAULT_PERMISSION_CLASSES': [
@@ -297,6 +300,7 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'pretix.control.middleware.PermissionMiddleware', 'pretix.control.middleware.PermissionMiddleware',
'pretix.control.middleware.AuditLogMiddleware',
'pretix.base.middleware.LocaleMiddleware', 'pretix.base.middleware.LocaleMiddleware',
'pretix.base.middleware.SecurityMiddleware', 'pretix.base.middleware.SecurityMiddleware',
'pretix.presale.middleware.EventMiddleware', 'pretix.presale.middleware.EventMiddleware',

View File

@@ -53,7 +53,7 @@ nav.navbar .danger, nav.navbar .danger:hover, nav.navbar .danger:active {
margin-bottom: 10px; margin-bottom: 10px;
} }
#button-shop { #button-shop, #button-sudo {
padding: 15px; padding: 15px;
min-height: 50px; min-height: 50px;
border: none; border: none;

View File

@@ -1,7 +1,9 @@
import pytest import pytest
from django.test import RequestFactory
from django.utils.timezone import now from django.utils.timezone import now
from pretix.base.models import Event, Organizer, Team, User from pretix.base.models import Event, Organizer, Team, User
from pretix.multidomain.middlewares import SessionMiddleware
@pytest.fixture @pytest.fixture
@@ -25,7 +27,19 @@ def user():
@pytest.fixture @pytest.fixture
def admin(): 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 @pytest.mark.django_db
@@ -193,23 +207,20 @@ def test_organizer_permissions_multiple_teams(event, user):
@pytest.mark.django_db @pytest.mark.django_db
def test_superuser(event, user): def test_superuser(event, admin, admin_request):
user.is_superuser = True assert admin.has_organizer_permission(event.organizer, request=admin_request)
user.save() 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 'arbitrary' not in admin.get_event_permission_set(event.organizer, event)
assert user.has_organizer_permission(event.organizer, 'can_create_events') assert 'arbitrary' not in admin.get_organizer_permission_set(event.organizer)
assert user.has_event_permission(event.organizer, event)
assert user.has_event_permission(event.organizer, event, 'can_change_event_settings')
assert 'arbitrary' in user.get_event_permission_set(event.organizer, event) assert event in admin.get_events_with_any_permission(request=admin_request)
assert 'arbitrary' in user.get_organizer_permission_set(event.organizer)
assert event in user.get_events_with_any_permission()
@pytest.mark.django_db @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') orga2 = Organizer.objects.create(slug='d2', name='d2')
event2 = Event.objects.create( event2 = Event.objects.create(
organizer=event.organizer, name='Dummy', slug='dummy2', organizer=event.organizer, name='Dummy', slug='dummy2',
@@ -236,25 +247,25 @@ def test_list_of_events(event, user, admin):
team2.limit_events.add(event) team2.limit_events.add(event)
team3.limit_events.add(event3) 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 event in events
assert event2 in events assert event2 in events
assert event3 in events assert event3 in events
assert event4 not 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 event not in events
assert event2 not in events assert event2 not in events
assert event3 in events assert event3 in events
assert event4 not in events assert event4 not in events
assert set(event.get_users_with_any_permission()) == {user, admin} assert set(event.get_users_with_any_permission()) == {user}
assert set(event2.get_users_with_any_permission()) == {user, admin} assert set(event2.get_users_with_any_permission()) == {user}
assert set(event3.get_users_with_any_permission()) == {user, admin} assert set(event3.get_users_with_any_permission()) == {user}
assert set(event4.get_users_with_any_permission()) == {admin} assert set(event4.get_users_with_any_permission()) == set()
assert set(event.get_users_with_permission('can_change_event_settings')) == {admin} assert set(event.get_users_with_permission('can_change_event_settings')) == set()
assert set(event2.get_users_with_permission('can_change_event_settings')) == {admin} assert set(event2.get_users_with_permission('can_change_event_settings')) == set()
assert set(event3.get_users_with_permission('can_change_event_settings')) == {user, admin} assert set(event3.get_users_with_permission('can_change_event_settings')) == {user}
assert set(event4.get_users_with_permission('can_change_event_settings')) == {admin} assert set(event4.get_users_with_permission('can_change_event_settings')) == set()
assert set(event.get_users_with_permission('can_change_orders')) == {admin, user} assert set(event.get_users_with_permission('can_change_orders')) == {user}

View File

@@ -8,6 +8,7 @@ from django.contrib.auth.tokens import (
) )
from django.core import mail as djmail from django.core import mail as djmail
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.utils.timezone import now
from django_otp.oath import TOTP from django_otp.oath import TOTP
from django_otp.plugins.otp_totp.models import TOTPDevice from django_otp.plugins.otp_totp.models import TOTPDevice
from u2flib_server.jsapi import JSONDict 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' self.client.defaults['HTTP_USER_AGENT'] = 'Mozilla/5.0 (X11; Linux x86_64) Something else'
response = self.client.get('/control/') response = self.client.get('/control/')
self.assertEqual(response.status_code, 302) 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

View File

@@ -26,13 +26,20 @@ def env():
superuser_urls = [ superuser_urls = [
"global/settings/", "global/settings/",
"global/update/",
"users/select2", "users/select2",
"users/", "users/",
"users/add", "users/add",
"users/1/", "users/1/",
"users/1/impersonate", "users/1/impersonate",
"users/1/reset", "users/1/reset",
"sudo/sessions/",
]
staff_urls = [
"global/update/",
"sudo/",
"sudo/2/",
] ]
event_urls = [ event_urls = [
@@ -146,10 +153,26 @@ def test_logged_out(client, env, url):
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.parametrize("url", superuser_urls) @pytest.mark.parametrize("url", superuser_urls)
def test_superuser_required(perf_patch, client, env, url): 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') client.login(email='dummy@dummy.dummy', password='dummy')
response = client.get('/control/' + url) response = client.get('/control/' + url)
assert response.status_code == 403 assert response.status_code == 403
env[1].is_superuser = True env[1].is_staff = True
env[1].save() env[1].save()
response = client.get('/control/' + url) response = client.get('/control/' + url)
assert response.status_code in (200, 302, 404) assert response.status_code in (200, 302, 404)

View File

@@ -99,8 +99,9 @@ class OrderSearchTest(SoupTest):
assert 'FO1' not in resp assert 'FO1' not in resp
assert 'FO2' not in resp assert 'FO2' not in resp
def test_suberuser(self): def test_superuser(self):
self.user.is_superuser = True self.user.is_staff = True
self.user.staffsession_set.create(date_start=now(), session_key=self.client.session.session_key)
self.user.save() self.user.save()
self.team.members.clear() self.team.members.clear()
resp = self.client.get('/control/search/orders/').rendered_content resp = self.client.get('/control/search/orders/').rendered_content

View File

@@ -34,7 +34,7 @@ def test_update_notice_displayed(client, user):
r = client.get('/control/') r = client.get('/control/')
assert 'pretix automatically checks for updates in the background' not in r.content.decode() assert 'pretix automatically checks for updates in the background' not in r.content.decode()
user.is_superuser = True user.is_staff = True
user.save() user.save()
r = client.get('/control/') r = client.get('/control/')
assert 'pretix automatically checks for updates in the background' in r.content.decode() 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 @pytest.mark.django_db
def test_settings(client, user): def test_settings(client, user):
user.is_superuser = True user.is_staff = True
user.save() user.save()
client.login(email='dummy@dummy.dummy', password='dummy') client.login(email='dummy@dummy.dummy', password='dummy')
@@ -71,7 +71,7 @@ def test_trigger(client, user):
content_type='application/json', content_type='application/json',
) )
user.is_superuser = True user.is_staff = True
user.save() user.save()
client.login(email='dummy@dummy.dummy', password='dummy') client.login(email='dummy@dummy.dummy', password='dummy')

View File

@@ -65,6 +65,7 @@ def logged_in_client(client, event):
) )
t.members.add(user) t.members.add(user)
client.force_login(user) client.force_login(user)
user.staffsession_set.create(date_start=now(), session_key=client.session.session_key)
return client return client