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:
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
19
app/room.py
19
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(
|
||||
|
||||
62
app/roster.py
Normal file
62
app/roster.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user