diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index e7dc24e24..fdd34717b 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 ac09de159..dd9b4c287 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 aef2fef40..c5fbd2f9c 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): @@ -233,12 +260,28 @@ class PretixPosSecurityProfile(AllowListSecurityProfile): ) -DEVICE_SECURITY_PROFILES = { - k.identifier: k() for k in ( - FullAccessSecurityProfile, - PretixScanSecurityProfile, - PretixScanNoSyncSecurityProfile, - PretixScanNoSyncNoSearchSecurityProfile, - PretixPosSecurityProfile, +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 + + +@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 cb47fed20..c49d86b32 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 a44119419..224ed715a 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.devicesceuirty.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 a45d9c1fc..71c614159 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 e2e695bd7..fb5f44ef1 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()