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

@@ -62,6 +62,7 @@ from pretix.base.forms import (
)
from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
from pretix.base.models.organizer import TeamQuerySet
from pretix.base.models.tax import TAX_CODE_LISTS
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin
@@ -100,11 +101,12 @@ class EventWizardFoundationForm(forms.Form):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
self.session = kwargs.pop('session')
self.clone_from = kwargs.pop('clone_from')
super().__init__(*args, **kwargs)
qs = Organizer.objects.all()
if not self.user.has_active_staff_session(self.session.session_key):
qs = qs.filter(
id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True)
id__in=self.user.teams.filter(TeamQuerySet.organizer_permission_q("organizer.events:create")).values_list('organizer', flat=True)
)
self.fields['organizer'] = forms.ModelChoiceField(
label=_("Organizer"),
@@ -125,6 +127,16 @@ class EventWizardFoundationForm(forms.Form):
self.fields['organizer'].initial = organizer
self.fields['locales'].initial = organizer.settings.locales
def clean(self):
d = super().clean()
if d.get('organizer') and self.clone_from and not self.user.has_active_staff_session(self.session.session_key):
if not self.clone_from.allow_copy_data(d['organizer'], self.user):
raise ValidationError({
"organizer": _("You do not have a sufficient level of access on the event you selected "
"to copy it to the desired organizer.")
})
return d
class EventWizardBasicsForm(I18nModelForm):
error_messages = {
@@ -198,6 +210,7 @@ class EventWizardBasicsForm(I18nModelForm):
self.has_subevents = kwargs.pop('has_subevents')
self.user = kwargs.pop('user')
self.session = kwargs.pop('session')
self.clone_from = kwargs.pop('clone_from')
super().__init__(*args, **kwargs)
if 'timezone' not in self.initial:
self.initial['timezone'] = get_current_timezone_name()
@@ -238,6 +251,16 @@ class EventWizardBasicsForm(I18nModelForm):
'check "{field}" above.').format(field=self.fields["no_taxes"].label)
})
if self.clone_from and not self.user.has_active_staff_session(self.session.session_key):
if data.get("team"):
source_event_perms = self.user.get_event_permission_set(self.organizer, self.clone_from)
team_perms = data["team"].event_permission_set(include_legacy=False)
if any(t not in source_event_perms for t in team_perms):
raise ValidationError({
"team": _("You cannot choose a team that would give you more access than you have on "
"the event you are copying.")
})
# change timezone
zone = ZoneInfo(data.get('timezone'))
data['date_from'] = self.reset_timezone(zone, data.get('date_from'))
@@ -261,9 +284,12 @@ class EventWizardBasicsForm(I18nModelForm):
@staticmethod
def has_control_rights(user, organizer, session):
# It's mostly pointless to let a user create an event where they can't event change the name or create products,
# so we detect if the user has sufficient access for that on a new event.
return user.teams.filter(
organizer=organizer, all_events=True, can_change_event_settings=True, can_change_items=True,
can_change_orders=True, can_change_vouchers=True
TeamQuerySet.event_permission_q("event.settings.general:write"),
organizer=organizer,
all_events=True,
).exists() or user.has_active_staff_session(session.session_key)
@@ -293,18 +319,24 @@ class EventWizardCopyForm(forms.Form):
if user.has_active_staff_session(session.session_key):
return Event.objects.all()
return Event.objects.filter(
# It is generally pointless to let users copy events when they would not even be able to change the
# date of the event they have just created. Therefore, even if it looks wrong, we're checking a write
# permission for read access.
Q(organizer_id__in=user.teams.filter(
all_events=True, can_change_event_settings=True, can_change_items=True
TeamQuerySet.event_permission_q("event.settings.general:write"),
all_events=True,
).values_list('organizer', flat=True)) | Q(id__in=user.teams.filter(
can_change_event_settings=True, can_change_items=True
TeamQuerySet.event_permission_q("event.settings.general:write"),
).values_list('limit_events__id', flat=True))
)
def __init__(self, *args, **kwargs):
kwargs.pop('organizer')
self.organizer = kwargs.pop('organizer')
kwargs.pop('locales')
self.session = kwargs.pop('session')
self.team = kwargs.pop('team')
kwargs.pop('has_subevents')
kwargs.pop('clone_from')
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
@@ -323,6 +355,24 @@ class EventWizardCopyForm(forms.Form):
)
self.fields['copy_from_event'].widget.choices = self.fields['copy_from_event'].choices
def clean(self):
d = super().clean()
if d.get('copy_from_event') and not self.user.has_active_staff_session(self.session.session_key):
if not d['copy_from_event'].allow_copy_data(self.organizer, self.user):
raise ValidationError({
"copy_from_event": _("You do not have a sufficient level of access on the event you selected "
"to copy it to the desired organizer.")
})
if self.team:
source_event_perms = self.user.get_event_permission_set(self.organizer, d['copy_from_event'])
team_perms = self.team.event_permission_set(include_legacy=False)
if any(t not in source_event_perms for t in team_perms):
raise ValidationError({
"copy_from_event": _("You cannot choose an event on which you have less access than the "
"team you selected in the previous step.")
})
return d
class EventMetaValueForm(forms.ModelForm):

View File

@@ -1111,7 +1111,7 @@ class OrderPaymentSearchFilterForm(forms.Form):
self.fields['organizer'].queryset = Organizer.objects.filter(
pk__in=self.request.user.teams.values_list('organizer', flat=True)
)
self.fields['event'].queryset = self.request.user.get_events_with_permission('can_view_orders')
self.fields['event'].queryset = self.request.user.get_events_with_permission('event.orders:read')
self.fields['provider'].choices += get_all_payment_providers()

View File

@@ -75,7 +75,10 @@ from pretix.base.models import (
ReusableMedium, SalesChannel, Team,
)
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink
from pretix.base.models.organizer import OrganizerFooterLink, TeamQuerySet
from pretix.base.permissions import (
get_all_event_permission_groups, get_all_organizer_permission_groups,
)
from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_organizer_settings,
)
@@ -297,7 +300,34 @@ class MembershipTypeForm(I18nModelForm):
fields = ['name', 'transferable', 'allow_parallel_usage', 'max_usages']
class PermissionMultipleChoiceField(forms.MultipleChoiceField):
def to_python(self, value):
return {
k: True for k in super().to_python(value) if k
}
def prepare_value(self, value):
if isinstance(value, dict):
return [k for k, v in value.items() if v is True]
return super().prepare_value(value)
class TeamForm(forms.ModelForm):
def _make_label(self, p):
source = '{}'
params = [p.label]
if p.plugin_name:
source = '<span class="fa fa-puzzle-piece text-muted" data-toggle="tooltip" title="{}"></span> ' + source
params.insert(0, _("Provided by a plugin"))
if p.help_text:
source += ' <span class="fa fa-info-circle text-muted" data-toggle="tooltip" title="{}"></span>'
params.append(p.help_text)
source += ' (<code>{}</code>)'
params.append(p.name)
return format_html(source, *params)
def __init__(self, *args, **kwargs):
organizer = kwargs.pop('organizer')
@@ -305,16 +335,62 @@ class TeamForm(forms.ModelForm):
self.fields['limit_events'].queryset = organizer.events.all().order_by(
'-has_subevents', '-date_from'
)
self.event_field_names = []
for pg in get_all_event_permission_groups().values():
initial = ",".join(sorted(
a for a in pg.actions if self.instance and self.instance.limit_event_permissions.get(f"{pg.name}:{a}")
)) or "EMPTY"
self.fields[f'event_{pg.name}'] = forms.ChoiceField(
choices=[
(
",".join(sorted(opt.actions)) or "EMPTY",
format_html(
'{label} '
'<span class="fa fa-question-circle fa-fw text-muted" data-toggle="tooltip"'
' data-placement="right" title="{help_text}"></span>',
label=opt.label,
help_text=opt.help_text,
) if opt.help_text else opt.label,
)
for opt in pg.options
],
label=pg.label,
help_text=pg.help_text,
initial=initial,
widget=forms.RadioSelect,
)
self.event_field_names.append(f'event_{pg.name}')
self.organizer_field_names = []
for pg in get_all_organizer_permission_groups().values():
initial = ",".join(sorted(
a for a in pg.actions if self.instance and self.instance.limit_organizer_permissions.get(f"{pg.name}:{a}")
)) or "EMPTY"
self.fields[f'organizer_{pg.name}'] = forms.ChoiceField(
choices=[
(
",".join(sorted(opt.actions)) or "EMPTY",
format_html(
'{label} '
'<span class="fa fa-question-circle fa-fw text-muted" data-toggle="tooltip"'
' data-placement="right" title="{help_text}"></span>',
label=opt.label,
help_text=opt.help_text,
) if opt.help_text else opt.label,
)
for opt in pg.options
],
label=pg.label,
help_text=pg.help_text,
initial=initial,
widget=forms.RadioSelect,
)
self.organizer_field_names.append(f'organizer_{pg.name}')
class Meta:
model = Team
fields = ['name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards', 'can_manage_customers',
'can_manage_reusable_media',
'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
'can_view_vouchers', 'can_change_vouchers']
fields = ['name', 'require_2fa', 'all_events', 'limit_events',
'all_event_permissions',
'all_organizer_permissions',]
widgets = {
'limit_events': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_events',
@@ -327,15 +403,57 @@ class TeamForm(forms.ModelForm):
def clean(self):
data = super().clean()
if self.instance.pk and not data['can_change_teams']:
data['limit_event_permissions'] = {}
if not data['all_event_permissions']:
for pg in get_all_event_permission_groups().values():
selected = data.get(f'event_{pg.name}', 'EMPTY')
if selected == "EMPTY":
selected_actions = []
else:
selected_actions = selected.split(',')
for action in pg.actions:
if action in selected_actions:
data['limit_event_permissions'][f"{pg.name}:{action}"] = True
self.instance.limit_event_permissions = data['limit_event_permissions']
data['limit_organizer_permissions'] = {}
if not data['all_organizer_permissions']:
for pg in get_all_organizer_permission_groups().values():
selected = data.get(f'organizer_{pg.name}', 'EMPTY')
if selected == "EMPTY":
selected_actions = []
else:
selected_actions = selected.split(',')
for action in pg.actions:
if action in selected_actions:
data['limit_organizer_permissions'][f"{pg.name}:{action}"] = True
self.instance.limit_organizer_permissions = data['limit_organizer_permissions']
if self.instance.pk and not data['all_organizer_permissions'] and 'organizer.teams:write' not in data.get('limit_organizer_permissions', []):
if not self.instance.organizer.teams.exclude(pk=self.instance.pk).filter(
can_change_teams=True, members__isnull=False
TeamQuerySet.organizer_permission_q("organizer.teams:write"),
members__isnull=False
).exists():
raise ValidationError(_('The changes could not be saved because there would be no remaining team with '
'the permission to change teams and permissions.'))
return data
@property
def changed_data_for_log(self):
r = {}
for k in self.changed_data:
if k == "limit_events":
r[k] = [e.id for e in getattr(self.instance, k).all()]
elif k.startswith("event_"):
r["limit_event_permissions"] = self.instance.limit_event_permissions
elif k.startswith("organizer_"):
r["limit_organizer_permissions"] = self.instance.limit_organizer_permissions
else:
r[k] = getattr(self.instance, k)
return r
class GateForm(forms.ModelForm):