feat(roster): gate joins on registered student-ID list

Adds an optional roster.json (set of allowed student IDs) loaded at
startup. add_participant() raises StudentIdNotInRoster when the gate is
on and the supplied id is not present; route returns 403 with a clear
message and logs a roster_reject audit event. Names are NOT checked
against the roster: the join form asks for a current name as a soft
deterrent, but the only hard check is the id.

Includes a deploy/build_roster.py helper that turns class_register
attendance.xlsx into roster.json. Bootstrap env file now exports
QUIZ_ROSTER_PATH; missing file disables the gate (legacy behaviour).

Also drops the user-facing "The cookie is per-device." line from the
join card — students don't need to know the implementation; replaced
with "Enter your registered student ID and your current full name."
This commit is contained in:
ameer
2026-05-05 22:02:03 +08:00
parent 19603abc58
commit 74c1745559
12 changed files with 289 additions and 7 deletions

View File

@@ -17,6 +17,7 @@ from fastapi import WebSocket, WebSocketDisconnect
from app.config import Settings
from app.db import connect
from app.roster import is_allowed as roster_allows
from app.pool import (
get_question,
parse_pool_json,
@@ -62,9 +63,19 @@ class DuplicateStudentId(Exception):
claimed by another active participant (first-claim-wins anti-hijack)."""
class StudentIdNotInRoster(Exception):
"""Raised when the roster gate is enabled and the supplied student_id
is not present in the roster file. The join route surfaces this as a
403 with a clear message; nothing is written to the participants
table."""
class RoomManager:
def __init__(self, settings: Settings):
self.settings = settings
# Allowed-student-ids gate, populated from the roster file at
# startup by main.py. None disables the gate.
self.roster: set[str] | None = None
self.student_clients: dict[str, dict[WebSocket, dict[str, Any]]] = defaultdict(dict)
self.instructor_clients: dict[str, set[WebSocket]] = defaultdict(set)
# Projector clients are public read-only; no per-client identity.
@@ -218,7 +229,13 @@ class RoomManager:
hijack another student's id, or a legit student returning after
clearing cookies). The route handler turns the exception into a
409 + records a `duplicate_join` audit event so the instructor
can see the attempt on the live presence panel."""
can see the attempt on the live presence panel.
Also raises StudentIdNotInRoster if a roster file is loaded and
this id isn't in it. That gate runs before the DB insert so a
roster-rejected attempt never appears in the participants table."""
if not roster_allows(self.roster, student_id):
raise StudentIdNotInRoster(student_id)
async with connect(self.settings.db_path) as db:
try:
await db.execute(