Clean up logic around exporters

This commit is contained in:
Raphael Michel
2026-03-09 14:57:31 +01:00
parent 5ea79ca277
commit 36f63b1f7b
13 changed files with 689 additions and 45 deletions
+30 -4
View File
@@ -27,7 +27,9 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.forms import form_field_to_serializer_field
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport
from pretix.base.models import (
Event, ScheduledEventExport, ScheduledOrganizerExport,
)
from pretix.base.timeframes import SerializerDateFrameField
@@ -54,19 +56,28 @@ class ExporterSerializer(serializers.Serializer):
class JobRunSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
ex = kwargs.pop('exporter')
ex = self.ex = kwargs.pop('exporter')
super().__init__(*args, **kwargs)
if ex.is_multievent and not isinstance(ex, OrganizerLevelExportMixin):
self.fields["all_events"] = serializers.BooleanField(
required=False,
)
self.fields["events"] = serializers.SlugRelatedField(
queryset=ex.events,
required=False,
allow_empty=False,
allow_empty=True,
slug_field='slug',
many=True
)
for k, v in ex.export_form_fields.items():
self.fields[k] = form_field_to_serializer_field(v)
def to_representation(self, instance):
# Translate between events as a list of slugs (API) and list of ints (database)
if self.ex.is_multievent and not isinstance(self.ex, OrganizerLevelExportMixin) and "events" in instance and isinstance(instance["events"], list):
instance["events"] = [e.slug for e in self.ex.events.filter(pk__in=instance["events"]).only("slug")]
return instance
def to_internal_value(self, data):
if isinstance(data, QueryDict):
data = data.copy()
@@ -94,6 +105,14 @@ class JobRunSerializer(serializers.Serializer):
data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}'
data = super().to_internal_value(data)
# Translate between events as a list of slugs (API) and list of ints (database)
if self.ex.is_multievent and not isinstance(self.ex, OrganizerLevelExportMixin) and "events" in data and isinstance(data["events"], list):
if data["events"] and isinstance(data["events"][0], Event):
data["events"] = [e.pk for e in data["events"]]
elif data["events"] and isinstance(data["events"][0], str):
data["events"] = [e.pk for e in self.ex.events.filter(slug__in=data["events"]).only("pk")]
return data
def is_valid(self, raise_exception=False):
@@ -130,13 +149,20 @@ class ScheduledExportSerializer(serializers.ModelSerializer):
exporter = self.context['exporters'].get(identifier)
if exporter:
try:
JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
except ValidationError as e:
raise ValidationError({"export_form_data": e.detail})
else:
raise ValidationError({"export_identifier": ["Unknown exporter."]})
return attrs
def to_representation(self, instance):
repr = super().to_representation(instance)
exporter = self.context['exporters'].get(instance.export_identifier)
if exporter:
repr["export_form_data"] = JobRunSerializer(exporter=exporter).to_representation(repr["export_form_data"])
return repr
def validate_mail_additional_recipients(self, value):
d = value.replace(' ', '')
if len(d.split(',')) > 25:
+52 -1
View File
@@ -38,10 +38,12 @@ from pretix.api.serializers.exporters import (
ExporterSerializer, JobRunSerializer, ScheduledEventExportSerializer,
ScheduledOrganizerExportSerializer,
)
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.models import (
CachedFile, Device, ScheduledEventExport, ScheduledOrganizerExport,
TeamAPIToken,
)
from pretix.base.models.organizer import TeamQuerySet
from pretix.base.services.export import (
export, init_event_exporters, init_organizer_exporters, multiexport,
)
@@ -203,7 +205,7 @@ class ScheduledExportersViewSet(viewsets.ModelViewSet):
class ScheduledEventExportViewSet(ScheduledExportersViewSet):
serializer_class = ScheduledEventExportSerializer
queryset = ScheduledEventExport.objects.none()
permission = 'event.orders:read'
permission = None
def get_queryset(self):
perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user
@@ -249,6 +251,18 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
return {e.identifier: e for e in exporters}
def perform_update(self, serializer):
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
# user B has settings permission (= they can see the export configuration), but not enough permission
# to run the export themselves. Without this check, user B could modify the export and add themselves
# as a recipient. Thereby, user B would gain access to data they can't have.
exporter = self.exporters.get(serializer.instance.export_identifier)
if not exporter:
raise PermissionDenied("No access to exporter.")
perm_holder = self.request.auth if isinstance(self.request.auth, (TeamAPIToken, Device)) else self.request.user
if not perm_holder.has_event_permission(self.request.organizer, self.request.event, exporter.get_required_event_permission()):
raise PermissionDenied("No permission to edit exports you could not run.")
serializer.save(event=self.request.event)
serializer.instance.compute_next_run()
serializer.instance.error_counter = 0
@@ -319,6 +333,43 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
return {e.identifier: e for e in exporters}
def perform_update(self, serializer):
if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner:
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
# user B has settings permission (= they can see the export configuration), but not enough permission
# to run the export themselves. Without this check, user B could modify the export and add themselves
# as a recipient. Thereby, user B would gain access to data they can't have.
exporter = self.exporters.get(serializer.instance.export_identifier)
if not exporter:
raise PermissionDenied("No access to exporter.")
perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken))
else self.request.user)
if isinstance(exporter, OrganizerLevelExportMixin):
if not perm_holder.has_organizer_permission(
self.request.organizer, exporter.get_required_organizer_permission(), request=self.request,
):
raise PermissionDenied("No permission to edit exports you could not run.")
else:
if serializer.instance.export_form_data.get("all_events", False):
if isinstance(self.request.auth, Device):
if not self.request.auth.all_events:
raise PermissionDenied("No permission to edit exports you could not run.")
elif isinstance(self.request.auth, TeamAPIToken):
if not self.request.auth.team.all_events:
raise PermissionDenied("No permission to edit exports you could not run.")
elif self.request.user.is_authenticated:
if not self.request.user.teams.filter(
TeamQuerySet.event_permission_q(exporter.get_required_event_permission()),
all_events=True,
).exists():
raise PermissionDenied("No permission to edit exports you could not run.")
else:
events_selected = serializer.instance.export_form_data.get("events", [])
events_permission = set(perm_holder.get_events_with_permission(
exporter.get_required_event_permission(), request=self.request
).values_list("pk", flat=True))
if not all(e in events_permission for e in events_selected):
raise PermissionDenied("No permission to edit exports you could not run.")
serializer.save(organizer=self.request.organizer)
serializer.instance.compute_next_run()
serializer.instance.error_counter = 0
+3 -3
View File
@@ -271,7 +271,7 @@ class OrderListExporter(MultiSheetListExporter):
qs = self._date_filter(qs, form_data, rel='')
if form_data['paid_only']:
if form_data.get('paid_only'):
qs = qs.filter(status=Order.STATUS_PAID)
return qs
@@ -457,7 +457,7 @@ class OrderListExporter(MultiSheetListExporter):
).annotate(
payment_providers=Subquery(p_providers, output_field=CharField()),
).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule')
if form_data['paid_only']:
if form_data.get('paid_only'):
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
@@ -560,7 +560,7 @@ class OrderListExporter(MultiSheetListExporter):
qs = OrderPosition.all.filter(
order__event__in=self.events,
)
if form_data['paid_only']:
if form_data.get('paid_only'):
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
+1 -1
View File
@@ -134,7 +134,7 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
event_qs = organizer.events.all()
if form_data.get('events') is not None and not form_data.get('all_events'):
if isinstance(form_data['events'][0], str):
if form_data['events'] and isinstance(form_data['events'][0], str): # legacy API-created schedules
event_qs = event_qs.filter(slug__in=form_data.get('events'))
else:
event_qs = event_qs.filter(pk__in=form_data.get('events'))
@@ -34,7 +34,7 @@
{% if s.export_verbose_name == "?" %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Exporter not found" %}
{% trans "Exporter not found or no permission" %}
</strong>
{% elif s.error_counter >= 5 %}
<strong class="text-danger">
@@ -39,16 +39,18 @@
</fieldset>
{% if schedule_form %}
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
</button>
</div>
{% if not no_save %}
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
</button>
</div>
{% endif %}
{% else %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
@@ -34,7 +34,7 @@
{% if s.export_verbose_name == "?" %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Exporter not found" %}
{% trans "Exporter not found or no permission" %}
</strong>
{% elif s.error_counter >= 5 %}
<strong class="text-danger">
@@ -40,16 +40,18 @@
</fieldset>
{% if schedule_form %}
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
</button>
</div>
{% if not no_save %}
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
</button>
</div>
{% endif %}
{% else %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
+45 -3
View File
@@ -2791,7 +2791,16 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
@transaction.atomic()
def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save":
if not self.has_permission():
if self.scheduled and self.scheduled.pk and not self.has_permission_to_edit_scheduled():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot change it."
)
)
return super().get(request, *args, **kwargs)
elif (not self.scheduled or not self.scheduled.pk) and not self.has_permission_to_create_scheduled():
messages.error(
self.request,
_(
@@ -2879,8 +2888,32 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
def get_queryset(self):
return self.get_scheduled_queryset()
def has_permission(self):
return self.request.user.has_event_permission(self.request.organizer, self.request.event, "event.orders:read")
def has_permission_to_edit_scheduled(self):
# Exports can be edited by
# - their owner
# - any staff session user
# - any user with permission for organizer settings *and* the permissions required to run the rport
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
# user B has settings permission (= they can see the export configuration), but not enough permission
# to run the export themselves. Without this check, user B could modify the export and add themselves
# as a recipient. Thereby, user B would gain access to data they can't have.
if not self.exporter:
return False
if self.scheduled.owner == self.request.user:
return True
if self.request.user.has_active_staff_session(self.request.session.session_key):
return True
if not self.exporter.available_for_user(self.request.user):
return False
if self.request.user.has_event_permission(self.request.organizer, self.request.event,
"event.settings.general:write", request=self.request):
return self.request.user.has_event_permission(self.request.organizer, self.request.event,
self.exporter.get_required_event_permission())
def has_permission_to_create_scheduled(self):
# Exports can only be created if the user has the correct permissions. We *ignore* staff sessions, because
# the export is not *run* during a staff session and then would fail at the scheduled time.
return self.request.user.has_event_permission(self.request.organizer, self.request.event, self.exporter.get_required_event_permission())
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
@@ -2889,6 +2922,15 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
ctx['scheduled_copy_from'] = self.scheduled_copy_from
if self.scheduled and self.scheduled.pk and not self.has_permission_to_edit_scheduled() and self.exporter:
ctx['no_save'] = True
for f in self.exporter.form.fields.values():
f.disabled = True
for f in self.rrule_form.fields.values():
f.disabled = True
for f in self.schedule_form.fields.values():
f.disabled = True
elif not self.exporter:
for s in ctx['scheduled']:
try:
+63 -3
View File
@@ -2122,7 +2122,16 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
@transaction.atomic()
def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save":
if not self.has_permission():
if self.scheduled and self.scheduled.pk and not self.has_permission_to_edit_scheduled():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot change it."
)
)
return super().get(request, *args, **kwargs)
elif (not self.scheduled or not self.scheduled.pk) and not self.has_permission_to_create_scheduled():
messages.error(
self.request,
_(
@@ -2213,8 +2222,48 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
def get_queryset(self):
return self.get_scheduled_queryset()
def has_permission(self):
# Check if permission exists even without staff session
def has_permission_to_edit_scheduled(self):
# Exports can be edited by
# - their owner
# - any staff session user
# - any user with permission for organizer settings *and* the permissions required to run the report
# This is to prevent a possible privilege escalation where user A creates a scheduled export and
# user B has settings permission (= they can see the export configuration), but not enough permission
# to run the export themselves. Without this check, user B could modify the export and add themselves
# as a recipient. Thereby, user B would gain access to data they can't have.
if not self.exporter:
# Triggered in scenario 5 in test_organizer_edit_restrictions
return False
if self.scheduled.owner == self.request.user:
return True
if self.request.user.has_active_staff_session(self.request.session.session_key):
return True
if not self.exporter.available_for_user(self.request.user):
return False
if self.request.user.has_organizer_permission(self.request.organizer, "organizer.settings.general:write", request=self.request):
if isinstance(self.exporter, OrganizerLevelExportMixin):
# Test scenario 5/6 in test_organizer_edit_restrictions
return self.request.user.has_organizer_permission(
self.request.organizer, self.exporter.get_required_organizer_permission(), request=self.request
)
else:
if self.scheduled.export_form_data.get("all_events", False):
# Test scenario 1/2 in test_organizer_edit_restrictions
return self.request.user.teams.filter(
TeamQuerySet.event_permission_q(self.exporter.get_required_event_permission()),
all_events=True,
).exists()
else:
# Test scenario 3/4 in test_organizer_edit_restrictions
events_selected = self.scheduled.export_form_data.get("events", [])
events_permission = set(self.request.user.get_events_with_permission(
self.exporter.get_required_event_permission(), request=self.request,
).values_list("pk", flat=True))
return all(e in events_permission for e in events_selected)
def has_permission_to_create_scheduled(self):
# Exports can only be created if the user has the correct permissions. We *ignore* staff sessions, because
# the export is not *run* during a staff session and then would fail at the scheduled time.
if self.exporter:
if isinstance(self.exporter, OrganizerLevelExportMixin):
if not self.request.user.has_organizer_permission(self.request.organizer, self.exporter.get_required_organizer_permission()):
@@ -2233,6 +2282,17 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
ctx['scheduled_copy_from'] = self.scheduled_copy_from
if self.scheduled and self.scheduled.pk and not self.has_permission_to_edit_scheduled() and self.exporter:
ctx['no_save'] = True
for f in self.exporter.form.fields.values():
f.disabled = True
f.widget.attrs.pop("data-inverse-dependency", None)
for f in self.rrule_form.fields.values():
f.disabled = True
for f in self.schedule_form.fields.values():
f.disabled = True
elif not self.exporter:
for s in ctx['scheduled']:
try:
+237 -8
View File
@@ -39,8 +39,11 @@ from datetime import time
import pytest
from django.utils.timezone import now
from rest_framework.test import APIClient
from pretix.base.models import CachedFile, User
from pretix.base.models import (
CachedFile, Event, ScheduledEventExport, ScheduledOrganizerExport, User,
)
SAMPLE_EXPORTER_CONFIG = {
"identifier": "orderlist",
@@ -111,6 +114,10 @@ def test_org_list(token_client, organizer, event):
"name": "events",
"required": False
})
c['input_parameters'].insert(0, {
"name": "all_events",
"required": False
})
c['input_parameters'].remove({
"name": "items",
"required": False
@@ -144,13 +151,6 @@ def test_org_validate_events(token_client, organizer, team, event):
}, format='json')
assert resp.status_code == 202
resp = token_client.post('/api/v1/organizers/{}/exporters/orderlist/run/'.format(organizer.slug), data={
'_format': 'xlsx',
'events': []
}, format='json')
assert resp.status_code == 400
assert resp.data == {"events": ["This list may not be empty."]}
resp = token_client.post('/api/v1/organizers/{}/exporters/orderlist/run/'.format(organizer.slug), data={
'_format': 'xlsx',
'events': ["nonexisting"]
@@ -824,3 +824,232 @@ def test_org_scheduled_export_validate_rrule(user_client, organizer, user):
)
assert resp.status_code == 400
assert resp.data == {"schedule_rrule": ["BYEASTER not supported"]}
def _can_see_but_not_edit_org_export(client, scheduled):
response = client.get(
'/api/v1/organizers/{}/scheduled_exports/{}/'.format("dummy", scheduled.pk),
)
assert response.status_code == 200
response = client.patch(
'/api/v1/organizers/{}/scheduled_exports/{}/'.format("dummy", scheduled.pk),
data=response.data,
format='json',
)
return response.status_code == 200
@pytest.mark.django_db(transaction=True)
def test_organizer_edit_restrictions(client, event, organizer, user, team):
# This tests the prevention of a possible privilege escalation where user A creates a scheduled export and
# user B has settings permission (= they can see the export configuration), but not enough permission
# to run the export themselves. Without this check, user B could modify the export and add themselves
# as a recipient. Thereby, user B would gain access to data they can't have.
user1_client = APIClient()
user1_client.force_authenticate(user=user)
user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy")
user2_client = APIClient()
user2_client.force_authenticate(user=user2)
team1_client = APIClient()
t = team.tokens.create(name='Foo')
team1_client.credentials(HTTP_AUTHORIZATION='Token ' + t.token)
event1 = event
event2 = Event.objects.create(
organizer=organizer, name="Dummy", slug="dummy2",
date_from=now(), plugins="pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy"
)
team1 = team
team1.all_organizer_permissions = False
team1.all_event_permissions = False
team1.all_events = False
team1.limit_organizer_permissions = {"organizer.settings.general:write": True}
team1.limit_event_permissions = {"event.orders:read": True, "event.settings.general:write": True}
team1.save()
team1.limit_events.add(event1)
team1.members.add(user)
t = team.tokens.create(name='Foo')
client.credentials(HTTP_AUTHORIZATION='Token ' + t.token)
team2 = organizer.teams.create(
all_organizer_permissions=False, all_event_permissions=False, all_events=False,
limit_event_permissions={"event.orders:read": True},
limit_organizer_permissions={"organizer.giftcards:read": True}
)
team2.limit_events.add(event2)
team2.members.add(user2)
# Scenario 1
# User 2 created an export for all events. User 2 can edit it, because they own it.
# User 1 can see it, because they have permission to see scheduled exports, but can't change it, because they
# don't have access to all events.
s1 = ScheduledOrganizerExport.objects.create(
organizer=organizer,
owner=user2,
export_identifier="dummy_orders",
export_form_data={"all_events": True, "events": []},
mail_subject="Test",
mail_template="Test",
locale="en",
schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO",
schedule_rrule_time=time(2, 30, 0)
)
user._teamcache = {}
user2._teamcache = {}
assert _can_see_but_not_edit_org_export(user2_client, s1)
assert not _can_see_but_not_edit_org_export(user1_client, s1)
assert not _can_see_but_not_edit_org_export(team1_client, s1)
# Scenario 2
# User 2 created an export for all events. User 2 can edit it, because they own it.
# User 1 can see it, because they have permission to see scheduled exports, and change it, because they
# have access to all events.
team1.all_events = True
team1.save()
user._teamcache = {}
user2._teamcache = {}
assert _can_see_but_not_edit_org_export(user2_client, s1)
assert _can_see_but_not_edit_org_export(user1_client, s1)
assert _can_see_but_not_edit_org_export(team1_client, s1)
# Scenario 3
# User 2 created an export for a specific event. User 2 can edit it, because they own it.
# User 1 can see it, because they have permission to see scheduled exports, but can't change it, because they
# don't have access to that event.
team1.all_events = False
team1.save()
s1.export_form_data = {"all_events": False, "events": [event2.pk]}
s1.save()
user._teamcache = {}
user2._teamcache = {}
assert _can_see_but_not_edit_org_export(user2_client, s1)
assert not _can_see_but_not_edit_org_export(user1_client, s1)
assert not _can_see_but_not_edit_org_export(team1_client, s1)
# Scenario 4
# User 2 created an export for a specific event. User 2 can edit it, because they own it.
# User 1 can see it, because they have permission to see scheduled exports, and change it, because they
# have access to that event.
team1.limit_events.add(event2)
user._teamcache = {}
user2._teamcache = {}
assert _can_see_but_not_edit_org_export(user2_client, s1)
assert _can_see_but_not_edit_org_export(user1_client, s1)
assert _can_see_but_not_edit_org_export(team1_client, s1)
# Scenario 5
# User 2 created an export that requires a special permission on organizer level
# user 1 can see it, because they have permission to see scheduled exports, but can't change it, because they lack
# that special permission
s2 = ScheduledOrganizerExport.objects.create(
organizer=organizer,
owner=user2,
export_identifier="giftcardlist",
mail_subject="Test",
mail_template="Test",
locale="en",
schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO",
schedule_rrule_time=time(2, 30, 0)
)
user._teamcache = {}
user2._teamcache = {}
assert _can_see_but_not_edit_org_export(user2_client, s2)
assert not _can_see_but_not_edit_org_export(user1_client, s2)
assert not _can_see_but_not_edit_org_export(team1_client, s2)
# Scenario 6
# User 2 created an export that requires a special permission on organizer level
# user 1 can see it, because they have permission to see scheduled exports, and change it, because they have
# that special permission
team1.limit_organizer_permissions["organizer.giftcards:read"] = True
team1.save()
user._teamcache = {}
assert _can_see_but_not_edit_org_export(user2_client, s2)
assert _can_see_but_not_edit_org_export(team1_client, s2)
assert _can_see_but_not_edit_org_export(user1_client, s2)
def _can_see_but_not_edit_event_export(client, scheduled):
response = client.get(
'/api/v1/organizers/{}/events/{}/scheduled_exports/{}/'.format("dummy", "dummy", scheduled.pk),
)
assert response.status_code == 200
response = client.patch(
'/api/v1/organizers/{}/events/{}/scheduled_exports/{}/'.format("dummy", "dummy", scheduled.pk),
data=response.data,
format='json',
)
return response.status_code == 200
@pytest.mark.django_db(transaction=True)
def test_event_edit_restrictions(client, event, organizer, user, team):
# This tests the prevention of a possible privilege escalation where user A creates a scheduled export and
# user B has settings permission (= they can see the export configuration), but not enough permission
# to run the export themselves. Without this check, user B could modify the export and add themselves
# as a recipient. Thereby, user B would gain access to data they can't have.
user1_client = APIClient()
user1_client.force_authenticate(user=user)
user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy")
user2_client = APIClient()
user2_client.force_authenticate(user=user2)
team1_client = APIClient()
t = team.tokens.create(name='Foo')
team1_client.credentials(HTTP_AUTHORIZATION='Token ' + t.token)
event1 = event
team1 = team
team1.all_organizer_permissions = False
team1.all_event_permissions = False
team1.all_events = False
team1.limit_organizer_permissions = {"organizer.settings.general:write": True}
team1.limit_event_permissions = {"event.orders:read": True, "event.settings.general:write": True}
team1.save()
team1.limit_events.add(event1)
team1.members.add(user)
t = team.tokens.create(name='Foo')
client.credentials(HTTP_AUTHORIZATION='Token ' + t.token)
team2 = organizer.teams.create(
all_organizer_permissions=False, all_event_permissions=False, all_events=False,
limit_event_permissions={"event.orders:read": True, "event.vouchers:read": True},
limit_organizer_permissions={"organizer.giftcards:read": True}
)
team2.limit_events.add(event1)
team2.members.add(user2)
# User 2 created an export that requires a special permission on organizer level
# user 1 can see it, because they have permission to see scheduled exports, but can't change it, because they lack
# that special permission
s2 = ScheduledEventExport.objects.create(
event=event,
owner=user2,
export_identifier="dummy_vouchers",
mail_subject="Test",
mail_template="Test",
locale="en",
schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO",
schedule_rrule_time=time(2, 30, 0)
)
user._teamcache = {}
user2._teamcache = {}
assert _can_see_but_not_edit_event_export(user2_client, s2)
assert not _can_see_but_not_edit_event_export(user1_client, s2)
assert not _can_see_but_not_edit_event_export(team1_client, s2)
# Scenario 6
# User 2 created an export that requires a special permission on organizer level
# user 1 can see it, because they have permission to see scheduled exports, and change it, because they have
# that special permission
team1.limit_event_permissions["event.vouchers:read"] = True
team1.save()
user._teamcache = {}
assert _can_see_but_not_edit_event_export(user2_client, s2)
assert _can_see_but_not_edit_event_export(team1_client, s2)
assert _can_see_but_not_edit_event_export(user1_client, s2)
+196
View File
@@ -23,7 +23,9 @@ import datetime
import json
import pytest
from bs4 import BeautifulSoup
from django.utils.timezone import now
from tests.base import extract_form_fields
from pretix.base.models import (
Event, Item, Organizer, ScheduledEventExport, ScheduledOrganizerExport,
@@ -378,3 +380,197 @@ def test_organizer_limited_permission(client, env):
assert response.status_code == 200
response = client.post(f"/control/organizer/dummy/export/{s2.pk}/run")
assert response.status_code == 302
def _can_see_but_not_edit_org_export(client, user, scheduled):
client.login(email=user.email, password="dummy")
response = client.get("/control/organizer/dummy/export/")
assert f"export/{scheduled.pk}/delete".encode() in response.content
response = client.get(f"/control/organizer/dummy/export/?identifier={scheduled.export_identifier}&scheduled={scheduled.pk}")
if response.status_code == 404:
return False
assert response.status_code == 200
doc = BeautifulSoup(response.content, "lxml")
form_data = extract_form_fields(doc.select("form[data-asynctask]")[0])
form_data["schedule"] = "save"
response = client.post(f"/control/organizer/dummy/export/?identifier={scheduled.export_identifier}&scheduled={scheduled.pk}",
data=form_data, follow=True)
assert response.status_code == 200
return b"alert-success" in response.content and b"does not have sufficient permission" not in response.content
@pytest.mark.django_db(transaction=True)
def test_organizer_edit_restrictions(client, env):
# This tests the prevention of a possible privilege escalation where user A creates a scheduled export and
# user B has settings permission (= they can see the export configuration), but not enough permission
# to run the export themselves. Without this check, user B could modify the export and add themselves
# as a recipient. Thereby, user B would gain access to data they can't have.
user1 = env[1]
user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy")
event1 = env[0]
event2 = Event.objects.create(
organizer=env[0].organizer, name="Dummy", slug="dummy2",
date_from=now(), plugins="pretix.plugins.banktransfer,pretix.plugins.stripe,tests.testdummy"
)
team1 = env[2]
team1.all_organizer_permissions = False
team1.all_event_permissions = False
team1.all_events = False
team1.limit_organizer_permissions = {"organizer.settings.general:write": True}
team1.limit_event_permissions = {"event.orders:read": True, "event.settings.general:write": True}
team1.save()
team1.limit_events.add(event1)
team2 = env[0].organizer.teams.create(
all_organizer_permissions=False, all_event_permissions=False, all_events=False,
limit_event_permissions={"event.orders:read": True},
limit_organizer_permissions={"organizer.giftcards:read": True}
)
team2.limit_events.add(event2)
team2.members.add(user2)
# Scenario 1
# User 2 created an export for all events. User 2 can edit it, because they own it.
# User 1 can see it, because they have permission to see scheduled exports, but can't change it, because they
# don't have access to all events.
s1 = ScheduledOrganizerExport.objects.create(
organizer=env[0].organizer,
owner=user2,
export_identifier="dummy_orders",
export_form_data={"all_events": True, "events": []},
mail_subject="Test",
mail_template="Test",
schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO",
schedule_rrule_time=datetime.time(2, 30, 0)
)
assert _can_see_but_not_edit_org_export(client, user2, s1)
assert not _can_see_but_not_edit_org_export(client, user1, s1)
# Scenario 2
# User 2 created an export for all events. User 2 can edit it, because they own it.
# User 1 can see it, because they have permission to see scheduled exports, and change it, because they
# have access to all events.
team1.all_events = True
team1.save()
assert _can_see_but_not_edit_org_export(client, user2, s1)
assert _can_see_but_not_edit_org_export(client, user1, s1)
# Scenario 3
# User 2 created an export for a specific event. User 2 can edit it, because they own it.
# User 1 can see it, because they have permission to see scheduled exports, but can't change it, because they
# don't have access to that event.
team1.all_events = False
team1.save()
s1.export_form_data = {"all_events": False, "events": [event2.pk]}
s1.save()
assert _can_see_but_not_edit_org_export(client, user2, s1)
assert not _can_see_but_not_edit_org_export(client, user1, s1)
# Scenario 4
# User 2 created an export for a specific event. User 2 can edit it, because they own it.
# User 1 can see it, because they have permission to see scheduled exports, and change it, because they
# have access to that event.
team1.limit_events.add(event2)
assert _can_see_but_not_edit_org_export(client, user2, s1)
assert _can_see_but_not_edit_org_export(client, user1, s1)
# Scenario 5
# User 2 created an export that requires a special permission on organizer level
# user 1 can see it, because they have permission to see scheduled exports, but can't change it, because they lack
# that special permission
s2 = ScheduledOrganizerExport.objects.create(
organizer=env[0].organizer,
owner=user2,
export_identifier="giftcardlist",
mail_subject="Test",
mail_template="Test",
schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO",
schedule_rrule_time=datetime.time(2, 30, 0)
)
assert _can_see_but_not_edit_org_export(client, user2, s2)
assert not _can_see_but_not_edit_org_export(client, user1, s2)
# Scenario 6
# User 2 created an export that requires a special permission on organizer level
# user 1 can see it, because they have permission to see scheduled exports, and change it, because they have
# that special permission
team1.limit_organizer_permissions["organizer.giftcards:read"] = True
team1.save()
assert _can_see_but_not_edit_org_export(client, user2, s2)
assert _can_see_but_not_edit_org_export(client, user1, s2)
def _can_see_but_not_edit_event_export(client, user, scheduled):
client.login(email=user.email, password="dummy")
response = client.get("/control/event/dummy/dummy/orders/export/")
assert f"export/{scheduled.pk}/delete".encode() in response.content
response = client.get(f"/control/event/dummy/dummy/orders/export/?identifier={scheduled.export_identifier}&scheduled={scheduled.pk}")
if response.status_code == 404:
return False
assert response.status_code == 200
doc = BeautifulSoup(response.content, "lxml")
form_data = extract_form_fields(doc.select("form[data-asynctask]")[0])
form_data["schedule"] = "save"
response = client.post(f"/control/event/dummy/dummy/orders/export/?identifier={scheduled.export_identifier}&scheduled={scheduled.pk}",
data=form_data, follow=True)
assert response.status_code == 200
return b"alert-success" in response.content and b"does not have sufficient permission" not in response.content
@pytest.mark.django_db(transaction=True)
def test_event_edit_restrictions(client, env):
# This tests the prevention of a possible privilege escalation where user A creates a scheduled export and
# user B has settings permission (= they can see the export configuration), but not enough permission
# to run the export themselves. Without this check, user B could modify the export and add themselves
# as a recipient. Thereby, user B would gain access to data they can't have.
user1 = env[1]
user2 = User.objects.create_user("dummy2@dummy.dummy", "dummy")
event1 = env[0]
team1 = env[2]
team1.all_organizer_permissions = False
team1.all_event_permissions = False
team1.all_events = False
team1.limit_organizer_permissions = {"organizer.settings.general:write": True}
team1.limit_event_permissions = {"event.orders:read": True, "event.settings.general:write": True}
team1.save()
team1.limit_events.add(event1)
team2 = env[0].organizer.teams.create(
all_organizer_permissions=False, all_event_permissions=False, all_events=False,
limit_event_permissions={"event.orders:read": True, "event.vouchers:read": True},
limit_organizer_permissions={"organizer.giftcards:read": True}
)
team2.limit_events.add(event1)
team2.members.add(user2)
s2 = ScheduledEventExport.objects.create(
event=event1,
owner=user2,
export_identifier="dummy_vouchers",
mail_subject="Test",
mail_template="Test",
schedule_rrule="DTSTART:20230118T000000\nRRULE:FREQ=DAILY;INTERVAL=1;WKST=MO",
schedule_rrule_time=datetime.time(2, 30, 0)
)
assert _can_see_but_not_edit_event_export(client, user2, s2)
assert not _can_see_but_not_edit_event_export(client, user1, s2)
# Scenario 6
# User 2 created an export that requires a special permission on organizer level
# user 1 can see it, because they have permission to see scheduled exports, and change it, because they have
# that special permission
team1.limit_event_permissions["event.vouchers:read"] = True
team1.save()
assert _can_see_but_not_edit_event_export(client, user2, s2)
assert _can_see_but_not_edit_event_export(client, user1, s2)
+36
View File
@@ -22,11 +22,13 @@
from django.dispatch import receiver
from pretix.base.channels import SalesChannelType
from pretix.base.exporter import BaseExporter
from pretix.base.invoicing.transmission import (
TransmissionProvider, transmission_providers,
)
from pretix.base.models import Invoice
from pretix.base.signals import (
register_data_exporters, register_multievent_data_exporters,
register_payment_providers, register_sales_channel_types,
register_ticket_outputs,
)
@@ -48,6 +50,40 @@ def register_payment_provider(sender, **kwargs):
return [DummyPaymentProvider, DummyFullRefundablePaymentProvider, DummyPartialRefundablePaymentProvider]
class DummyOrdersExporter(BaseExporter):
verbose_name = "Dummy orders"
identifier = "dummy_orders"
class DummyVoucherExporter(BaseExporter):
verbose_name = "Dummy orders"
identifier = "dummy_vouchers"
@classmethod
def get_required_event_permission(cls) -> str:
return "event.vouchers:read"
@receiver(register_data_exporters, dispatch_uid="dummy_exporter_o")
def register_data_exporters_recv_o(sender, **kwargs):
return DummyOrdersExporter
@receiver(register_data_exporters, dispatch_uid="dummy_exporter_v")
def register_data_exporters_recv_v(sender, **kwargs):
return DummyVoucherExporter
@receiver(register_multievent_data_exporters, dispatch_uid="dummy_exporter_multi_o")
def register_multievent_data_exporters_recv_o(sender, **kwargs):
return DummyOrdersExporter
@receiver(register_multievent_data_exporters, dispatch_uid="dummy_exporter_multi_v")
def register_multievent_data_exporters_recv(sender, **kwargs):
return DummyVoucherExporter
class FoobazSalesChannel(SalesChannelType):
identifier = "baz"
verbose_name = "Foobar"