Pluggable permissions (#5728)

* Data model draft

* Refactor query and assignment usages of old permissions

* Backend UI

* API serializer

* Big string replace

* Docs, tests and fixes for teams api

* Update docs for device auth

* Eliminate old names

* Make tests pass

* Use new permissions, remove inconsistencies

* Add test for translations

* Show plugin permissions

* Add permission for seating plans

* Fix plugin activation

* Fix failing test

* Refactor to permission groups

* Update doc/api/resources/devices.rst

Co-authored-by: luelista <weller@rami.io>

* Update doc/api/resources/events.rst

Co-authored-by: luelista <weller@rami.io>

* Update src/pretix/api/serializers/organizer.py

Co-authored-by: luelista <weller@rami.io>

* Fix typo

* Fix python version compat

* Replacement after rebase

* Add proper permission handling for exports

* Docs for exporters

* Runtime linting of permission names

* Fix typos

* Show export page even without orders permission

* More legacy compat

* Do not strongly validate before plugins are loaded

* Rebase migration

* Add permission for outgoing mails

* Review notes

* Update doc/api/resources/teams.rst

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Clean up logic around exporters

* Review and failures

* Fix migration leading to forbidden combination

* Handle permissions on event copying

* Remove print-statements

* Make test clearer

* Review feedback

* Add AnyPermissionOf

* migration safety

---------

Co-authored-by: luelista <weller@rami.io>
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
This commit is contained in:
Raphael Michel
2026-03-17 14:43:56 +01:00
committed by GitHub
parent eddde2b6c0
commit df0b580dd6
203 changed files with 5374 additions and 2331 deletions

View File

@@ -300,7 +300,7 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
def ignored_meta_properties(self):
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
else self.context['request'].user)
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']):
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']):
return []
return [k for k, p in self.meta_properties.items() if p.protected]
@@ -445,7 +445,7 @@ class CloneEventSerializer(EventSerializer):
date_admission = validated_data.pop('date_admission', None)
new_event = super().create({**validated_data, 'plugins': None})
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
event = self.context['event']
new_event.copy_data_from(event, skip_meta_data='meta_data' in validated_data)
if plugins is not None:
@@ -561,7 +561,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
def ignored_meta_properties(self):
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
else self.context['request'].user)
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']):
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']):
return []
return [k for k, p in self.meta_properties.items() if p.protected]
@@ -707,7 +707,10 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
class EventSettingsSerializer(SettingsSerializer):
default_write_permission = 'event.settings.general:write'
default_fields = [
# These are readable for all users with access to the events, therefore secrets stored in the settings store
# should not be included!
'imprint_url',
'checkout_email_helptext',
'presale_has_ended_text',
@@ -1080,16 +1083,16 @@ class SeatSerializer(I18nAwareModelSerializer):
def prefetch_expanded_data(self, items, request, expand_fields):
if 'orderposition' in expand_fields:
if 'can_view_orders' not in request.eventpermset:
raise PermissionDenied('can_view_orders permission required for expand=orderposition')
if 'event.orders:read' not in request.eventpermset:
raise PermissionDenied('event.orders:read permission required for expand=orderposition')
prefetch_by_id(items, OrderPosition.objects.prefetch_related('order'), 'orderposition_id', 'orderposition')
if 'cartposition' in expand_fields:
if 'can_view_orders' not in request.eventpermset:
raise PermissionDenied('can_view_orders permission required for expand=cartposition')
if 'event.orders:read' not in request.eventpermset:
raise PermissionDenied('event.orders:read permission required for expand=cartposition')
prefetch_by_id(items, CartPosition.objects, 'cartposition_id', 'cartposition')
if 'voucher' in expand_fields:
if 'can_view_vouchers' not in request.eventpermset:
raise PermissionDenied('can_view_vouchers permission required for expand=voucher')
if 'event.vouchers:read' not in request.eventpermset:
raise PermissionDenied('event.vouchers:read permission required for expand=voucher')
prefetch_by_id(items, Voucher.objects, 'voucher_id', 'voucher')
def __init__(self, instance, *args, **kwargs):

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,20 +56,28 @@ class ExporterSerializer(serializers.Serializer):
class JobRunSerializer(serializers.Serializer):
def __init__(self, *args, **kwargs):
ex = kwargs.pop('exporter')
events = kwargs.pop('events', None)
ex = self.ex = kwargs.pop('exporter')
super().__init__(*args, **kwargs)
if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
self.fields["events"] = serializers.SlugRelatedField(
queryset=events,
if ex.is_multievent and not isinstance(ex, OrganizerLevelExportMixin):
self.fields["all_events"] = serializers.BooleanField(
required=False,
allow_empty=False,
)
self.fields["events"] = serializers.SlugRelatedField(
queryset=ex.events,
required=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()
@@ -95,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):
@@ -131,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:

View File

@@ -24,7 +24,7 @@ from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.exceptions import PermissionDenied, ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import OrderPositionSerializer
@@ -66,6 +66,9 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
super().__init__(*args, **kwargs)
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
if not self.context["can_read_giftcards"]:
raise PermissionDenied("No permission to access gift card details.")
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
@@ -77,6 +80,8 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
)
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
# No additional permission check performed, documented limitation of the permission system
# Would get to complex/unusable otherwise since the permission depends on the event
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
else:
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
@@ -86,6 +91,9 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
)
if 'customer' in self.context['request'].query_params.getlist('expand'):
if not self.context["can_read_customers"]:
raise PermissionDenied("No permission to access customer details.")
self.fields['customer'] = CustomerSerializer(read_only=True)
else:
self.fields['customer'] = serializers.SlugRelatedField(

View File

@@ -615,7 +615,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
# /events/…/checkinlists/…/positions/
# We're unable to check this on this level if we're on /checkinrpc/, in which case we rely on the view
# layer to not set pdf_data=true in the first place.
request and hasattr(request, 'eventpermset') and 'can_view_orders' not in request.eventpermset
request and hasattr(request, 'eventpermset') and 'event.orders:read' not in request.eventpermset
)
if ('pdf_data' in self.context and not self.context['pdf_data']) or pdf_data_forbidden:
self.fields.pop('pdf_data', None)

View File

@@ -45,12 +45,19 @@ from pretix.base.models import (
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
)
from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.permissions import (
get_all_event_permission_groups, get_all_organizer_permission_groups,
)
from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER,
)
from pretix.base.services.mail import mail
from pretix.base.settings import validate_organizer_settings
from pretix.helpers.permission_migration import (
OLD_TO_NEW_EVENT_COMPAT, OLD_TO_NEW_EVENT_MIGRATION,
OLD_TO_NEW_ORGANIZER_COMPAT, OLD_TO_NEW_ORGANIZER_MIGRATION,
)
from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -306,23 +313,128 @@ class EventSlugField(serializers.SlugRelatedField):
return self.context['organizer'].events.all()
class PermissionMultipleChoiceField(serializers.MultipleChoiceField):
def to_internal_value(self, data):
return {
p: True for p in super().to_internal_value(data)
}
def to_representation(self, value):
return [p for p, v in value.items() if v]
class TeamSerializer(serializers.ModelSerializer):
limit_events = EventSlugField(slug_field='slug', many=True)
limit_event_permissions = PermissionMultipleChoiceField(choices=[], required=False, allow_null=False, allow_empty=True)
limit_organizer_permissions = PermissionMultipleChoiceField(choices=[], required=False, allow_null=False, allow_empty=True)
# Legacy fields, handled in to_representation and validate
can_change_event_settings = serializers.BooleanField(required=False, write_only=True)
can_change_items = serializers.BooleanField(required=False, write_only=True)
can_view_orders = serializers.BooleanField(required=False, write_only=True)
can_change_orders = serializers.BooleanField(required=False, write_only=True)
can_checkin_orders = serializers.BooleanField(required=False, write_only=True)
can_view_vouchers = serializers.BooleanField(required=False, write_only=True)
can_change_vouchers = serializers.BooleanField(required=False, write_only=True)
can_create_events = serializers.BooleanField(required=False, write_only=True)
can_change_organizer_settings = serializers.BooleanField(required=False, write_only=True)
can_change_teams = serializers.BooleanField(required=False, write_only=True)
can_manage_gift_cards = serializers.BooleanField(required=False, write_only=True)
can_manage_customers = serializers.BooleanField(required=False, write_only=True)
can_manage_reusable_media = serializers.BooleanField(required=False, write_only=True)
class Meta:
model = Team
fields = (
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'all_event_permissions', 'limit_event_permissions',
'all_organizer_permissions', 'limit_organizer_permissions', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_checkin_orders', 'can_view_vouchers',
'can_change_vouchers', 'can_create_events', 'can_change_organizer_settings', 'can_change_teams',
'can_manage_gift_cards', 'can_manage_customers', 'can_manage_reusable_media'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
event_perms_flattened = []
organizer_perms_flattened = []
for pg in get_all_event_permission_groups().values():
for action in pg.actions:
event_perms_flattened.append(f"{pg.name}:{action}")
for pg in get_all_organizer_permission_groups().values():
for action in pg.actions:
organizer_perms_flattened.append(f"{pg.name}:{action}")
self.fields['limit_event_permissions'].choices = [(p, p) for p in event_perms_flattened]
self.fields['limit_organizer_permissions'].choices = [(p, p) for p in organizer_perms_flattened]
def to_representation(self, instance):
r = super().to_representation(instance)
for old, new in OLD_TO_NEW_EVENT_COMPAT.items():
r[old] = instance.all_event_permissions or all(instance.limit_event_permissions.get(n) for n in new)
for old, new in OLD_TO_NEW_ORGANIZER_COMPAT.items():
r[old] = instance.all_organizer_permissions or all(instance.limit_organizer_permissions.get(n) for n in new)
return r
def validate(self, data):
old_data_set = any(k.startswith("can_") for k in data)
new_data_set = any(k in data for k in [
"all_event_permissions", "limit_event_permissions", "all_organizer_permissions", "limit_organizer_permissions"
])
if old_data_set and new_data_set:
raise ValidationError("You cannot set deprecated and current permission attributes at the same time.")
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
if new_data_set:
if full_data.get('limit_event_permissions') and full_data.get('all_event_permissions'):
raise ValidationError('Do not set both limit_event_permissions and all_event_permissions.')
if full_data.get('limit_organizer_permissions') and full_data.get('all_organizer_permissions'):
raise ValidationError('Do not set both limit_organizer_permissions and all_organizer_permissions.')
if old_data_set:
# Migrate with same logic as in migration 0297_pluggable_permissions
if all(full_data.get(k) is True for k in OLD_TO_NEW_EVENT_MIGRATION.keys() if k != "can_checkin_orders"):
data["all_event_permissions"] = True
data["limit_event_permissions"] = {}
else:
data["all_event_permissions"] = False
data["limit_event_permissions"] = {}
for k, v in OLD_TO_NEW_EVENT_MIGRATION.items():
if full_data.get(k) is True:
data["limit_event_permissions"].update({kk: True for kk in v})
if all(full_data.get(k) is True for k in OLD_TO_NEW_ORGANIZER_MIGRATION.keys() if k != "can_checkin_orders"):
data["all_organizer_permissions"] = True
data["limit_organizer_permissions"] = {}
else:
data["all_organizer_permissions"] = False
data["limit_organizer_permissions"] = {}
for k, v in OLD_TO_NEW_ORGANIZER_MIGRATION.items():
if full_data.get(k) is True:
data["limit_organizer_permissions"].update({kk: True for kk in v})
if full_data.get('limit_events') and full_data.get('all_events'):
raise ValidationError('Do not set both limit_events and all_events.')
full_data.update(data)
for pg in get_all_event_permission_groups().values():
requested = ",".join(sorted(
a for a in pg.actions if self.instance and full_data["limit_event_permissions"].get(f"{pg.name}:{a}")
))
if requested not in (",".join(sorted(opt.actions)) for opt in pg.options):
possible = '\' or \''.join(','.join(opt.actions) for opt in pg.options)
raise ValidationError(f"For permission group {pg.name}, the valid combinations of actions are "
f"'{possible}' but you tried to set '{requested}'.")
for pg in get_all_organizer_permission_groups().values():
requested = ",".join(sorted(
a for a in pg.actions if self.instance and full_data["limit_organizer_permissions"].get(f"{pg.name}:{a}")
))
if requested not in (",".join(sorted(opt.actions)) for opt in pg.options):
possible = '\' or \''.join(','.join(opt.actions) for opt in pg.options)
raise ValidationError(f"For permission group {pg.name}, the valid combinations of actions are "
f"'{possible}' but you tried to set '{requested}'.")
return data
@@ -339,7 +451,7 @@ class DeviceSerializer(serializers.ModelSerializer):
created = serializers.DateTimeField(read_only=True)
revoked = serializers.BooleanField(read_only=True)
initialized = serializers.DateTimeField(read_only=True)
initialization_token = serializers.DateTimeField(read_only=True)
initialization_token = serializers.CharField(read_only=True)
security_profile = serializers.ChoiceField(choices=[], required=False, default="full")
class Meta:
@@ -353,6 +465,8 @@ class DeviceSerializer(serializers.ModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['security_profile'].choices = [(k, v.verbose_name) for k, v in get_all_security_profiles().items()]
if not self.context['can_see_tokens']:
del self.fields['initialization_token']
class TeamInviteSerializer(serializers.ModelSerializer):
@@ -437,7 +551,10 @@ class TeamMemberSerializer(serializers.ModelSerializer):
class OrganizerSettingsSerializer(SettingsSerializer):
default_write_permission = 'organizer.settings.general:write'
default_fields = [
# These are readable for all users with access to the events, therefore secrets stored in the settings store
# should not be included!
'customer_accounts',
'customer_accounts_native',
'customer_accounts_link_by_email',

View File

@@ -37,6 +37,8 @@ logger = logging.getLogger(__name__)
class SettingsSerializer(serializers.Serializer):
default_fields = []
readonly_fields = []
default_write_permission = 'organizer.settings.general:write'
write_permission_required = {}
def __init__(self, *args, **kwargs):
self.changed_data = []
@@ -58,9 +60,17 @@ class SettingsSerializer(serializers.Serializer):
f._label = str(form_kwargs.get('label', fname))
f._help_text = str(form_kwargs.get('help_text'))
f.parent = self
self.write_permission_required[fname] = DEFAULTS[fname].get('write_permission', self.default_write_permission)
self.fields[fname] = f
def validate(self, attrs):
for k in attrs.keys():
p = self.write_permission_required.get(k, self.default_write_permission)
if p not in self.context["permissions"]:
raise ValidationError({k: f"Setting this field requires permission {p}"})
return {k: v for k, v in attrs.items() if k not in self.readonly_fields}
def update(self, instance: HierarkeyProxy, validated_data):