From 940f173b1d9060062b486ee6abcef5ab543bc74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Beaul=C3=A9?= Date: Sat, 15 Jun 2024 12:22:01 -0300 Subject: [PATCH] Create auth scheme for private URL keys This commit creates a new authentication class that takes an "Authorization" header with a private URL key (prefixed by "Key"), to identify participants with their use of API endpoints where appropriate. It will be necessary for participants to use the API with private keys when the new frontend will be in use. Extra permissions logic has been added to ensure participants only have access to their data, to act as themselves, and only if the relevant preference has been set. A couple endpoints have been adapted: * Ballots: One's own ballots can always be seen, but preference on whether to create them. Teams can see theirs on the "private_ballots_released" setting. * Feedback: Participants can always see the feedback they have submitted but not whether it is ignored. If "participant_ballots" set, either no auth or private URL key is needed. * Check-in: Participants can check themselves in and out if self-check- in active. Added a fake doc comment to avoid the LogActionMixin text from appearing on all endpoint docs. --- tabbycat/api/mixins.py | 2 + tabbycat/api/permissions.py | 20 +++++ tabbycat/api/serializers.py | 80 ++++++++++++++----- tabbycat/api/tests/test_serializers.py | 1 + tabbycat/api/utils.py | 2 +- tabbycat/api/views.py | 102 ++++++++++++++++++++++--- 6 files changed, 173 insertions(+), 34 deletions(-) diff --git a/tabbycat/api/mixins.py b/tabbycat/api/mixins.py index b3e3ed83fa2..32eafb784cf 100644 --- a/tabbycat/api/mixins.py +++ b/tabbycat/api/mixins.py @@ -11,6 +11,8 @@ class APILogActionMixin(LogActionMixin): + """ + """ action_log_content_object_attr = 'obj' def perform_create(self, serializer): diff --git a/tabbycat/api/permissions.py b/tabbycat/api/permissions.py index c0fc745184c..d11e707d995 100644 --- a/tabbycat/api/permissions.py +++ b/tabbycat/api/permissions.py @@ -1,9 +1,29 @@ from dynamic_preferences.registries import global_preferences_registry +from rest_framework.authentication import TokenAuthentication +from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import BasePermission, SAFE_METHODS +from participants.models import Person from users.permissions import has_permission +class URLKeyAuthentication(TokenAuthentication): + keyword = 'Key' + model = Person + + def authenticate_credentials(self, key): + model = self.get_model() + if not key: + raise AuthenticationFailed('No URL key provided.') + + try: + person = model.objects.select_related('adjudicator__tournament', 'speaker__team__tournament').get(url_key=key) + except model.DoesNotExist: + raise AuthenticationFailed('Invalid URL key.') + + return (None, person) + + class APIEnabledPermission(BasePermission): message = "The API has been disabled on this site." diff --git a/tabbycat/api/serializers.py b/tabbycat/api/serializers.py index 7fe3eefa19b..d690a71b1e6 100644 --- a/tabbycat/api/serializers.py +++ b/tabbycat/api/serializers.py @@ -10,6 +10,7 @@ from django.utils import timezone from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from rest_framework.exceptions import PermissionDenied from rest_framework.fields import get_error_detail, SkipField from rest_framework.settings import api_settings @@ -22,14 +23,14 @@ from participants.models import Adjudicator, Institution, Region, Speaker, SpeakerCategory, Team from participants.utils import populate_code_names from privateurls.utils import populate_url_keys -from results.mixins import TabroomSubmissionFieldsMixin -from results.models import BallotSubmission, SpeakerScore, TeamScore +from results.models import BallotSubmission, SpeakerScore, Submission, TeamScore from results.result import DebateResult, ResultError from standings.speakers import SpeakerStandingsGenerator from standings.teams import TeamStandingsGenerator from tournaments.models import Round, Tournament from users.models import Group from users.permissions import has_permission, Permission +from utils.misc import get_ip_address from venues.models import Venue, VenueCategory, VenueConstraint from . import fields @@ -1036,7 +1037,7 @@ class Meta: validate_seq = partialmethod(_validate_field, 'seq') -class FeedbackSerializer(TabroomSubmissionFieldsMixin, serializers.ModelSerializer): +class FeedbackSerializer(serializers.ModelSerializer): class SubmitterSourceField(fields.BaseSourceField): field_source_name = 'source' @@ -1096,6 +1097,12 @@ class Meta: 'submitter_type', 'participant_submitter', 'submitter', 'confirmer', 'confirm_timestamp', 'ip_address', 'private_url') + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not is_staff(kwargs.get('context')): + self.fields.pop('ip_address') + self.fields.pop('ignored') + def validate(self, data): source = data.pop('source') debate = data.pop('debate') @@ -1117,21 +1124,28 @@ def validate(self, data): raise serializers.ValidationError("Target is not in debate") # Also move the source field into participant_specific fields - if isinstance(source, Team): - try: - data['source_team'] = source.debateteam_set.get(debate=debate) - except DebateTeam.DoesNotExist: - raise serializers.ValidationError("Source is not in debate") - elif isinstance(source, Adjudicator): - try: - data['source_adjudicator'] = source.debateadjudicator_set.get(debate=debate) - except DebateAdjudicator.DoesNotExist: - raise serializers.ValidationError("Source is not in debate") + participant = self.context['participant_requester'] + source_type = type(source) + type_name = source_type.__name__.lower() + if participant and getattr(participant, type_name, None) != source: + raise PermissionDenied("Participant may only submit feedback from themselves") + related_field = getattr(source, 'debate%s_set' % type_name) + try: + data['source_%s' % type_name] = related_field.get(debate=debate) + except related_field.rel.related_model.DoesNotExist: + raise serializers.ValidationError("Source is not in debate") return super().validate(data) - def get_request(self): - return self.context['request'] + def get_submitter_fields(self): + participant = self.context['participant_requester'] + request = self.context['request'] + return { + 'participant_submitter': request.auth if participant else None, + 'submitter': participant or request.user, + 'submitter_type': Submission.Submitter.PUBLIC if participant else Submission.Submitter.TABROOM, + 'ip_address': get_ip_address(request), + } def create(self, validated_data): answers = validated_data.pop('get_answers') @@ -1165,7 +1179,7 @@ def update(self, instance, validated_data): return super().update(instance, validated_data) -class BallotSerializer(TabroomSubmissionFieldsMixin, serializers.ModelSerializer): +class BallotSerializer(serializers.ModelSerializer): class ResultSerializer(serializers.Serializer): class SheetSerializer(serializers.Serializer): @@ -1338,8 +1352,27 @@ class Meta: 'submitter_type', 'submitter', 'participant_submitter', 'confirmer', 'confirm_timestamp', 'ip_address', 'private_url') - def get_request(self): - return self.context['request'] + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not is_staff(kwargs.get('context')): + self.fields.pop('ip_address') + + def clean_confirmed(self, value): + if value and self.context.get('participant_requester'): + raise PermissionDenied("Public cannot confirm ballot") + return value + + def get_submitter_fields(self): + participant = self.context['participant_requester'] + request = self.context['request'] + if participant is not None and not self.context['debate'].debateadjudicator_set.filter(adjudicator_id=participant.id).exists(): + raise PermissionDenied('Authenticated adjudicator is not in debate') + return { + 'participant_submitter': participant, + 'submitter': participant or request.user, + 'submitter_type': Submission.Submitter.PUBLIC if participant else Submission.Submitter.TABROOM, + 'ip_address': get_ip_address(request), + } def create(self, validated_data): result_data = validated_data.pop('result').pop('get_result_info') @@ -1355,7 +1388,10 @@ def create(self, validated_data): debateadj_count = self.context['debate'].debateadjudicator_set.exclude(type=DebateAdjudicator.TYPE_TRAINEE).count() if debateadj_count > 1: if len(result_data['sheets']) == 1: - validated_data['participant_submitter'] = result_data['sheets'][0]['adjudicator'] + validated_data['participant_submitter'] = result_data['sheets'][0].get('adjudicator', validated_data['participant_submitter']) + p_sub = validated_data['participant_submitter'] + if self.context['participant_requester'] is not None and p_sub is not None and p_sub != self.context['participant_requester']: + raise PermissionDenied('Cannot submit single-adjudicator ballot for someone else') validated_data['single_adj'] = True elif validated_data.get('single_adj', False): raise serializers.ValidationError({'single_adj': 'Single-adjudicator ballots can only have one scoresheet'}) @@ -1363,8 +1399,10 @@ def create(self, validated_data): raise serializers.ValidationError({ 'result': 'Voting ballots must either have one scoresheet or ballots from all voting adjudicators', }) - elif len(result_data['sheets']) > 1: - raise serializers.ValidationError({'result': 'Consensus ballots can only have one scoresheet'}) + else: + if len(result_data['sheets']) > 1: + raise serializers.ValidationError({'result': 'Consensus ballots can only have one scoresheet'}) + validated_data['single_adj'] = self.context['tournament'].pref('individual_ballots') ballot = super().create(validated_data) diff --git a/tabbycat/api/tests/test_serializers.py b/tabbycat/api/tests/test_serializers.py index 0314b993176..d6a795b112f 100644 --- a/tabbycat/api/tests/test_serializers.py +++ b/tabbycat/api/tests/test_serializers.py @@ -482,6 +482,7 @@ def test_single_adj_ballot(self): }], }, }) + print(response.data) self.assertEqual(response.status_code, 201) def test_team_not_in_debate(self): diff --git a/tabbycat/api/utils.py b/tabbycat/api/utils.py index 95c753b07a9..522c6a018c6 100644 --- a/tabbycat/api/utils.py +++ b/tabbycat/api/utils.py @@ -1,4 +1,4 @@ def is_staff(context): # OpenAPI generation does not have a view (sometimes context is also None in that circumstance). # Avoid redacting fields. - return context is None or 'view' not in context or not context['request'].user.is_anonymous + return context is None or 'view' not in context or not getattr(context['request'].user, 'is_anonymous', True) diff --git a/tabbycat/api/views.py b/tabbycat/api/views.py index 0a302cd23f2..1ef2d60ca50 100644 --- a/tabbycat/api/views.py +++ b/tabbycat/api/views.py @@ -10,11 +10,11 @@ from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from dynamic_preferences.api.serializers import PreferenceSerializer from dynamic_preferences.api.viewsets import PerInstancePreferenceViewSet -from rest_framework.exceptions import NotFound +from rest_framework.exceptions import NotFound, PermissionDenied from rest_framework.fields import DateTimeField from rest_framework.generics import GenericAPIView, get_object_or_404, RetrieveUpdateAPIView from rest_framework.mixins import ListModelMixin -from rest_framework.permissions import IsAdminUser +from rest_framework.permissions import BasePermission, IsAdminUser from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.views import APIView @@ -32,7 +32,7 @@ from checkins.utils import create_identifiers, get_unexpired_checkins from draw.models import Debate, DebateTeam from options.models import TournamentPreferenceModel -from participants.models import Adjudicator, Institution, Speaker, SpeakerCategory, Team +from participants.models import Adjudicator, Institution, Person, Speaker, SpeakerCategory, Team from results.models import SpeakerScore, TeamScore from standings.speakers import SpeakerStandingsGenerator from standings.teams import TeamStandingsGenerator @@ -44,7 +44,7 @@ from . import serializers from .fields import ParticipantAvailabilityForeignKeyField from .mixins import AdministratorAPIMixin, APILogActionMixin, PublicAPIMixin, RoundAPIMixin, TournamentAPIMixin, TournamentPublicAPIMixin -from .permissions import APIEnabledPermission, PerTournamentPermissionRequired, PublicPreferencePermission +from .permissions import APIEnabledPermission, PerTournamentPermissionRequired, PublicPreferencePermission, URLKeyAuthentication tournament_parameter = OpenApiParameter('tournament_slug', description="The tournament's slug", type=str, location="path") @@ -664,6 +664,23 @@ def post(self, request, *args, **kwargs): return Response(self.get_response_dict(request, obj.get(), False, None), status=status) +class PersonCheckinMixin: + class CustomPermission(BasePermission): + def has_permission(self, request, view): + return view.tournament.pref('participant_ballots') == 'private-urls' and view.participant_requester and request.method != 'POST' + + authentication_classes = [URLKeyAuthentication] + permission_classes = [APIEnabledPermission, CustomPermission | PerTournamentPermissionRequired | IsAdminUser] + + @property + def participant_requester(self): + if isinstance(person := self.request.auth, Person): + return person + + def get_queryset(self): + return super().get_queryset().filter(id=self.participant_requester.id) + + @extend_schema(tags=['adjudicators']) @extend_schema_view( get=extend_schema(summary="Get adjudicator checkin status"), @@ -672,7 +689,7 @@ def post(self, request, *args, **kwargs): patch=extend_schema(summary="Toggle adjudicator checkin status"), post=extend_schema(summary="Create adjudicator checkin identifier"), ) -class AdjudicatorCheckinsView(BaseCheckinsView): +class AdjudicatorCheckinsView(PersonCheckinMixin, BaseCheckinsView): model = Adjudicator object_api_view = 'api-adjudicator-detail' window_preference_pref = 'checkin_window_people' @@ -686,7 +703,7 @@ class AdjudicatorCheckinsView(BaseCheckinsView): patch=extend_schema(summary="Toggle speaker checkin status"), post=extend_schema(summary="Create speaker checkin identifier"), ) -class SpeakerCheckinsView(BaseCheckinsView): +class SpeakerCheckinsView(PersonCheckinMixin, BaseCheckinsView): model = Speaker object_api_view = 'api-speaker-detail' window_preference_pref = 'checkin_window_people' @@ -962,12 +979,24 @@ def delete_all(self, request, *args, **kwargs): partial_update=extend_schema(summary="Patch ballot", parameters=[id_parameter], request=serializers.UpdateBallotSerializer), ) class BallotViewSet(RoundAPIMixin, TournamentPublicAPIMixin, ModelViewSet): + + class CustomPermission(BasePermission): + def has_permission(self, request, view): + return ( + (view.action in ['list', 'retrieve', 'create'] and view.tournament.pref('participant_ballots') == 'private-urls' and view.participant_requester) or + (view.action == 'create' and view.tournament.pref('participant_ballots') == 'public') or + (view.action in ['list', 'retrieve'] and view.tournament.pref('private_ballots_released') is True) + ) + serializer_class = serializers.BallotSerializer access_preference = 'ballots_released' tournament_field = 'debate__round__tournament' round_field = 'debate__round' + authentication_classes = [URLKeyAuthentication] + permission_classes = [APIEnabledPermission, PublicPreferencePermission | CustomPermission | PerTournamentPermissionRequired] + list_permission = Permission.VIEW_BALLOTSUBMISSIONS create_permission = Permission.ADD_BALLOTSUBMISSIONS update_permission = Permission.EDIT_BALLOTSUBMISSIONS @@ -976,6 +1005,23 @@ class BallotViewSet(RoundAPIMixin, TournamentPublicAPIMixin, ModelViewSet): action_log_type_created = ActionLogEntry.ActionType.BALLOT_CREATE action_log_type_updated = ActionLogEntry.ActionType.BALLOT_EDIT + @property + def participant_requester(self): + if isinstance(person := self.request.auth, Person): + try: + return person.adjudicator + except Adjudicator.DoesNotExist: + if self.action == 'create': + raise PermissionDenied('URL key for submitting ballot must be for an adjudicator') + else: + return person.speaker.team + + def get_serializer_context(self): + context = super().get_serializer_context() + context['participant_requester'] = self.participant_requester + context['debate'] = self.debate + return context + @property def debate(self): if hasattr(self, '_debate'): @@ -987,14 +1033,15 @@ def debate(self): def lookup_kwargs(self): return {'debate': self.debate} - def get_serializer_context(self): - context = super().get_serializer_context() - context['debate'] = self.debate - return context - def get_queryset(self): filters = Q() - if self.request.query_params.get('confirmed') or not self.request.user.is_staff: + + if isinstance(self.participant_requester, Adjudicator): + filters &= Q(debate__debateadjudicator_set__adjudicator_id=self.participant_requester.id) + if isinstance(self.participant_requester, Team): + filters &= Q(debate__debateteam_set__team_id=self.participant_requester.id) + + if self.request.query_params.get('confirmed') or not (self.request.user.is_staff or self.participant_requester): filters &= Q(confirmed=True) return super().get_queryset().filter(filters).prefetch_related( 'debateteammotionpreference_set__motion__tournament', @@ -1059,22 +1106,53 @@ def get_queryset(self): destroy=extend_schema(summary="Delete feedback", parameters=[id_parameter]), ) class FeedbackViewSet(TournamentAPIMixin, AdministratorAPIMixin, ModelViewSet): + + class CustomPermission(BasePermission): + def has_permission(self, request, view): + return ( + (view.action in ['list', 'retrieve', 'create'] and view.tournament.pref('participant_feedback') == 'private-urls' and view.participant_requester) or + (view.action == 'create' and view.tournament.pref('participant_feedback') == 'public') + ) + serializer_class = serializers.FeedbackSerializer tournament_field = 'adjudicator__tournament' action_log_type_created = ActionLogEntry.ActionType.FEEDBACK_SAVE action_log_type_updated = ActionLogEntry.ActionType.FEEDBACK_SAVE + authentication_classes = [URLKeyAuthentication] + permission_classes = [APIEnabledPermission, CustomPermission | PerTournamentPermissionRequired | IsAdminUser] + list_permission = Permission.VIEW_FEEDBACK create_permission = Permission.ADD_FEEDBACK update_permission = Permission.EDIT_FEEDBACK_IGNORE destroy_permission = Permission.EDIT_FEEDBACK_CONFIRM + @property + def participant_requester(self): + if isinstance(person := self.request.auth, Person): + try: + return person.adjudicator + except Adjudicator.DoesNotExist: + return person.speaker.team + + def get_serializer_context(self): + context = super().get_serializer_context() + context['participant_requester'] = self.participant_requester + return context + def perform_create(self, serializer): serializer.save() + self.log_action(type=self.action_log_type_created, agent=ActionLogEntry.Agent.API) def get_queryset(self): query_params = self.request.query_params filters = Q() + + # Disallow querying for feedback that they didn't submit + if (person := self.participant_requester) is not None: + if self.action == 'list' and (query_params.get('source_type') != type(person).__name__.lower() or query_params.get('source') != str(person.id)): + raise PermissionDenied("URL key-authorized requests may only get the participants' objects") + if query_params.get('source_type') == 'adjudicator': filters &= Q(source_team__isnull=True) if query_params.get('source'):