Skip to content

Commit

Permalink
Create auth scheme for private URL keys
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
tienne-B committed Jul 19, 2024
1 parent 8ec535f commit 940f173
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 34 deletions.
2 changes: 2 additions & 0 deletions tabbycat/api/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@


class APILogActionMixin(LogActionMixin):
"""
"""
action_log_content_object_attr = 'obj'

def perform_create(self, serializer):
Expand Down
20 changes: 20 additions & 0 deletions tabbycat/api/permissions.py
Original file line number Diff line number Diff line change
@@ -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."

Expand Down
80 changes: 59 additions & 21 deletions tabbycat/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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')
Expand All @@ -1355,16 +1388,21 @@ 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'})
elif len(result_data['sheets']) != debateadj_count:
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)

Expand Down
1 change: 1 addition & 0 deletions tabbycat/api/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 1 addition & 1 deletion tabbycat/api/utils.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 940f173

Please sign in to comment.