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

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

View File

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

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,
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

View File

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

View File

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

View File

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

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.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

View File

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

View File

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

View File

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

View File

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

View File

@@ -151,6 +151,22 @@
</li>
{% 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 %}
<li>
<a href="{% url 'control:global.update' %}" class="danger">
@@ -191,7 +207,7 @@
{% trans "Dashboard" %}
</a>
</li>
{% if request.user.is_superuser %}
{% if staff_session %}
<li>
<a href="{% url 'control:global.settings' %}"
{% if "global.settings" in url_name %}class="active"{% endif %}>
@@ -219,14 +235,21 @@
{% trans "Order search" %}
</a>
</li>
{% if request.user.is_superuser %}
{% if staff_session %}
<li>
<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>
{% trans "Users" %}
</a>
</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 %}
{% for nav in nav_global %}
<li>
@@ -260,6 +283,21 @@
</div>
</div>
</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 %}
<div class="impersonate-warning">
<span class="fa fa-user-secret"></span>

View File

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

View File

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

View File

@@ -23,7 +23,7 @@
<div class="col-sm-4">
{% if plugin.app.compatibility_errors %}
<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>
{% elif plugin.module in plugins_active %}
<button class="btn btn-default btn-block" name="plugin:{{ plugin.module }}" value="disable">{% trans "Disable" %}</button>
@@ -44,7 +44,7 @@
{% endblocktrans %}</p>
{% endif %}
<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">
{% trans "This plugin needs to be enabled by a system administrator for your event." %}
</div>

View File

@@ -29,7 +29,7 @@
<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." %}
</div>
{% if request.user.is_superuser %}
{% if staff_session %}
<a href="{% url "control:organizers.add" %}" class="btn btn-default">
{% trans "Create a new organizer" %}
</a>

View File

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

View File

@@ -20,7 +20,7 @@
</button>
</div>
</form>
{% if request.user.is_superuser %}
{% if staff_session %}
<p>
<a href="{% url "control:organizers.add" %}" class="btn btn-default">
<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.locale layout='control' %}
{% bootstrap_field form.timezone layout='control' %}
{% bootstrap_field form.is_superuser layout='control' %}
{% bootstrap_field form.is_staff layout='control' %}
</fieldset>
<fieldset>
<legend>{% trans "Log-in settings" %}</legend>

View File

@@ -56,7 +56,7 @@
</strong></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_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">
<a href="{% url "control:users.edit" id=u.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
</td>

View File

@@ -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<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/select2$', typeahead.users_select2, name='users.select2'),
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):
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,

View File

@@ -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':

View File

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

View File

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

View File

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

View File

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

View File

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

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.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)

View File

@@ -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={

View File

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

View File

@@ -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 []

View File

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

View File

@@ -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 [
{

View File

@@ -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 [
{

View File

@@ -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 [
{

View File

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

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_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',

View File

@@ -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;

View File

@@ -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}

View File

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

View File

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

View File

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

View File

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

View File

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