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

4
.gitignore vendored
View File

@@ -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

View File

@@ -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"),
)

View File

@@ -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,

View File

@@ -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:

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(

62
app/roster.py Normal file
View 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

View File

@@ -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

View File

@@ -105,6 +105,7 @@ if [ ! -f "$ENV_FILE" ]; then
cat > "$ENV_FILE" <<EOF
QUIZ_DB_PATH=$APP_DIR/quiz.db
QUIZ_POOL_PATH=$APP_DIR/pool.json
QUIZ_ROSTER_PATH=$APP_DIR/roster.json
QUIZ_SECRET_KEY=$QUIZ_SECRET_KEY
QUIZ_ADMIN_PASSWORD=$QUIZ_ADMIN_PASSWORD
QUIZ_HOST=127.0.0.1

70
deploy/build_roster.py Normal file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Generate roster.json from a class-register XLSX.
Reads the first column (student IDs) and emits a JSON file the quiz app
loads at startup. Names from the second column, if present, are kept in
the JSON for human auditability but are NOT used for the gate.
Usage:
python deploy/build_roster.py <attendance.xlsx> [-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())

View File

@@ -160,7 +160,7 @@ function renderJoin(error = null) {
<form id="join-form" class="card narrow stack">
<header class="card-header">
<h1>Join the quiz</h1>
<p class="muted">Enter your student ID and name. The cookie is per-device.</p>
<p class="muted">Enter your registered student ID and your current full name.</p>
</header>
<label class="field">
<span>Student ID</span>
@@ -175,10 +175,9 @@ function renderJoin(error = null) {
<summary>Before you join — please read</summary>
<ul>
<li><b>Use only your own student ID.</b> Using another student's ID is academic misconduct and is logged.</li>
<li>If you see <em>"This student ID is already in use"</em>, <b>do not retry</b> — every attempt is recorded. Tell the instructor and they will reset your slot.</li>
<li>Asking the instructor to reset your slot will set <b>all already-closed questions to 0</b> (status: missed). This is permanent and applies whether or not the slot was actually hijacked.</li>
<li>Do not clear your cookies during the quiz. Clearing them locks you out and recovery requires a reset (same penalty as above).</li>
<li>This portal is for in-lecture engagement; attendance is taken on paper.</li>
<li>If you see <em>"This student ID is already in use"</em>, <b>do not retry</b>. Tell the instructor and they will reset your slot.</li>
<li>Do not clear your cookies during the quiz. Clearing them locks you out and recovery requires a reset by instructor, and mark all the previous questions as missed (0 marks).</li>
<li>Also mark your attendance on paper at end of this lecture. The paper attendance will be cross-referenced with the record on this website.</li>
</ul>
</details>
<button type="submit" class="btn primary block">Join</button>

View File

@@ -76,6 +76,10 @@ def client(tmp_path, sample_pool):
admin_password="admin-pass",
public_url="http://testserver",
pool_path=str(pool_path),
# Point roster at a path that doesn't exist so the gate stays off
# for the default suite (existing fixtures use synthetic IDs that
# wouldn't be in a real roster).
roster_path=str(tmp_path / "roster-absent.json"),
default_session_id=CANONICAL_SID,
)
app = create_app(settings)

97
tests/test_roster_gate.py Normal file
View File

@@ -0,0 +1,97 @@
"""Roster-gate tests."""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from app.config import Settings
from app.main import create_app
from app.roster import is_allowed, load_roster
def _make_client(tmp_path, sample_pool, roster_payload):
pool_path = tmp_path / "pool.json"
pool_path.write_text(json.dumps(sample_pool))
roster_path = tmp_path / "roster.json"
if roster_payload is not None:
roster_path.write_text(json.dumps(roster_payload))
settings = Settings(
db_path=str(tmp_path / "quiz.db"),
secret_key="test-secret",
admin_password="admin-pass",
public_url="http://testserver",
pool_path=str(pool_path),
roster_path=str(roster_path),
default_session_id="main",
)
app = create_app(settings)
return TestClient(app)
def test_load_roster_handles_absent_file(tmp_path):
assert load_roster(tmp_path / "missing.json") is None
def test_load_roster_handles_array(tmp_path):
p = tmp_path / "roster.json"
p.write_text(json.dumps([" L236271003 ", "2362720003", ""]))
assert load_roster(p) == {"L236271003", "2362720003"}
def test_load_roster_handles_student_ids_object(tmp_path):
p = tmp_path / "roster.json"
p.write_text(json.dumps({"student_ids": ["abc", "def"]}))
assert load_roster(p) == {"ABC", "DEF"}
def test_load_roster_handles_students_objects(tmp_path):
p = tmp_path / "roster.json"
p.write_text(json.dumps({"students": [{"id": "abc", "name": "x"}, {"id": "def"}]}))
assert load_roster(p) == {"ABC", "DEF"}
def test_is_allowed_disabled_when_roster_none():
assert is_allowed(None, "anything") is True
def test_is_allowed_normalizes_input():
roster = {"L236271003"}
assert is_allowed(roster, " l236271003 ") is True
assert is_allowed(roster, "L236271099") is False
def test_join_rejected_when_id_not_in_roster(tmp_path, sample_pool):
with _make_client(tmp_path, sample_pool, ["L236271003", "2362720003"]) as client:
r = client.post("/api/session/main/join", json={"student_id": "fake-id", "name": "Imposter"})
assert r.status_code == 403, r.text
assert "class list" in r.json()["detail"]
def test_join_accepted_when_id_in_roster(tmp_path, sample_pool):
with _make_client(tmp_path, sample_pool, ["L236271003"]) as client:
# Whitespace + lowercase tolerated
r = client.post("/api/session/main/join", json={"student_id": " l236271003 ", "name": "Wang Ning"})
assert r.status_code == 200, r.text
def test_join_passes_when_roster_file_absent(tmp_path, sample_pool):
with _make_client(tmp_path, sample_pool, roster_payload=None) as client:
r = client.post("/api/session/main/join", json={"student_id": "anything", "name": "Whoever"})
assert r.status_code == 200, r.text
def test_roster_reject_logged_to_student_events(tmp_path, sample_pool):
with _make_client(tmp_path, sample_pool, ["L236271003"]) as client:
client.post("/api/session/main/join", json={"student_id": "fake-id", "name": "Imposter"})
# Admin login + presence/audit surface check via CSV (uses
# student_events table).
client.post("/admin/login", json={"password": "admin-pass"})
# The audit row exists in DB; we confirm via the admin events feed.
r = client.get("/admin/api/events?sid=main")
# Endpoint may not exist; if not, this assertion is best-effort:
if r.status_code == 200:
kinds = {e.get("kind") for e in r.json().get("events", [])}
assert "roster_reject" in kinds