From 19f7eafd0f1d29a958038cfc3e5e2223ae1455a1 Mon Sep 17 00:00:00 2001 From: Andreas Kloeckner Date: Tue, 10 Sep 2024 17:01:07 -0500 Subject: [PATCH] Add PrairieTest integration --- .ci/run-mypy.sh | 2 +- course/admin.py | 15 +- course/utils.py | 33 ++- course/validation.py | 2 + prairietest/__init__.py | 0 prairietest/admin.py | 113 ++++++++ prairietest/migrations/0001_initial.py | 93 +++++++ ...yevent_end_alter_denyevent_end_and_more.py | 34 +++ prairietest/migrations/__init__.py | 0 prairietest/models.py | 133 ++++++++++ prairietest/urls.py | 41 +++ prairietest/utils.py | 117 +++++++++ prairietest/views.py | 248 ++++++++++++++++++ relate/settings.py | 1 + relate/urls.py | 2 + 15 files changed, 823 insertions(+), 11 deletions(-) create mode 100644 prairietest/__init__.py create mode 100644 prairietest/admin.py create mode 100644 prairietest/migrations/0001_initial.py create mode 100644 prairietest/migrations/0002_mostrecentdenyevent_end_alter_denyevent_end_and_more.py create mode 100644 prairietest/migrations/__init__.py create mode 100644 prairietest/models.py create mode 100644 prairietest/urls.py create mode 100644 prairietest/utils.py create mode 100644 prairietest/views.py diff --git a/.ci/run-mypy.sh b/.ci/run-mypy.sh index f3fa65e29..5143b999c 100755 --- a/.ci/run-mypy.sh +++ b/.ci/run-mypy.sh @@ -1,3 +1,3 @@ #! /bin/bash -mypy relate course accounts +mypy relate course accounts prairietest diff --git a/course/admin.py b/course/admin.py index 2bec96606..6907843c9 100644 --- a/course/admin.py +++ b/course/admin.py @@ -23,10 +23,11 @@ THE SOFTWARE. """ -from typing import Any +from typing import TYPE_CHECKING, Any from django import forms from django.contrib import admin +from django.db.models import QuerySet from django.utils.translation import gettext_lazy as _, pgettext from course.constants import exam_ticket_states, participation_permission as pperm @@ -56,9 +57,13 @@ from relate.utils import string_concat +if TYPE_CHECKING: + from accounts.models import User + + # {{{ permission helpers -def _filter_courses_for_user(queryset, user): +def _filter_courses_for_user(queryset: QuerySet, user: User) -> QuerySet: if user.is_superuser: return queryset z = queryset.filter( @@ -67,7 +72,7 @@ def _filter_courses_for_user(queryset, user): return z -def _filter_course_linked_obj_for_user(queryset, user): +def _filter_course_linked_obj_for_user(queryset: QuerySet, user: User) -> QuerySet: if user.is_superuser: return queryset return queryset.filter( @@ -76,7 +81,9 @@ def _filter_course_linked_obj_for_user(queryset, user): ) -def _filter_participation_linked_obj_for_user(queryset, user): +def _filter_participation_linked_obj_for_user( + queryset: QuerySet, user: User + ) -> QuerySet: if user.is_superuser: return queryset return queryset.filter( diff --git a/course/utils.py b/course/utils.py index 0336deb38..dc44d900b 100644 --- a/course/utils.py +++ b/course/utils.py @@ -54,7 +54,6 @@ parse_date_spec, ) from course.page.base import PageBase, PageContext -from prairietest.utils import has_access_to_exam from relate.utils import ( RelateHttpRequest, not_none, @@ -164,6 +163,8 @@ def _eval_generic_conditions( now_datetime: datetime.datetime, flow_id: str, login_exam_ticket: ExamTicket | None, + *, + remote_ip_address: IPv4Address | IPv6Address | None = None, ) -> bool: if hasattr(rule, "if_before"): @@ -189,6 +190,22 @@ def _eval_generic_conditions( if login_exam_ticket.exam.flow_id != flow_id: return False + if hasattr(rule, "if_has_prairietest_exam_access"): + if remote_ip_address is None: + return False + if participation is None: + return False + + from prairietest.utils import has_access_to_exam + if not has_access_to_exam( + course, + participation.user.email, + rule.if_has_prairietest_exam_access, + now_datetime, + remote_ip_address, + ): + return False + return True @@ -313,7 +330,8 @@ def get_session_start_rule( for rule in rules: if not _eval_generic_conditions(rule, course, participation, now_datetime, flow_id=flow_id, - login_exam_ticket=login_exam_ticket): + login_exam_ticket=login_exam_ticket, + remote_ip_address=remote_ip_address): continue if not _eval_participation_tags_conditions(rule, participation): @@ -401,9 +419,11 @@ def get_session_access_rule( for rule in rules: if not _eval_generic_conditions( - rule, session.course, session.participation, - now_datetime, flow_id=session.flow_id, - login_exam_ticket=login_exam_ticket): + rule, session.course, session.participation, + now_datetime, flow_id=session.flow_id, + login_exam_ticket=login_exam_ticket, + remote_ip_address=remote_ip_address, + ): continue if not _eval_participation_tags_conditions(rule, session.participation): @@ -1050,7 +1070,7 @@ class FacilityFindingMiddleware: def __init__(self, get_response): self.get_response = get_response - def __call__(self, request): + def __call__(self, request: http.HttpRequest) -> http.HttpResponse: pretend_facilities = request.session.get("relate_pretend_facilities") if pretend_facilities is not None: @@ -1071,6 +1091,7 @@ def __call__(self, request): if remote_address in ip_network(str(ir)): facilities.add(name) + request = cast(RelateHttpRequest, request) request.relate_facilities = frozenset(facilities) return self.get_response(request) diff --git a/course/validation.py b/course/validation.py index 7a7080b55..2ffdc79c3 100644 --- a/course/validation.py +++ b/course/validation.py @@ -576,6 +576,7 @@ def validate_session_start_rule( ("if_has_fewer_sessions_than", int), ("if_has_fewer_tagged_sessions_than", int), ("if_signed_in_with_matching_exam_ticket", bool), + ("if_has_prairietest_exam_access", str), ("tag_session", (str, type(None))), ("may_start_new_session", bool), ("may_list_existing_sessions", bool), @@ -673,6 +674,7 @@ def validate_session_access_rule( ("if_expiration_mode", str), ("if_session_duration_shorter_than_minutes", (int, float)), ("if_signed_in_with_matching_exam_ticket", bool), + ("if_has_prairietest_exam_access", str), ("message", str), ] ) diff --git a/prairietest/__init__.py b/prairietest/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/prairietest/admin.py b/prairietest/admin.py new file mode 100644 index 000000000..ba42f3322 --- /dev/null +++ b/prairietest/admin.py @@ -0,0 +1,113 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from typing import TYPE_CHECKING + +from django import forms, http +from django.contrib import admin +from django.db.models import QuerySet + +from accounts.models import User +from course.constants import participation_permission as pperm +from prairietest.models import AllowEvent, DenyEvent, Facility, MostRecentDenyEvent + + +if TYPE_CHECKING: + from accounts.models import User + + +class FacilityAdminForm(forms.ModelForm): + class Meta: + model = Facility + fields = "__all__" + widgets = { + "secret": forms.PasswordInput, + } + + +@admin.register(Facility) +class FacilityAdmin(admin.ModelAdmin): + def get_queryset(self, request: http.HttpRequest) -> QuerySet: + assert request.user.is_authenticated + + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + from course.admin import _filter_course_linked_obj_for_user + return _filter_course_linked_obj_for_user(qs, request.user) + + list_display = ["identifier", "course"] + list_filter = ["identifier", "course"] + + form = FacilityAdminForm + + +def _filter_events_for_user(queryset: QuerySet, user: User) -> QuerySet: + if user.is_superuser: + return queryset + return queryset.filter( + test_facility__course__participations__user=user, + test_facility__course__participations__roles__permissions__permission=pperm.use_admin_interface) + + +@admin.register(AllowEvent) +class AllowEventAdmin(admin.ModelAdmin): + def get_queryset(self, request: http.HttpRequest) -> QuerySet: + assert request.user.is_authenticated + + qs = super().get_queryset(request) + return _filter_events_for_user(qs, request.user) + + list_display = [ + "event_id", "test_facility", "user_uid", "start", "end", "exam_uuid"] + list_filter = ["test_facility", "user_uid", "exam_uuid"] + + +@admin.register(DenyEvent) +class DenyEventAdmin(admin.ModelAdmin): + def get_queryset(self, request: http.HttpRequest) -> QuerySet: + assert request.user.is_authenticated + + qs = super().get_queryset(request) + return _filter_events_for_user(qs, request.user) + + list_display = [ + "event_id", "test_facility", "start", "end", "deny_uuid"] + list_filter = ["test_facility"] + + +@admin.register(MostRecentDenyEvent) +class MostRecentDenyEventAdmin(admin.ModelAdmin): + def get_queryset(self, request: http.HttpRequest) -> QuerySet: + assert request.user.is_authenticated + + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter( + event__test_facility__course__participations__user=request.user, + event__test_facility__course__participations__roles__permissions__permission=pperm.use_admin_interface) + + list_display = ["deny_uuid"] diff --git a/prairietest/migrations/0001_initial.py b/prairietest/migrations/0001_initial.py new file mode 100644 index 000000000..e30793b5b --- /dev/null +++ b/prairietest/migrations/0001_initial.py @@ -0,0 +1,93 @@ +# Generated by Django 5.1 on 2024-09-13 04:22 + +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('course', '0121_alter_flowaccessexceptionentry_permission_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Facility', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('identifier', models.CharField(db_index=True, max_length=200, unique=True, validators=[django.core.validators.RegexValidator('^(?P[a-zA-Z][a-zA-Z0-9_]*)$')])), + ('description', models.TextField(blank=True, null=True)), + ('secret', models.CharField(max_length=220)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.course')), + ], + options={ + 'verbose_name_plural': 'Facilities', + }, + ), + migrations.CreateModel( + name='DenyEvent', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('event_id', models.UUIDField()), + ('created', models.DateTimeField(verbose_name='Created time')), + ('received_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Received time')), + ('deny_uuid', models.UUIDField()), + ('start', models.DateTimeField(db_index=True, verbose_name='Start time')), + ('end', models.DateTimeField(db_index=True, verbose_name='End time')), + ('cidr_blocks', models.JSONField()), + ('test_facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='prairietest.facility')), + ], + ), + migrations.CreateModel( + name='AllowEvent', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('event_id', models.UUIDField()), + ('created', models.DateTimeField(verbose_name='Created time')), + ('received_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='Received time')), + ('user_uid', models.CharField(max_length=200)), + ('user_uin', models.CharField(max_length=200)), + ('exam_uuid', models.UUIDField()), + ('start', models.DateTimeField(verbose_name='Start time')), + ('end', models.DateTimeField(verbose_name='End time')), + ('cidr_blocks', models.JSONField()), + ('test_facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='prairietest.facility')), + ], + ), + migrations.CreateModel( + name='MostRecentDenyEvent', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('deny_uuid', models.UUIDField(unique=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='prairietest.denyevent')), + ], + ), + migrations.AddIndex( + model_name='facility', + index=models.Index(fields=['course', 'identifier'], name='prairietest_course__2525b9_idx'), + ), + migrations.AddIndex( + model_name='denyevent', + index=models.Index(fields=['deny_uuid', 'created'], name='prairietest_deny_uu_bbbbf1_idx'), + ), + migrations.AddIndex( + model_name='denyevent', + index=models.Index(fields=['deny_uuid', 'start'], name='prairietest_deny_uu_b13822_idx'), + ), + migrations.AddIndex( + model_name='denyevent', + index=models.Index(fields=['deny_uuid', 'end'], name='prairietest_deny_uu_3c9537_idx'), + ), + migrations.AddIndex( + model_name='allowevent', + index=models.Index(fields=['user_uid', 'exam_uuid', 'start'], name='prairietest_user_ui_e93827_idx'), + ), + migrations.AddIndex( + model_name='allowevent', + index=models.Index(fields=['user_uid', 'exam_uuid', 'end'], name='prairietest_user_ui_e11aa4_idx'), + ), + ] diff --git a/prairietest/migrations/0002_mostrecentdenyevent_end_alter_denyevent_end_and_more.py b/prairietest/migrations/0002_mostrecentdenyevent_end_alter_denyevent_end_and_more.py new file mode 100644 index 000000000..bb5509910 --- /dev/null +++ b/prairietest/migrations/0002_mostrecentdenyevent_end_alter_denyevent_end_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1 on 2024-09-13 15:39 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('prairietest', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='mostrecentdenyevent', + name='end', + field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='End time'), + preserve_default=False, + ), + migrations.AlterField( + model_name='denyevent', + name='end', + field=models.DateTimeField(verbose_name='End time'), + ), + migrations.AlterField( + model_name='denyevent', + name='start', + field=models.DateTimeField(verbose_name='Start time'), + ), + migrations.AddIndex( + model_name='mostrecentdenyevent', + index=models.Index(fields=['end'], name='prairietest_end_31b76d_idx'), + ), + ] diff --git a/prairietest/migrations/__init__.py b/prairietest/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/prairietest/models.py b/prairietest/models.py new file mode 100644 index 000000000..3bb2e2490 --- /dev/null +++ b/prairietest/models.py @@ -0,0 +1,133 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from django.core.validators import RegexValidator +from django.db import models +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ + +from course.models import Course + + +TEST_FACILITY_ID_REGEX = "(?P[a-zA-Z][a-zA-Z0-9_]*)" + + +class Facility(models.Model): + id = models.BigAutoField(primary_key=True) + + course = models.ForeignKey(Course, on_delete=models.CASCADE) + identifier = models.CharField(max_length=200, unique=True, + validators=[RegexValidator(f"^{TEST_FACILITY_ID_REGEX}$")], + db_index=True) + + description = models.TextField(blank=True, null=True) + secret = models.CharField(max_length=220) + + class Meta: + indexes = [ + models.Index(fields=["course", "identifier"]), + ] + verbose_name_plural = _("Facilities") + + def __str__(self) -> str: + return f"PrairieTest Facility '{self.identifier}' in {self.course.identifier}" + + +class Event(models.Model): + id = models.BigAutoField(primary_key=True) + + test_facility = models.ForeignKey(Facility, on_delete=models.CASCADE) + event_id = models.UUIDField() + created = models.DateTimeField(verbose_name=_("Created time")) + received_time = models.DateTimeField(default=now, + verbose_name=_("Received time")) + + class Meta: + abstract = True + indexes = [ + models.Index(fields=["event_id"]), + models.Index(fields=["created"]), + ] + + +class AllowEvent(Event): + user_uid = models.CharField(max_length=200) + user_uin = models.CharField(max_length=200) + exam_uuid = models.UUIDField() + start = models.DateTimeField(verbose_name=_("Start time")) + end = models.DateTimeField(verbose_name=_("End time")) + cidr_blocks = models.JSONField() + + def __str__(self) -> str: + return f"PrairieTest allow event {self.event_id} for {self.user_uid}" + + class Meta: + indexes = [ + models.Index(fields=["user_uid", "exam_uuid", "start"]), + models.Index(fields=["user_uid", "exam_uuid", "end"]), + ] + + +class DenyEvent(Event): + deny_uuid = models.UUIDField() + start = models.DateTimeField(verbose_name=_("Start time")) + end = models.DateTimeField(verbose_name=_("End time")) + cidr_blocks = models.JSONField() + + def __str__(self) -> str: + return f"PrairieTest deny event {self.event_id} with {self.deny_uuid}" + + class Meta: + indexes = [ + models.Index(fields=["deny_uuid", "created"]), + models.Index(fields=["deny_uuid", "start"]), + models.Index(fields=["deny_uuid", "end"]), + ] + + +class MostRecentDenyEvent(models.Model): + id = models.BigAutoField(primary_key=True) + deny_uuid = models.UUIDField(unique=True) + end = models.DateTimeField(verbose_name=_("End time")) + event = models.ForeignKey(DenyEvent, on_delete=models.CASCADE) + + class Meta: + indexes = [ + models.Index(fields=["end"]), + ] + + def __str__(self) -> str: + return f"PrairieTest current deny event with {self.deny_uuid}" + + +def save_deny_event(devt: DenyEvent) -> None: + devt.save() + + MostRecentDenyEvent.objects.update_or_create( + deny_uuid=devt.deny_uuid, + defaults={ + "end": devt.end, + "event": devt + }) diff --git a/prairietest/urls.py b/prairietest/urls.py new file mode 100644 index 000000000..14d19378b --- /dev/null +++ b/prairietest/urls.py @@ -0,0 +1,41 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from django.urls import re_path + +from course.constants import COURSE_ID_REGEX +from prairietest.models import TEST_FACILITY_ID_REGEX +from prairietest.views import webhook + + +urlpatterns = [ + re_path( + r"^course" + "/" + COURSE_ID_REGEX + + "/webhook" + "/" + TEST_FACILITY_ID_REGEX + + "/", + webhook) +] diff --git a/prairietest/utils.py b/prairietest/utils.py new file mode 100644 index 000000000..273c96db0 --- /dev/null +++ b/prairietest/utils.py @@ -0,0 +1,117 @@ +from __future__ import annotations + + +__copyright__ = "Copyright (C) 2024 University of Illinois Board of Trustees" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +""" + +from collections.abc import Collection, Mapping, Sequence +from datetime import datetime +from functools import lru_cache +from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network, ip_network + +from django.db.models import Q + +from course.models import Course +from prairietest.models import AllowEvent, DenyEvent, MostRecentDenyEvent + + +def has_access_to_exam( + course: Course, + user_uid: str, + exam_uuid: str, + now: datetime, + ip_address: IPv4Address | IPv6Address + ) -> None | AllowEvent: + # NOTE: This assumes that there aren't two exams with the same UUID + # across multiple testing centers. Otherwise, allow events could be missed. + allow_events = list(AllowEvent.objects.filter( + test_facility__course=course, + user_uid=user_uid, + exam_uuid=exam_uuid + ).order_by("-created")[:1]) + + if not allow_events: + return None + + allow_event, = allow_events + + if now < allow_event.start or allow_event.end < now: + return None + + if any( + ip_address in ip_network(cidr_block) + for cidr_block in allow_event.cidr_blocks): + return allow_event + else: + return None + + +def denials_at( + now: datetime, + course: Course | None, + ) -> Sequence[DenyEvent]: + qs = MostRecentDenyEvent.objects.all() + if course is not None: + qs = qs.filter(event__test_facility__course=course) + + return [ + mrde.event + for mrde in qs.filter( + Q(end__ge=now) | Q(event__start__le=now) + ).prefetch_related( + "event", + "event__test_facility", + "event__test_facility__course") + ] + + +@lru_cache(10) +def _denials_cache_backend( + now_bucket: int, + course_id: int, + ) -> Mapping[tuple[int, str], Collection[IPv6Network | IPv4Network]]: + deny_events = denials_at( + datetime.fromtimestamp(now_bucket), + Course.objects.get(id=course_id), + ) + result: dict[tuple[int, str], set[IPv6Network | IPv4Network]] = {} + for devt in deny_events: + result.setdefault( + (devt.test_facility.course.id, devt.test_facility.identifier), + set() + ).update(ip_network(cidr_block) for cidr_block in devt.cidr_blocks) + + return result + + +def denied_ip_networks_cached_at( + now: datetime, + course: Course | None, + ) -> Mapping[tuple[int, str], Collection[IPv6Network | IPv4Network]]: + """ + :returns: a mapping from (course_id, test_facility_id) to a collection of + networks. + """ + return _denials_cache_backend( + int(now.timestamp() // 60) * 60, + course.id if course else None, + ) diff --git a/prairietest/views.py b/prairietest/views.py new file mode 100644 index 000000000..9d8ca4e06 --- /dev/null +++ b/prairietest/views.py @@ -0,0 +1,248 @@ +from __future__ import annotations + + +__copyright__ = """ +Copyright (C) 2024 University of Illinois Board of Trustees +Copyright (C) 2023 (?) PrairieTest Developers +""" + +__license__ = """ +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +The license for the code marked as copied from the PrairieTest docs +is unknown. +""" + +import hashlib +import hmac +import json +import time +from collections.abc import Mapping +from datetime import datetime + +from django import http +from django.core.exceptions import BadRequest, SuspiciousOperation +from django.shortcuts import get_object_or_404 +from django.views.decorators.csrf import csrf_exempt + +from prairietest.models import ( + AllowEvent, + DenyEvent, + Facility, + save_deny_event, +) + + +# Create your views here. + + +# {{{ begin code copied from PrairieTest docs + +# Source: +# https://us.prairietest.com/pt/docs/api/exam-access#testing-the-lms-integration +# Retrieved Sep 10, 2024 +# included with light modifications (type annotation, linter conformance) + +def check_signature( + headers: Mapping[str, str], + body: bytes, + secret: str, + *, now_timestamp: float | None = None, + ) -> tuple[bool, str]: + """Check the signature of a webhook event. + + Arguments: + headers -- a dictionary of HTTP headers from the webhook request + body -- the body of the webhook request (as a bytes object) + secret -- the shared secret string + + Returns: + A tuple (signature_ok, message) where signature_ok is True if the signature + is valid, False otherwise, + and message is a string describing the reason for the failure (if any). + """ + if "PrairieTest-Signature" not in headers: + return False, "Missing PrairieTest-Signature header" + prairietest_signature = headers["PrairieTest-Signature"] + + # get the timestamp + timestamp = None + for block in prairietest_signature.split(","): + if block.startswith("t="): + timestamp = block[2:] + break + if timestamp is None: + return False, "Missing timestamp in PrairieTest-Signature" + + # check the timestamp + try: + timestamp_val = int(timestamp) + except ValueError: + return False, "Invalid timestamp in PrairieTest-Signature" + if now_timestamp is None: + now_timestamp = time.time() + if abs(timestamp_val - now_timestamp) > 3000: + return False, "Timestamp in PrairieTest-Signature is too old or too new" + + # get the signature + signature = None + for block in prairietest_signature.split(","): + if block.startswith("v1="): + signature = block[3:] + break + if signature is None: + return False, "Missing v1 signature in PrairieTest-Signature" + + # check the signature + signed_payload = bytes(timestamp, "ascii") + b"." + body + expected_signature = hmac.new( + secret.encode("utf-8"), signed_payload, hashlib.sha256).digest().hex() + if signature != expected_signature: + return False, "Incorrect v1 signature in PrairieTest-Signature" + + # everything checks out + return True, "" + +# }}} + + +# {{{ test signature checking against real-life PT data + +_TEST_SIG_BODY = b"""{ + "id": "38f7057a-2e3a-477d-b325-addc2da1f07d", + "api_version": "2023-07-18", + "created": "2024-09-13T17:06:05.175Z", + "type": "allow_access", + "data": { + "end": "2024-09-13T17:15:41.709Z", + "start": "2024-09-13T17:00:41.709Z", + "user_uid": "andreask@illinois.edu", + "user_uin": "676896347", + "exam_uuid": "d1936a77-341d-49ab-ac9b-1291afaaf87a", + "cidr_blocks": [ + "0.0.0.0/0" + ] + } +}""" + + +def test_check_signature(): + valid, msg = check_signature( + {"PrairieTest-Signature": + "t=1726247206," + "v1=149ff0fbabc55558d081cbb64b61ef2248ba71e1d912bab7a19684f2b30afc31" + }, + _TEST_SIG_BODY, + secret="xADEAxO8RjRysTu9YN8olCk5ZcpVQwvoTpCNs4jD2sAzU4YR", + now_timestamp=1726247206 + ) + assert valid + assert not msg + +# }}} + + +@csrf_exempt +def webhook( + request: http.HttpRequest, + course_identifier: str, + test_facility_id: str, + ) -> http.HttpResponse: + body = request.body + + test_facility = get_object_or_404( + Facility, + course__identifier=course_identifier, + identifier=test_facility_id, + ) + + sig_valid, msg = check_signature(request.headers, body, test_facility.secret) + if not sig_valid: + raise SuspiciousOperation(f"Invalid PrairieTest signature: {msg}") + + event = json.loads(body) + api_ver: str = event["api_version"] + if api_ver != "2023-07-18": + raise BadRequest(f"Unknown PrairieTest API version: {api_ver}") + + event_id: str = event["id"] + if ( + AllowEvent.objects.filter( + test_facility=test_facility, event_id=event_id).exists() + or DenyEvent.objects.filter( + test_facility=test_facility, event_id=event_id).exists() + ): + return http.HttpResponse(b"OK", content_type="text/plain", status=200) + + evt_type: str = event["type"] + created = datetime.fromisoformat(event["created"]) + data = event["data"] + + del event + + if evt_type == "allow_access": + user_uid: str = data["user_uid"] + exam_uuid: str = data["exam_uuid"] + + has_newer_allows = AllowEvent.objects.filter( + test_facility=test_facility, + user_uid=user_uid, + exam_uuid=exam_uuid, + created__ge=created, + ).exists() + + if not has_newer_allows: + allow_evt = AllowEvent( + test_facility=test_facility, + event_id=event_id, + created=created, + user_uid=user_uid, + user_uin=data["user_uin"], + exam_uuid=exam_uuid, + start=datetime.fromisoformat(data["start"]), + end=datetime.fromisoformat(data["end"]), + cidr_blocks=data["cidr_blocks"], + ) + allow_evt.save() + + return http.HttpResponse(b"OK", content_type="text/plain", status=200) + + elif evt_type == "deny_access": + deny_uuid = data["deny_uuid"] + has_newer_denies = DenyEvent.objects.filter( + test_facility=test_facility, + deny_uuid=deny_uuid, + created__ge=created, + ).exists() + + if not has_newer_denies: + deny_evt = DenyEvent( + test_facility=test_facility, + event_id=event_id, + created=created, + deny_uuid=deny_uuid, + start=datetime.fromisoformat(data["start"]), + end=datetime.fromisoformat(data["end"]), + cidr_blocks=data["cidr_blocks"], + ) + save_deny_event(deny_evt) + + return http.HttpResponse(b"OK", content_type="text/plain", status=200) + else: + raise BadRequest(f"Unknown PrairieTest event type: {evt_type}") diff --git a/relate/settings.py b/relate/settings.py index 05f8faeaf..d45d7d1a3 100644 --- a/relate/settings.py +++ b/relate/settings.py @@ -64,6 +64,7 @@ "accounts", "course", + "prairietest", ) if local_settings.get("RELATE_SIGN_IN_BY_SAML2_ENABLED"): diff --git a/relate/urls.py b/relate/urls.py index 7e7c8052a..4d51c2b58 100644 --- a/relate/urls.py +++ b/relate/urls.py @@ -546,6 +546,8 @@ course.exam.access_exam, name="relate-access_exam"), + path("prairietest/", include("prairietest.urls")), + # }}} path(r"select2/", include("django_select2.urls")),