API documentation

LMS exam access

LMS integration for exam access

This page describes how to configure an LMS (Learning Management System) to serve exams to students in a PrairieTest testing center. Make sure you are familiar with the security model for PrairieTest exams described in section Security of testing centers before reading about LMS integration.

For the LMS to play its role in this cooperative security model it needs to know which IPs are "inside the testing center" and which students inside the testing center are allowed to access which exams. This information is provided by to the LMS by PrairieTest via a webhook API. Because all of the information is dynamic (which students are taking which exams at which times, and which IPs are inside the testing center) the LMS must listen for updates to this information and maintain two lists:

  1. A deny-list of IP addresses that are inside the testing center and should be blocked from accessing non-exam content.
  2. An allow-list of student/exam pairs that are inside the testing center and should be allowed to access the specified exam from the specified IP addresses.

Both the deny-list and allow-list entries are time limited and only apply during a specified time range. This is to allow dynamic updates to the lists as students start and end exams and the testing center IP addresses change.

Note that it's important for the LMS to be actively blocking access to non-exam content from students inside of a testing center, even if there are no students taking exams on the LMS at that time. This is because students in other courses might be taking exams and they shouldn't be able to access non-exam content on the LMS, especially if they can use the LMS to infiltrate or exfiltrate data from the testing center. That is, whenever the LMS has been granted access via the testing center firewall, it must be listening for PrairieTest events and blocking access as appropriate.

Configuring the LMS and testing center

  1. The LMS administrator should configure a webhook to receive events from PrairieTest. This webhook is configured in the "Course Settings" page. The associated shared secret key is also shown on this page.
  2. The LMS administrator should communicate with the testing center administrator to open up the testing center firewall to allow all traffic from the testing center to the LMS. While the firewall is open in this way the LMS must be actively listening for PrairieTest events and blocking access to non-exam content from the testing center, so the firewall should only be opened after the webhook is configured and the LMS is receiving events.

Webhook events

PrairieTest sends webhook events to the LMS as POST requests with a JSON payload. See below for details about the format of the headers and payload. The LMS should respond with an HTTP status code of 200 if the event was successfully processed, or an error code if the event was not understood or could not be processed. PrairieTest will retry events that return a 4xx or 5xx status code or which otherwise fail to be delivered. Retries are designed to accommodate short-term network or LMS server failures, such as a server restart.

Signature checking

All webhook events are signed with a shared secret key and the signature is sent in the PrairieTest-Signature header. The LMS should check the signature of each event to ensure that it was sent by PrairieTest and not by a malicious third party.

The format of the signature is a Unix timestamp followed by one or more signature blocks, such as t=1690577244,v1=ca6470ea55b5b6d3020e7c2ac7db638075768247c87e12f58ede495c77978dc8. Each signature block is prefixed by a scheme such as v1 which determines the format of the signature itself. At this time the only valid scheme is v1, which uses a SHA-256 HMAC signature encoded as a hex string, and all other schemes should be ignored. The timestamp and signature are generated every time an event delivery attempt is made, so a single event will have a new timestamp and signature if the delivery is retried.

To verify the signature the steps are:

  1. Split the signature into blocks using the , character as the delimiter.
  2. Find the timestamp block with the t scheme and extract the timestamp from it. If this timestamp differs from the current timestamp by some tolerance (recommended 5 minutes) then the event is invalid. The timestamp is the number of seconds since midnight, January 1, 1970 UTC.
  3. Find the signature block with the scheme v1 and extract the signature from it.
  4. Form the signed_payload by concatenating the timestamp string, the '.' character, and the body of the request.
  5. Compute the SHA-256 HMAC of the signed_payload using the shared secret key as the HMAC key. Encode the result as a hex string. If this matches the signature then the event is valid.

Example Python code to check the signature is shown below in the code example section (the check_signature() function).

API versions

Webhook events contain an api_version property. At this time the only valid value for this property is 2023-07-18. If the LMS receives an event with an api_version that it does not understand it should respond with a 400 status code.

Event IDs and uniqueness

Every event has a unique id. If the LMS receives an event with an id that it has already seen, it should discard the event and return a 200 status code. This allows PrairieTest to retry sending events if there is a network failure, and it allows the LMS to restart without losing events.

It is recommended that the LMS keep a record of all events it has seen, both to detect duplicates and to allow forensic analysis of events in case of a security breach.

Event: allow_access

This event says that a particular student should be given access to a specified exam within a given time range from a given set of IP addresses. Both the time range and the IP addresses might change during the exam, so the LMS should listen for updates to this event and update its allow-list accordingly. The pair (user_uid, exam_uuid) serves as a unique key for this allow-list entry.

An example event is shown below:

{
  "id": "4f021523-b7e7-4489-8fda-d8540ec80286",
  "api_version": "2023-07-18",
  "created": "2023-07-18T16:20:47Z",
  "type": "allow_access",
  "data": {
    "user_uid": "student@example.com",
    "user_uin": "123456789",
    "exam_uuid": "f76d939a-08a9-455b-b12d-72e48577e112",
    "start": "2020-01-01T12:00:00Z",
    "end": "2020-01-01T12:50:00Z",
    "cidr_blocks": ["130.126.247.14/32", "192.17.180.128/25"]
  }
}

The data properties for an allow_access event are:

  • user_uid: The student's UID string, typically something like student@example.com. The format of this UID is dependent on the university authentication system used for the course. This UID is not guaranteed to be a deliverable email address.
  • user_uin: The student's UIN string, typically something like 123456789. The format of the UIN is dependent on the university authentication system used for the course.
  • exam_uuid: The UUID string specifying the exam. This corresponds to the UUID on the "Exam Settings" page in PrairieTest.
  • start: The start time of the exam, in ISO 8601 format. This starts the interval when the student is allowed to access the exam.
  • end: The end time of the exam, in ISO 8601 format. This ends the interval when the student is allowed to access the exam.
  • cidr_blocks: A list of CIDR blocks (each IPv4 or IPv6) specifying the IP addresses that the student is allowed to access the exam from. Note that a CIDR block of '0.0.0.0/0' will match all IPv4 addresses and thus allow access from any address. Similarly, if cidr_blocks is an empty list then no IP addresses will match and thus no access should be granted.

Event: deny_access

This event says that students should be blocked from accessing non-exam content from the specified list of IP addresses within a given time range. Both the time range and the IP addresses might change during the exam, so the LMS should listen for updates to this event and update its deny-list accordingly. The deny_uuid serves as a unique key for this deny-list entry.

An example event is shown below:

{
  "id": "4f021523-b7e7-4489-8fda-d8540ec80286",
  "api_version": "2023-07-18",
  "created": "2023-07-18T16:20:47Z",
  "type": "deny_access",
  "data": {
    "deny_uuid": "41e074c8-2d74-11ee-a1b3-2a59eef39e4e",
    "start": "2020-01-01T12:00:00Z",
    "end": "2020-01-01T12:50:00Z",
    "cidr_blocks": ["130.126.247.14/32", "192.17.180.128/25"]
  }
}

The data properties for a deny_access event are:

  • deny_uuid: The UUID string specifying the deny event. This is used as a unique key for this entry in the deny-list.
  • start: The start of the interval during which the specified IP addresses should be blocked from accessing non-exam content, in ISO 8601 format.
  • end: The end of the interval during which the specified IP addresses should be blocked from accessing non-exam content, in ISO 8601 format.
  • cidr_blocks: A list of CIDR blocks (each IPv4 or IPv6) specifying the IP addresses that should be blocked from accessing non-exam content. Note that a CIDR block of '0.0.0.0/0' will match all IPv4 addresses and thus deny access from any address. Similarly, if cidr_blocks is an empty list then no IP addresses will match and thus no access should be denied.

Maintaining the allow-list and deny-list

The LMS should listen for events from PrairieTest and maintain two dictionaries, one for allow events and one for deny events. The dictionaries should be keyed by (student_uid, exam_uuid) for allow events and by deny_uuid for deny events. Whenever a new event is processed it should be added to the appropriate dictionary. If an event with the same key already exists, the old event should be replaced with the new one if the created timestamp is more recent. This allows PrairieTest to send updates to existing events, such as extending the end time for a student's exam or altering the CIDR blocks that are allowed to access the exam.

Testing the LMS integration

To generate test events for the LMS to process, an exam can be created and started within the course that has the webhook configured. The process for this is:

  1. Configure a webhook in the "Course Settings" page.
  2. Invite yourself as a student to the course on the "Students" page. Then return to the homepage to accept the invite.
  3. Create a new exam in the course on the "Exams" page and edit the exam visibility to make it visible to students.
  4. Create a new session on the exam "Sessions" page, with type "In-person" and the session start time set to the current time plus 10 minutes.
  5. On the exam "Reservations" page, make a reservation for yourself in the session you just created (click on the pencil icon in the "Session" column).
  6. Go into the session and click "Start exam". This should send a deny_access event to the LMS. Clicking "Block students from starting exam" should send another deny_access event.
  7. With the exam started, click "Check in" next to your reservation.
  8. Click on "PrairieTest" in the upper left to go back to the homepage.
  9. Click on the exam in the "Current exam" card at the top of the page.
  10. Click on "Start exam". This will send an allow_access event to the LMS.
  11. Go back to the session page. Editing the "End" time of your exam or clicking "End exam"/"Re-open" will send more allow_access events to the LMS.
  12. Use the "Webhook Events" tab within an exam to view the webhook events and check that they are being received by the LMS. Note that only allow_access events appear on this page, not deny_access events.

Viewing webhook events and delivery status

With a course exam, the "Webhook Events" tab shows the list of all webhook events that have been sent for the exam. Clicking on an event shows the event details, including the history of delivery attempts and the response from the LMS to each one. This can be used to debug problems with the LMS integration.

The "Webhook Events" tab for an exam only shows allow_access events associated with the exam, not any deny_access events.

Example Python code to check event signatures

The following code checks the signature of an event.

import time, hmac, hashlib

def check_signature(headers, body, secret):
    """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 abs(timestamp_val - time.time()) > 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, ''

Example Python code to process incoming events

Python code to process events might look like the following. This function is intended to be part of the same file as the code above. Note that this example is not suitable for production. A production implementation must: (1) persist the allow-list and deny-list to permanent storage so that the server can recover from a restart; (2) validate the data format of the incoming event; and (3) use an HTTPS server, rather than the insecure HTTP server in this example.

import json, ipaddress, datetime
from http.server import BaseHTTPRequestHandler, HTTPServer

SERVER_PORT = 8081
PRAIRIETEST_LMS_SECRET = 'nctUonVg9grN01Xp9ALSMXIHRCVnQRC4EH8Y1Ck5oxSiY2D4'
RECEIVED_EVENTS = {}
ALLOW_EVENTS = {}
DENY_EVENTS = {}

class RequestHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers['Content-Length'])
        body = self.rfile.read(content_length)

        # Check signature
        signature_ok, message = check_signature(self.headers, body, PRAIRIETEST_LMS_SECRET)
        if not signature_ok:
            self.send_error(400, f'Invalid signature: {message}')
            return

        # Parse event
        event = json.loads(body)

        # Check API version
        if event['api_version'] != '2023-07-18':
            self.send_error(400, f'Unknown API version: {event["api_version"]}')
            return

        # Ignore duplicate events
        id = event['id']
        if id in RECEIVED_EVENTS:
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(b'OK')
            return

        if event['type'] == 'allow_access':
            RECEIVED_EVENTS[id] = event
            key = (event['data']['user_uid'], event['data']['exam_uuid'])
            if key not in ALLOW_EVENTS or ALLOW_EVENTS[key]['created'] < event['created']:
                ALLOW_EVENTS[key] = event
                print(f'New allow-rule: {event["data"]}')
            else:
                print(f'Allow-rule not added for {event["data"]["user_uid"]}')
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(b'OK')
        elif event['type'] == 'deny_access':
            RECEIVED_EVENTS[id] = event
            key = event['data']['deny_uuid']
            if key not in DENY_EVENTS or DENY_EVENTS[key]['created'] < event['created']:
                DENY_EVENTS[event['data']['deny_uuid']] = event
                print(f'New deny-rule: {event["data"]}')
            else:
                print(f'Deny-rule not added for {event["data"]["user_uid"]}')
            self.send_response(200)
            self.send_header('Content-Type', 'text/plain')
            self.end_headers()
            self.wfile.write(b'OK')
        else:
            self.send_error(400, f'Unknown event type: {event["type"]}')

if __name__ == '__main__':
    print(f'Listening on port {SERVER_PORT}...')
    httpd = HTTPServer(('127.0.0.1', SERVER_PORT), RequestHandler)
    httpd.serve_forever()

Example Python code to check exam access

The following code checks whether a student should be allowed to access a given exam from a given IP address. This function is intended to be part of the same file as the code above. This function should be called whenever a student attempts to access any component of an exam on the LMS. Note that the return value of this function may change dynamically during an exam as new events are received, so the return value should not be cached.

def check_exam_access(user_uid, exam_uuid, ip_address):
    """Check if a user should be allowed to access an exam from a given IP address.

    Arguments:
    user_uid -- a student's UID string, like "studentid@example.com"
    exam_uuid -- a UUID string specifying the exam, like "f76d939a-08a9-455b-b12d-72e48577e112"
    ip_address -- an IP address string, like "192.17.180.182"

    Returns:
    True if the user should be allowed to access the exam from the given IP address, False otherwise.
    """
    if (user_uid, exam_uuid) not in ALLOW_EVENTS:
        return False
    event = ALLOW_EVENTS[(user_uid, exam_uuid)]
    start = datetime.fromisoformat(event['data']['start'])
    end = datetime.fromisoformat(event['data']['end'])
    if datetime.now() < start or datetime.now() > end:
        return False
    cidr_blocks = event['data']['cidr_blocks']
    for cidr_block in cidr_blocks:
        if ipaddress.ip_address(ip_address) in ipaddress.ip_network('cidr_block'):
            return True
    return False

Example Python code to check access to non-exam content

The following code checks whether a student should be allowed to access non-exam content from a given IP address. This function is intended to be part of the same file as the code above. This function should be called whenever a student attempts to access any non-exam content on the LMS. Note that the return value of this function may change dynamically during an exam as new events are received, so the return value should not be cached.

def check_non_exam_access(ip_address):
    """Check if a user should be allowed to access non-exam content from a given IP address.

    Arguments:
    ip_address -- an IP address string, like "192.17.180.192"

    Returns:
    True if the user should be allowed to access non-exam content from the given IP address, False otherwise.
    """
    for event in DENY_EVENTS.values():
        start = datetime.fromisoformat(event['data']['start'])
        end = datetime.fromisoformat(event['data']['end'])
        if datetime.now() < start or datetime.now() > end:
            continue
        cidr_blocks = event['data']['cidr_blocks']
        for cidr_block in cidr_blocks:
            if ipaddress.ip_address(ip_address) in ipaddress.ip_network(cidr_block):
                return False
    return True