diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index e7dc24e24a..fdd34717b6 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -100,3 +100,7 @@ API .. automodule:: pretix.base.signals :no-index: :members: validate_event_settings, api_event_settings_fields + +.. automodule:: pretix.api.signals + :no-index: + :members: register_device_security_profile diff --git a/src/pretix/api/auth/device.py b/src/pretix/api/auth/device.py index ac09de1598..dd9b4c2878 100644 --- a/src/pretix/api/auth/device.py +++ b/src/pretix/api/auth/device.py @@ -27,7 +27,7 @@ from rest_framework import exceptions from rest_framework.authentication import TokenAuthentication from pretix.api.auth.devicesecurity import ( - DEVICE_SECURITY_PROFILES, FullAccessSecurityProfile, + FullAccessSecurityProfile, get_all_security_profiles, ) from pretix.base.models import Device @@ -58,7 +58,8 @@ class DeviceTokenAuthentication(TokenAuthentication): def authenticate(self, request): r = super().authenticate(request) if r and isinstance(r[1], Device): - profile = DEVICE_SECURITY_PROFILES.get(r[1].security_profile, FullAccessSecurityProfile) + profiles = get_all_security_profiles() + profile = profiles.get(r[1].security_profile, FullAccessSecurityProfile()) if not profile.is_allowed(request): raise exceptions.PermissionDenied('Request denied by device security profile.') return r diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index aef2fef40f..d6be03e72c 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -20,13 +20,40 @@ # . # import logging +from collections import OrderedDict +from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ +from pretix.api.signals import register_device_security_profile + logger = logging.getLogger(__name__) +_ALL_PROFILES = None -class FullAccessSecurityProfile: +class BaseSecurityProfile: + @property + def identifier(self) -> str: + """ + Unique identifier for this profile. + """ + raise NotImplementedError() + + @property + def verbose_name(self) -> str: + """ + Human-readable name (can be a ``gettext_lazy`` object). + """ + raise NotImplementedError() + + def is_allowed(self, request) -> bool: + """ + Return whether a given request should be allowed. + """ + raise NotImplementedError() + + +class FullAccessSecurityProfile(BaseSecurityProfile): identifier = 'full' verbose_name = _('Full device access (reading and changing orders and gift cards, reading of products and settings)') @@ -34,7 +61,7 @@ class FullAccessSecurityProfile: return True -class AllowListSecurityProfile: +class AllowListSecurityProfile(BaseSecurityProfile): allowlist = () def is_allowed(self, request): @@ -157,88 +184,28 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile): ) -class PretixPosSecurityProfile(AllowListSecurityProfile): - identifier = 'pretixpos' - verbose_name = _('pretixPOS') - allowlist = ( - ('GET', 'api-v1:version'), - ('GET', 'api-v1:device.eventselection'), - ('GET', 'api-v1:idempotency.query'), - ('GET', 'api-v1:device.info'), - ('POST', 'api-v1:device.update'), - ('POST', 'api-v1:device.revoke'), - ('POST', 'api-v1:device.roll'), - ('GET', 'api-v1:event-list'), - ('GET', 'api-v1:event-detail'), - ('GET', 'api-v1:subevent-list'), - ('GET', 'api-v1:subevent-detail'), - ('GET', 'api-v1:itemcategory-list'), - ('GET', 'api-v1:item-list'), - ('GET', 'api-v1:question-list'), - ('GET', 'api-v1:quota-list'), - ('GET', 'api-v1:taxrule-list'), - ('GET', 'api-v1:ticketlayout-list'), - ('GET', 'api-v1:ticketlayoutitem-list'), - ('GET', 'api-v1:badgelayout-list'), - ('GET', 'api-v1:badgeitem-list'), - ('GET', 'api-v1:voucher-list'), - ('GET', 'api-v1:voucher-detail'), - ('GET', 'api-v1:order-list'), - ('POST', 'api-v1:order-list'), - ('GET', 'api-v1:order-detail'), - ('DELETE', 'api-v1:orderposition-detail'), - ('PATCH', 'api-v1:orderposition-detail'), - ('GET', 'api-v1:orderposition-list'), - ('GET', 'api-v1:orderposition-answer'), - ('GET', 'api-v1:orderposition-pdf_image'), - ('POST', 'api-v1:orderposition-printlog'), - ('POST', 'api-v1:order-mark-canceled'), - ('POST', 'api-v1:orderpayment-list'), - ('POST', 'api-v1:orderrefund-list'), - ('POST', 'api-v1:orderrefund-done'), - ('POST', 'api-v1:cartposition-list'), - ('POST', 'api-v1:cartposition-bulk-create'), - ('GET', 'api-v1:checkinlist-list'), - ('POST', 'api-v1:checkinlistpos-redeem'), - ('POST', 'plugins:pretix_posbackend:order.posprintlog'), - ('POST', 'plugins:pretix_posbackend:order.poslock'), - ('DELETE', 'plugins:pretix_posbackend:order.poslock'), - ('DELETE', 'api-v1:cartposition-detail'), - ('GET', 'api-v1:giftcard-list'), - ('POST', 'api-v1:giftcard-transact'), - ('PATCH', 'api-v1:giftcard-detail'), - ('GET', 'plugins:pretix_posbackend:posclosing-list'), - ('POST', 'plugins:pretix_posbackend:posreceipt-list'), - ('POST', 'plugins:pretix_posbackend:posclosing-list'), - ('POST', 'plugins:pretix_posbackend:posdebugdump-list'), - ('POST', 'plugins:pretix_posbackend:posdebuglogentry-list'), - ('POST', 'plugins:pretix_posbackend:posdebuglogentry-bulk-create'), - ('GET', 'plugins:pretix_posbackend:poscashier-list'), - ('POST', 'plugins:pretix_posbackend:stripeterminal.token'), - ('POST', 'plugins:pretix_posbackend:stripeterminal.paymentintent'), - ('PUT', 'plugins:pretix_posbackend:file.upload'), - ('GET', 'api-v1:revokedsecrets-list'), - ('GET', 'api-v1:blockedsecrets-list'), - ('GET', 'api-v1:event.settings'), - ('GET', 'plugins:pretix_seating:event.event'), - ('GET', 'plugins:pretix_seating:event.event.subevent'), - ('GET', 'plugins:pretix_seating:event.plan'), - ('GET', 'plugins:pretix_seating:selection.simple'), - ('POST', 'api-v1:upload'), - ('POST', 'api-v1:checkinrpc.redeem'), - ('GET', 'api-v1:checkinrpc.search'), - ('POST', 'api-v1:reusablemedium-lookup'), - ('GET', 'api-v1:reusablemedium-list'), - ('POST', 'api-v1:reusablemedium-list'), - ) +def get_all_security_profiles(): + global _ALL_PROFILES + + if _ALL_PROFILES: + return _ALL_PROFILES + + types = OrderedDict() + for recv, ret in register_device_security_profile.send(None): + if isinstance(ret, (list, tuple)): + for r in ret: + types[r.identifier] = r + else: + types[ret.identifier] = ret + _ALL_PROFILES = types + return types -DEVICE_SECURITY_PROFILES = { - k.identifier: k() for k in ( - FullAccessSecurityProfile, - PretixScanSecurityProfile, - PretixScanNoSyncSecurityProfile, - PretixScanNoSyncNoSearchSecurityProfile, - PretixPosSecurityProfile, +@receiver(register_device_security_profile, dispatch_uid="base_register_default_security_profiles") +def register_default_webhook_events(sender, **kwargs): + return ( + FullAccessSecurityProfile(), + PretixScanSecurityProfile(), + PretixScanNoSyncSecurityProfile(), + PretixScanNoSyncNoSearchSecurityProfile(), ) -} diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index cb47fed208..c49d86b32a 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -29,6 +29,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import ValidationError +from pretix.api.auth.devicesecurity import get_all_security_profiles from pretix.api.serializers import AsymmetricField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import CompatibleJSONField @@ -297,6 +298,7 @@ class DeviceSerializer(serializers.ModelSerializer): revoked = serializers.BooleanField(read_only=True) initialized = serializers.DateTimeField(read_only=True) initialization_token = serializers.DateTimeField(read_only=True) + security_profile = serializers.ChoiceField(choices=[], required=False, default="full") class Meta: model = Device @@ -306,6 +308,10 @@ class DeviceSerializer(serializers.ModelSerializer): 'os_name', 'os_version', 'software_brand', 'software_version', 'security_profile' ) + 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()] + class TeamInviteSerializer(serializers.ModelSerializer): class Meta: diff --git a/src/pretix/api/signals.py b/src/pretix/api/signals.py index a441194193..40383d5d80 100644 --- a/src/pretix/api/signals.py +++ b/src/pretix/api/signals.py @@ -32,10 +32,17 @@ from pretix.helpers.periodic import minimum_interval register_webhook_events = Signal() """ This signal is sent out to get all known webhook events. Receivers should return an -instance of a subclass of pretix.api.webhooks.WebhookEvent or a list of such +instance of a subclass of ``pretix.api.webhooks.WebhookEvent`` or a list of such instances. """ +register_device_security_profile = Signal() +""" +This signal is sent out to get all known device security_profiles. Receivers should +return an instance of a subclass of ``pretix.api.auth.devicesecurity.BaseSecurityProfile`` +or a list of such instances. +""" + @receiver(periodic_task) @scopes_disabled() diff --git a/src/pretix/base/models/devices.py b/src/pretix/base/models/devices.py index a45d9c1fce..71c6141592 100644 --- a/src/pretix/base/models/devices.py +++ b/src/pretix/base/models/devices.py @@ -28,7 +28,6 @@ from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ from django_scopes import ScopedManager, scopes_disabled -from pretix.api.auth.devicesecurity import DEVICE_SECURITY_PROFILES from pretix.base.models import LoggedModel @@ -161,7 +160,6 @@ class Device(LoggedModel): ) security_profile = models.CharField( max_length=190, - choices=[(k, v.verbose_name) for k, v in DEVICE_SECURITY_PROFILES.items()], default='full', null=True, blank=False diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index e2e695bd79..fb5f44ef10 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -54,6 +54,7 @@ from i18nfield.strings import LazyI18nString from phonenumber_field.formfields import PhoneNumberField from pytz import common_timezones +from pretix.api.auth.devicesecurity import get_all_security_profiles from pretix.api.models import WebHook from pretix.api.webhooks import get_all_webhook_events from pretix.base.customersso.oidc import oidc_validate_and_complete_config @@ -311,6 +312,11 @@ class DeviceForm(forms.ModelForm): '-has_subevents', '-date_from' ) self.fields['gate'].queryset = organizer.gates.all() + self.fields['security_profile'] = forms.ChoiceField( + label=self.fields['security_profile'].label, + help_text=self.fields['security_profile'].help_text, + choices=[(k, v.verbose_name) for k, v in get_all_security_profiles().items()], + ) def clean(self): d = super().clean() @@ -344,6 +350,11 @@ class DeviceBulkEditForm(forms.ModelForm): '-has_subevents', '-date_from' ) self.fields['gate'].queryset = organizer.gates.all() + self.fields['security_profile'] = forms.ChoiceField( + label=self.fields['security_profile'].label, + help_text=self.fields['security_profile'].help_text, + choices=[(k, v.verbose_name) for k, v in get_all_security_profiles().items()], + ) def clean(self): d = super().clean()