From 74c17455596b72abe9af8546280aa601e68699fc Mon Sep 17 00:00:00 2001 From: ameer Date: Tue, 5 May 2026 22:02:03 +0800 Subject: [PATCH] feat(roster): gate joins on registered student-ID list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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." --- .gitignore | 4 ++ app/config.py | 2 + app/db.py | 3 ++ app/main.py | 2 + app/room.py | 19 +++++++- app/roster.py | 62 +++++++++++++++++++++++++ app/routes_student.py | 23 +++++++++- deploy/bootstrap.sh | 1 + deploy/build_roster.py | 70 ++++++++++++++++++++++++++++ static/quiz.js | 9 ++-- tests/conftest.py | 4 ++ tests/test_roster_gate.py | 97 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 289 insertions(+), 7 deletions(-) create mode 100644 app/roster.py create mode 100644 deploy/build_roster.py create mode 100644 tests/test_roster_gate.py diff --git a/.gitignore b/.gitignore index b70856e..d7ed264 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,10 @@ examples/*_pool.json # Operators populate it; it stays out of version control. /pool.json +# Class roster (real student IDs and names) lives at the repo root on +# the operator's machine and on the server; never in version control. +/roster.json + # Codex build leftovers .codex_done codex_last_message.md diff --git a/app/config.py b/app/config.py index 83d4dec..75a914d 100644 --- a/app/config.py +++ b/app/config.py @@ -29,6 +29,7 @@ class Settings: public_url: str = "https://quiz.ahkhan.me" log_level: str = "INFO" pool_path: str = "./pool.json" + roster_path: str = "./roster.json" default_session_id: str = "main" @classmethod @@ -43,6 +44,7 @@ class Settings: public_url=os.getenv("QUIZ_PUBLIC_URL", "https://quiz.ahkhan.me").rstrip("/"), log_level=os.getenv("QUIZ_LOG_LEVEL", "INFO"), pool_path=os.getenv("QUIZ_POOL_PATH", "./pool.json"), + roster_path=os.getenv("QUIZ_ROSTER_PATH", "./roster.json"), default_session_id=os.getenv("QUIZ_SESSION_ID", "main"), ) diff --git a/app/db.py b/app/db.py index f142f15..0e13508 100644 --- a/app/db.py +++ b/app/db.py @@ -68,6 +68,9 @@ CREATE INDEX IF NOT EXISTS idx_participants_sid ON participants(sid); -- 'duplicate_join' — second-claim attempt on an already-claimed -- student_id; student_id field holds the -- ATTEMPTED id; detail JSON carries IP/UA/name +-- 'roster_reject' — join attempted with a student_id that is +-- not on the registered class list; same +-- payload shape as duplicate_join CREATE TABLE IF NOT EXISTS student_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, sid TEXT NOT NULL, diff --git a/app/main.py b/app/main.py index f153594..fe1069e 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,7 @@ from app.config import Settings from app.db import init_db from app.pool import PoolValidationError, load_pool_from_file from app.room import RoomManager +from app.roster import load_roster from app.routes_admin import router as admin_router from app.routes_student import router as student_router @@ -24,6 +25,7 @@ def create_app(settings: Settings | None = None) -> FastAPI: @asynccontextmanager async def lifespan(_app: FastAPI): await init_db(settings.db_path) + rooms.roster = load_roster(settings.roster_path) try: pool = load_pool_from_file(settings.pool_path) except PoolValidationError as exc: diff --git a/app/room.py b/app/room.py index 139810c..f89c29c 100644 --- a/app/room.py +++ b/app/room.py @@ -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( diff --git a/app/roster.py b/app/roster.py new file mode 100644 index 0000000..d049f83 --- /dev/null +++ b/app/roster.py @@ -0,0 +1,62 @@ +"""Roster gate for the join flow. + +When a roster file is present, only student IDs listed there can join. +The check is case-insensitive and ignores surrounding whitespace, so a +trailing space or a lowercased prefix does not lock a legit student +out. Names are NOT checked against the roster — the join form asks for +a name purely so the instructor's presence panel and CSV export read +naturally; the roster acts as the access gate. + +Roster file format is permissive: either a JSON array of IDs, or an +object with a `student_ids` key (list of strings) or a `students` key +(list of objects with an `id` field). Missing roster file means no gate +is applied (legacy behaviour). +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +log = logging.getLogger("quiz.roster") + + +def _normalize(student_id: str) -> str: + return student_id.strip().upper() + + +def load_roster(path: str | Path) -> set[str] | None: + """Return the set of normalized allowed student IDs, or None if no + roster file exists at `path` (gate disabled).""" + p = Path(path) + if not p.exists(): + log.info("No roster file at %s — roster gate DISABLED.", p) + return None + try: + raw = json.loads(p.read_text()) + except (json.JSONDecodeError, OSError) as exc: + log.error("Roster file %s could not be parsed: %s", p, exc) + return None + ids: list[str] = [] + if isinstance(raw, list): + ids = [str(x) for x in raw] + elif isinstance(raw, dict): + if isinstance(raw.get("student_ids"), list): + ids = [str(x) for x in raw["student_ids"]] + elif isinstance(raw.get("students"), list): + ids = [str(s.get("id", "")) for s in raw["students"] if isinstance(s, dict)] + cleaned = {_normalize(i) for i in ids if i and i.strip()} + if not cleaned: + log.warning("Roster file %s parsed empty — gate DISABLED.", p) + return None + log.info("Roster gate ENABLED with %d allowed student IDs from %s.", len(cleaned), p) + return cleaned + + +def is_allowed(roster: set[str] | None, student_id: str) -> bool: + """True if `student_id` passes the roster gate. If `roster` is None, + no gate is applied and every well-formed ID is allowed.""" + if roster is None: + return True + return _normalize(student_id) in roster diff --git a/app/routes_student.py b/app/routes_student.py index 1b143a0..76d7dfa 100644 --- a/app/routes_student.py +++ b/app/routes_student.py @@ -12,7 +12,7 @@ from app import auth from app.config import Settings from app.models import JoinRequest, StudentEventRequest from app.rate_limit import client_ip -from app.room import DuplicateStudentId, RoomManager +from app.room import DuplicateStudentId, RoomManager, StudentIdNotInRoster def router(settings: Settings, rooms: RoomManager) -> APIRouter: @@ -63,6 +63,27 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter: cookie_id = str(uuid4()) try: await rooms.add_participant(sid, student_id, name, cookie_id) + except StudentIdNotInRoster: + # Roster gate: id is not on the registered class list. Log a + # `roster_reject` event with attempted ip/ua/name so the + # instructor sees casual fishing attempts in the audit log. + await rooms.log_event( + sid, + student_id=student_id, + kind="roster_reject", + detail={ + "attempted_name": name, + "ip": client_ip(request), + "ua": (request.headers.get("user-agent") or "")[:200], + }, + ) + raise HTTPException( + status_code=403, + detail=( + "This student ID is not on the class list. " + "Check the digits, then ask the instructor if it still fails." + ), + ) from None except DuplicateStudentId: # First-claim-wins anti-hijack: a participant row already # exists for this student_id. Could be a hijack attempt diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 3dd7ecc..df520e6 100755 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -105,6 +105,7 @@ if [ ! -f "$ENV_FILE" ]; then cat > "$ENV_FILE" < [-o roster.json] + +The XLSX is expected to have a header row, then one row per student. +Column 1 = student ID, column 2 = name (optional). +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + + +def build(xlsx_path: Path, out_path: Path) -> int: + try: + import openpyxl + except ImportError: + print("openpyxl is required: pip install openpyxl", file=sys.stderr) + return 2 + + wb = openpyxl.load_workbook(xlsx_path) + ws = wb.worksheets[0] + students = [] + seen: set[str] = set() + for row in ws.iter_rows(values_only=True): + if not row: + continue + sid_raw = row[0] + if sid_raw is None: + continue + sid = str(sid_raw).strip() + if not sid or sid in {"学号", "Student ID", "ID"}: + continue + if sid.upper() in seen: + continue + seen.add(sid.upper()) + name = "" + if len(row) > 1 and row[1] is not None: + name = str(row[1]).strip() + students.append({"id": sid, "name": name}) + + payload = { + "source": str(xlsx_path), + "count": len(students), + "students": students, + } + out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2)) + print(f"Wrote {len(students)} students to {out_path}") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(description="Build roster.json for the quiz app.") + p.add_argument("xlsx", type=Path, help="Path to attendance.xlsx") + p.add_argument("-o", "--out", type=Path, default=Path("roster.json")) + args = p.parse_args() + return build(args.xlsx, args.out) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/static/quiz.js b/static/quiz.js index 39d18dc..880f12f 100644 --- a/static/quiz.js +++ b/static/quiz.js @@ -160,7 +160,7 @@ function renderJoin(error = null) {

Join the quiz

-

Enter your student ID and name. The cookie is per-device.

+

Enter your registered student ID and your current full name.