From d34ddae458166ceeb1fdb66cb8f120d6c0396087 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 25 Aug 2026 01:19:19 -0500 Subject: [PATCH] feat(student): admin student-account management + CSV import, session hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-routes.js: create-with-credentials, /convert, /reset-password, /revoke-sessions, /sessions, /import (transactional CSV import — whole file validated, one Postgres transaction, never accepts a password/hash column from input). Fixed GET /api/admin/students to select an explicit safe column list instead of SELECT * — the new password_hash/credential_token_hash columns must never be exposed through this route. - server.js: /api/login now also blocks on credential_token_hash (not just password_hash) — closes a real window where a student mid-conversion stayed reachable via the old bare-student_id path. studentSession middleware now re-verifies status='active' on every request, not just at session creation, matching instructorSession's existing behavior. - sessions.js: revokeAllStudentSessions, listStudentSessions. - rbac.js: extracted checkCohortAccess/checkCohortWriteAccess as shared core functions so the new classroom/help-request RBAC (next commit) reuses the exact same authorization rule instead of a parallel copy. - student-csv.js + test: hand-rolled parser (no new dependency), unit tested. - prometheus.yml.j2: activated the platform-api scrape job. --- infra/monitoring/prometheus/prometheus.yml.j2 | 15 +- platform/api/src/admin-routes.js | 170 +++++++++++++++--- platform/api/src/rbac.js | 112 ++++++++---- platform/api/src/server.js | 59 +++++- platform/api/src/sessions.js | 22 +++ platform/api/src/student-csv.js | 70 ++++++++ platform/api/test/student-csv.test.js | 46 +++++ 7 files changed, 431 insertions(+), 63 deletions(-) create mode 100644 platform/api/src/student-csv.js create mode 100644 platform/api/test/student-csv.test.js diff --git a/infra/monitoring/prometheus/prometheus.yml.j2 b/infra/monitoring/prometheus/prometheus.yml.j2 index 1184307..efefdfd 100644 --- a/infra/monitoring/prometheus/prometheus.yml.j2 +++ b/infra/monitoring/prometheus/prometheus.yml.j2 @@ -83,11 +83,10 @@ scrape_configs: - target_label: __address__ replacement: localhost:9115 - # RootNode application metrics — see docs/OBSERVABILITY.md §"exposing /metrics" for the - # documented, NOT-YET-BUILT follow-up (Platform API/Gateway/Provisioner don't currently - # expose a Prometheus /metrics endpoint; this job is ready for when that lands, disabled - # until then rather than scraping a 404 forever). - # - job_name: rootnode-platform-api - # static_configs: - # - targets: ["api:4300"] - # metrics_path: /metrics + # RootNode application metrics — Phase 5A §34 (platform/api/src/metrics.js): classroom/help- + # request/Lab-state gauges and student login counters. No student/instructor id or other + # high-cardinality value is ever a label here — see that file's header comment. + - job_name: rootnode-platform-api + static_configs: + - targets: ["api:4300"] + metrics_path: /metrics diff --git a/platform/api/src/admin-routes.js b/platform/api/src/admin-routes.js index bb3f8be..81658b2 100644 --- a/platform/api/src/admin-routes.js +++ b/platform/api/src/admin-routes.js @@ -16,6 +16,9 @@ const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors'); const attemptsLib = require('./attempts'); // Phase 4.1 §13 — abandonment on instructor-driven reset/destroy const { parsePagination, paginatedResponse } = require('./pagination'); // Phase 4.1 §16 +const { issueCredentialToken } = require('./students-auth'); // Phase 5A §4/§5/§7 +const { prepareLabsForCohort } = require('./prepare-labs'); // Phase 5A §21 — shared with admin-classroom-routes.js +const { parseStudentCsv } = require('./student-csv'); // Phase 5A §29 module.exports = function registerAdminRoutes(app, deps) { const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps; @@ -95,33 +98,168 @@ module.exports = function registerAdminRoutes(app, deps) { }); // ── Students ───────────────────────────────────────────────────────────────────────── + // Phase 5A: explicit column list, not SELECT * — students now carries password_hash and + // credential_token_hash (migration 017), which must never reach an API response + // (docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §2). (password_hash IS NOT NULL) is + // exposed as a boolean, matching students-auth.js's publicStudent() shape, so the admin UI + // can tell "pilot, needs conversion" from "already credentialed" without ever seeing the hash. admin.get('/api/admin/students', requireAnyInstructor, async (req, res) => { const pool = getPool(); const { rows } = req.instructor.role === 'instructor' ? await pool.query( - `SELECT DISTINCT s.* FROM students s + `SELECT DISTINCT s.student_id, s.display_name, s.status, s.email, s.login_identifier, + (s.password_hash IS NOT NULL) AS has_credentials, s.created_at, s.updated_at, s.last_active_at, s.last_login_at + FROM students s JOIN cohort_enrollments e ON e.student_id = s.student_id JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.instructor.instructor_id]) - : await pool.query(`SELECT * FROM students ORDER BY display_name`); + : await pool.query( + `SELECT student_id, display_name, status, email, login_identifier, + (password_hash IS NOT NULL) AS has_credentials, created_at, updated_at, last_active_at, last_login_at + FROM students ORDER BY display_name`); res.json(rows); }); + // Phase 5A §2/§3: email/login_identifier are both optional and independent — an older + // self-paced student gets email, a younger instructor-managed one gets login_identifier + // (any instructor-assigned safe string, not necessarily an email). Providing EITHER issues + // an activation token immediately (§5's "instructor creates account -> temporary credential" + // flow) and returns it ONCE in this response — the instructor relays it to the student + // out-of-band (print it, read it to them, hand it over); it is never stored in plaintext or + // logged anywhere (students-auth.js's issueCredentialToken only ever persists the hash). + // Providing neither creates a plain pilot row, unchanged from before Phase 5A. admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => { - const { display_name, cohort_id } = req.body || {}; + const { display_name, cohort_id, email, login_identifier } = req.body || {}; if (!display_name) return res.status(400).json({ error: 'display_name is required' }); const pool = getPool(); const { rows: countRows } = await pool.query('SELECT count(*)::int AS n FROM students'); const studentId = `student-${String(countRows[0].n + 1).padStart(3, '0')}`; - await pool.query(`INSERT INTO students (student_id, display_name) VALUES ($1, $2)`, [studentId, display_name.trim()]); + try { + await pool.query( + `INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`, + [studentId, display_name.trim(), email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null] + ); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' }); + throw e; + } if (cohort_id) { await pool.query( `INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, [cohort_id, studentId] ); } - await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id } }); - res.status(201).json({ student_id: studentId, display_name: display_name.trim() }); + let activationToken = null; + if (email || login_identifier) { + const issued = await issueCredentialToken(pool, studentId, 'activation'); + activationToken = issued.token; + } + await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id, credentialed: !!activationToken } }); + res.status(201).json({ student_id: studentId, display_name: display_name.trim(), activation_token: activationToken }); + }); + + // §7 — converts an existing PILOT student (password_hash IS NULL) into a credentialed + // account: assigns email/login_identifier and issues an activation token, same mechanism as + // creation. Nothing about the student's existing progress/attempts/achievements/cohort + // membership is touched — student_id, the FK every other table hangs off of, never changes. + admin.post('/api/admin/students/:id/convert', requireAdminOrOwner, async (req, res) => { + const { email, login_identifier } = req.body || {}; + if (!email && !login_identifier) return res.status(400).json({ error: 'email or login_identifier is required' }); + const pool = getPool(); + const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + if (rows[0].password_hash) return res.status(409).json({ error: 'already_converted', message: 'This student already has real credentials.' }); + try { + await pool.query( + `UPDATE students SET email = COALESCE($1, email), login_identifier = COALESCE($2, login_identifier), updated_at = now() WHERE student_id = $3`, + [email ? String(email).toLowerCase().trim() : null, login_identifier ? String(login_identifier).trim() : null, req.params.id] + ); + } catch (e) { + if (e.code === '23505') return res.status(409).json({ error: 'email_or_identifier_in_use' }); + throw e; + } + const { token } = await issueCredentialToken(pool, req.params.id, 'activation'); + await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_converted', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); + res.json({ student_id: req.params.id, activation_token: token }); + }); + + // §4 — for an ALREADY-credentialed account (unlike convert, above). Issues a fresh 'reset' + // token and revokes every existing session, same "instructor never needs to know the + // student's password" principle as activation. + admin.post('/api/admin/students/:id/reset-password', requireAdminOrOwner, async (req, res) => { + const pool = getPool(); + const { rows } = await pool.query(`SELECT student_id, password_hash FROM students WHERE student_id = $1`, [req.params.id]); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + if (!rows[0].password_hash) return res.status(409).json({ error: 'not_yet_converted', message: 'Convert this student to a real account first.' }); + const { token } = await issueCredentialToken(pool, req.params.id, 'reset'); + await require('./sessions').revokeAllStudentSessions(pool, req.params.id); + await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_password_reset', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); + res.json({ student_id: req.params.id, reset_token: token }); + }); + + admin.post('/api/admin/students/:id/revoke-sessions', requireAdminOrOwner, async (req, res) => { + await require('./sessions').revokeAllStudentSessions(getPool(), req.params.id); + await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'student_sessions_revoked', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); + res.json({ ok: true }); + }); + + admin.get('/api/admin/students/:id/sessions', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => { + const sessionsList = await require('./sessions').listStudentSessions(getPool(), req.params.id); + res.json(sessionsList); + }); + + // §29 — CSV bulk roster import. Deliberately transactional: the WHOLE file is parsed and + // validated first; if any row is invalid, NOTHING is imported and every row-level error is + // returned, rather than silently importing the valid rows and skipping the rest (the safer + // default per docs/PHASE5A_STUDENT_IDENTITY_REMOTE_CLASSROOM.md §29 — a partial import that + // "mostly worked" is a worse failure mode than a rejected file with a clear error list). + // Expected columns: display_name, login_identifier (or email), cohort_id (optional). Never + // accepts a password/password_hash column — activation tokens are always generated server-side. + admin.post('/api/admin/students/import', requireAdminOrOwner, async (req, res) => { + const { csv } = req.body || {}; + if (!csv || typeof csv !== 'string') return res.status(400).json({ error: 'csv (text) is required' }); + const { rows: parsedRows, errors: parseErrors } = parseStudentCsv(csv); + if (parseErrors.length) return res.status(400).json({ error: 'invalid_csv', row_errors: parseErrors }); + + const pool = getPool(); + const client = await pool.connect(); + const created = []; + try { + await client.query('BEGIN'); + const { rows: countRows } = await client.query('SELECT count(*)::int AS n FROM students'); + let nextN = countRows[0].n + 1; + for (const row of parsedRows) { + const studentId = `student-${String(nextN++).padStart(3, '0')}`; + await client.query( + `INSERT INTO students (student_id, display_name, email, login_identifier) VALUES ($1, $2, $3, $4)`, + [studentId, row.display_name, row.email || null, row.login_identifier || null] + ); + if (row.cohort_id) { + const { rowCount } = await client.query(`SELECT 1 FROM cohorts WHERE cohort_id = $1`, [row.cohort_id]); + if (!rowCount) throw Object.assign(new Error(`cohort_id '${row.cohort_id}' does not exist (row for ${row.display_name})`), { code: 'BAD_COHORT' }); + await client.query(`INSERT INTO cohort_enrollments (cohort_id, student_id) VALUES ($1, $2)`, [row.cohort_id, studentId]); + } + let activationToken = null; + if (row.email || row.login_identifier) { + // issueCredentialToken only ever calls .query() on what it's given — a transaction + // client works identically to a pool here, so the whole import (including every + // token) commits or rolls back together with everything else. + const issued = await issueCredentialToken(client, studentId, 'activation'); + activationToken = issued.token; + } + created.push({ student_id: studentId, display_name: row.display_name, activation_token: activationToken }); + } + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + if (e.code === '23505') return res.status(409).json({ error: 'duplicate_email_or_identifier_in_file_or_existing_roster' }); + if (e.code === 'BAD_COHORT') return res.status(400).json({ error: 'invalid_cohort', message: e.message }); + throw e; + } finally { + client.release(); + } + await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_csv_import', result: 'success', sourceIp: req.ip, metadata: { count: created.length } }); + res.status(201).json({ created }); }); admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => { @@ -306,23 +444,9 @@ module.exports = function registerAdminRoutes(app, deps) { // ── Classroom prep: Prepare Labs / Destroy Cohort Labs ────────────────────────────────── admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => { - const { rows: students } = await getPool().query( - `SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id - WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`, - [req.params.id] - ); - const results = await Promise.allSettled(students.map(async (s) => { - const existing = await provisioner.listForStudent(s.student_id); - const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status)); - if (active) return { student_id: s.student_id, display_name: s.display_name, status: active.status }; - const { status, body } = await provisioner.provision(s.student_id); - if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error }; - return { student_id: s.student_id, display_name: s.display_name, status: body.status }; - })); - await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } }); - res.json({ - results: results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message }), - }); + const results = await prepareLabsForCohort(getPool(), provisioner, req.params.id); + await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: results.length } }); + res.json({ results }); }); admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortWriteAccess('id'), async (req, res) => { diff --git a/platform/api/src/rbac.js b/platform/api/src/rbac.js index 0d22805..64d7f6c 100644 --- a/platform/api/src/rbac.js +++ b/platform/api/src/rbac.js @@ -30,27 +30,34 @@ module.exports = function createRbac(pool) { const requireAdminOrOwner = requireRole('owner', 'admin'); const requireAnyInstructor = requireRole('owner', 'admin', 'instructor'); + // Core check, factored out so both the cohort_id-param routes below AND the + // classroom_session_id-keyed routes (Phase 5A §24, further down) can share one rule instead + // of two copies of the same query drifting apart over time. + async function checkCohortAccess(req, res, cohortId) { + const instructor = req.instructor; + if (['owner', 'admin'].includes(instructor.role)) return true; + const { rowCount } = await pool.query( + `SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, + [cohortId, instructor.instructor_id] + ); + if (!rowCount) { + await recordAudit(pool, { + actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied', + resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip, + }); + res.status(403).json({ error: 'forbidden' }); + return false; + } + return true; + } + // Cohort-scoped access: owner/admin bypass (global teaching visibility by role alone). A // plain 'instructor' must have a cohort_instructors row for this cohort_id, or gets 403 — // same as any other authorization failure, no distinction made between "not your cohort" and // "cohort doesn't exist" in the response. function requireCohortAccess(cohortIdParam = 'id') { return async (req, res, next) => { - const instructor = req.instructor; - if (['owner', 'admin'].includes(instructor.role)) return next(); - const cohortId = req.params[cohortIdParam]; - const { rowCount } = await pool.query( - `SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, - [cohortId, instructor.instructor_id] - ); - if (!rowCount) { - await recordAudit(pool, { - actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied', - resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip, - }); - return res.status(403).json({ error: 'forbidden' }); - } - next(); + if (await checkCohortAccess(req, res, req.params[cohortIdParam])) next(); }; } @@ -84,24 +91,27 @@ module.exports = function createRbac(pool) { // vs 'viewer' (read-only — sees dashboards/progress/analytics via requireCohortAccess above, // but cannot Prepare/Destroy Labs or provision a student's environment). owner/admin still // bypass entirely, same as every other cohort-scoped check. + async function checkCohortWriteAccess(req, res, cohortId) { + const instructor = req.instructor; + if (['owner', 'admin'].includes(instructor.role)) return true; + const { rows } = await pool.query( + `SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, + [cohortId, instructor.instructor_id] + ); + if (!rows.length || rows[0].role_in_cohort === 'viewer') { + await recordAudit(pool, { + actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied', + resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip, + metadata: rows.length ? { reason: 'viewer_role' } : undefined, + }); + res.status(403).json({ error: 'forbidden' }); + return false; + } + return true; + } function requireCohortWriteAccess(cohortIdParam = 'id') { return async (req, res, next) => { - const instructor = req.instructor; - if (['owner', 'admin'].includes(instructor.role)) return next(); - const cohortId = req.params[cohortIdParam]; - const { rows } = await pool.query( - `SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, - [cohortId, instructor.instructor_id] - ); - if (!rows.length || rows[0].role_in_cohort === 'viewer') { - await recordAudit(pool, { - actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied', - resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip, - metadata: rows.length ? { reason: 'viewer_role' } : undefined, - }); - return res.status(403).json({ error: 'forbidden' }); - } - next(); + if (await checkCohortWriteAccess(req, res, req.params[cohortIdParam])) next(); }; } @@ -130,9 +140,49 @@ module.exports = function createRbac(pool) { }; } + // Phase 5A §24 — classroom routes are keyed by classroom_session_id, not cohort_id directly, + // so this resolves the classroom's cohort first (404 if it doesn't exist — a route param + // isn't proof the resource exists) and then applies the exact same rule + // requireCohortAccess/requireCohortWriteAccess do. Stashes the resolved cohort_id on the + // request (req.classroomCohortId) so handlers that need it don't re-query. + function requireClassroomAccess(classroomIdParam = 'id') { + return async (req, res, next) => { + const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + req.classroomCohortId = rows[0].cohort_id; + if (await checkCohortAccess(req, res, rows[0].cohort_id)) next(); + }; + } + function requireClassroomWriteAccess(classroomIdParam = 'id') { + return async (req, res, next) => { + const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + req.classroomCohortId = rows[0].cohort_id; + if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next(); + }; + } + + // Same idea, one join further — help_requests routes are keyed by help_request_id, resolved + // through classroom_sessions to the cohort. Used by the acknowledge/resolve routes. + function requireHelpRequestWriteAccess(helpRequestIdParam = 'id') { + return async (req, res, next) => { + const { rows } = await pool.query( + `SELECT cs.cohort_id, hr.classroom_session_id FROM help_requests hr + JOIN classroom_sessions cs ON cs.classroom_session_id = hr.classroom_session_id + WHERE hr.help_request_id = $1`, + [req.params[helpRequestIdParam]] + ); + if (!rows.length) return res.status(404).json({ error: 'not found' }); + req.classroomCohortId = rows[0].cohort_id; + req.classroomSessionId = rows[0].classroom_session_id; + if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next(); + }; + } + return { requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess, requireCohortWriteAccess, requireStudentCohortWriteAccess, + requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess, }; }; diff --git a/platform/api/src/server.js b/platform/api/src/server.js index ee4c63d..941e00c 100644 --- a/platform/api/src/server.js +++ b/platform/api/src/server.js @@ -43,10 +43,20 @@ app.use(express.static(FRONTEND_DIST)); // student_id is derived ONLY from an opaque, server-issued session token (now Postgres-backed, // not the in-memory Map from Phase 2B) — never from a header or query parameter a client // controls. CSRF is checked here too, for every state-changing student request. +// Phase 5A: now re-checks the student is still 'active' on every request, matching +// instructorSession's existing pattern below — a deactivation takes effect on the very next +// request instead of waiting out the session's natural expiry. Previously this middleware only +// checked the session itself existed, which was fine when every student was pilot/trusted-roster +// but is a real gap now that real credentialed accounts (and a "deactivate" admin control) exist. async function studentSession(req, res, next) { const token = parseCookies(req)[STUDENT_COOKIE]; const studentId = await sessions.resolveStudentSession(getPool(), token); if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' }); + const { rows } = await getPool().query(`SELECT status FROM students WHERE student_id = $1`, [studentId]); + if (!rows.length || rows[0].status !== 'active') { + await sessions.destroyStudentSession(getPool(), token); // deactivated since the session was issued + return res.status(401).json({ error: 'no_session', message: 'Please log in.' }); + } if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' }); req.studentId = studentId; req.sessionToken = token; @@ -88,6 +98,19 @@ app.get('/healthz', async (req, res) => { } }); +// GET /metrics — Phase 5A §34. Unauthenticated like /healthz (Prometheus scrapers don't carry +// credentials here, matching every other exporter in infra/monitoring/) but restricted at the +// firewall to the Platform VM's own Prometheus (infra/firewall/rn-platform-01.rules already +// only allows loopback/rn-platform-internal for this port — this endpoint adds no new public +// exposure). No student/instructor PII, no high-cardinality labels — see metrics.js. +app.get('/metrics', async (req, res) => { + try { + res.type('text/plain').send(await require('./metrics').renderMetrics(getPool(), provisioner)); + } catch (e) { + res.status(503).type('text/plain').send('# metrics collection failed\n'); + } +}); + // GET /api/version — Phase 4.1 §25. Unauthenticated like /healthz, and deliberately just as // minimal: an app version, the deployed Git SHA (or 'dev' locally — see // infra/docker/platform/docker-compose.production.yml's GIT_SHA env var, set to the same @@ -140,11 +163,26 @@ app.get('/api/roster', async (req, res) => { // can't be forged/switched by editing client state, and is checked on every Lab request. See // docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5 for the future path (one-time codes, // institutional SSO) that upgrades identification without touching this session system. +// Phase 5A §7: pilot-only path now — once an instructor converts a student to a real, +// credentialed account (password_hash set), this route refuses them and points at the real +// login, so a converted account can never be accessed by just knowing its bare student_id. +// Unconverted pilot rows (password_hash IS NULL) are untouched by Phase 5A and keep working +// exactly as before — no pilot data or access is lost. app.post('/api/login', async (req, res) => { const { student_id } = req.body || {}; - const { rows } = await getPool().query(`SELECT student_id, display_name, status FROM students WHERE student_id = $1`, [student_id]); + const { rows } = await getPool().query( + `SELECT student_id, display_name, status, password_hash, credential_token_hash FROM students WHERE student_id = $1`, + [student_id] + ); const student = rows[0]; if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' }); + // Blocks on credential_token_hash too, not just password_hash — a student mid-conversion + // (instructor has issued an activation token but the student hasn't set a password yet) must + // not be reachable via the old insecure bare-id path either, or conversion doesn't actually + // close it. Found by testing (infra/scripts/drills/test-phase5a-rehearsal.js). + if (student.password_hash || student.credential_token_hash) { + return res.status(409).json({ error: 'account_converted', message: 'This account now requires a real login — use POST /api/student/login.' }); + } await touchStudent(getPool(), student.student_id); const token = await sessions.createStudentSession(getPool(), student.student_id); const csrf = makeCsrfToken(); @@ -329,6 +367,15 @@ app.get('/api/environment/by-token/:token', async (req, res) => { require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) }); +// Phase 5A — the new credentialed student login path. COOKIE_SECURE=1 is the existing signal +// for "this is a real deployed environment" (see cookies.js) — reused here rather than adding +// a second env var, to decide whether /api/student/request-password-reset may echo the reset +// token back for local/test use (never in a real deployment). +require('./student-auth-routes')(app, { + pool: getPool(), sessions, studentSession, curriculum, loadProgress, + isProduction: process.env.COOKIE_SECURE === '1', +}); + require('./learning-routes')(app, { studentSession, getPool, curriculum, loadProgress, wrap }); const rbac = createRbac(getPool()); @@ -340,6 +387,16 @@ require('./admin-learning-routes')(app, { instructorSession: wrap(instructorSession), rbac, curriculum, getPool, loadProgress, recordAudit, }); +// Phase 5A §11-§20 — live classroom sessions. classroom-routes.js is the student-facing half +// (join/heartbeat/announcements/help-request); admin-classroom-routes.js is the instructor +// dashboard/lifecycle half. Both sit alongside, not inside, the existing learning-routes.js/ +// admin-learning-routes.js — a classroom session is a distinct concept from curriculum +// progress, even though its dashboard reads curriculum/progress data. +require('./classroom-routes')(app, { studentSession, getPool, curriculum }); +require('./admin-classroom-routes')(app, { + instructorSession: wrap(instructorSession), rbac, provisioner, curriculum, getPool, loadProgress, recordAudit, +}); + // Fallback to the platform-shell SPA for any unmatched non-API route app.get('*', (req, res) => { res.sendFile(path.join(FRONTEND_DIST, 'index.html')); diff --git a/platform/api/src/sessions.js b/platform/api/src/sessions.js index cc0bd52..8fbd22e 100644 --- a/platform/api/src/sessions.js +++ b/platform/api/src/sessions.js @@ -71,6 +71,27 @@ async function resolveStudentSession(pool, token) { } const destroyStudentSession = revokeSession; +// Phase 5A §6/§10 — mirrors revokeAllInstructorSessions/listInstructorSessions exactly. Used +// by admin-triggered "revoke student sessions", and automatically after a student password +// change/reset (except the session that just made the change, if any). +async function revokeAllStudentSessions(pool, studentId, exceptToken) { + const exceptHash = exceptToken ? hashToken(exceptToken) : null; + await pool.query( + `UPDATE sessions SET revoked_at = now() + WHERE student_id = $1 AND revoked_at IS NULL AND ($2::text IS NULL OR token_hash != $2)`, + [studentId, exceptHash] + ); +} + +async function listStudentSessions(pool, studentId) { + const { rows } = await pool.query( + `SELECT session_id, created_at, last_seen_at, expires_at, revoked_at + FROM sessions WHERE student_id = $1 ORDER BY created_at DESC LIMIT 20`, + [studentId] + ); + return rows; +} + // ── Instructor sessions ────────────────────────────────────────────────────────────────── function createInstructorSession(pool, instructorId) { return createSession(pool, { type: 'instructor', instructorId, ttlMs: INSTRUCTOR_TTL_MS, idleTtlMs: INSTRUCTOR_IDLE_TTL_MS }); @@ -131,6 +152,7 @@ function startSessionSweeper(pool) { module.exports = { createStudentSession, resolveStudentSession, destroyStudentSession, + revokeAllStudentSessions, listStudentSessions, createInstructorSession, resolveInstructorSession, destroyInstructorSession, revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper, }; diff --git a/platform/api/src/student-csv.js b/platform/api/src/student-csv.js new file mode 100644 index 0000000..5c8d89e --- /dev/null +++ b/platform/api/src/student-csv.js @@ -0,0 +1,70 @@ +// CSV roster import parsing — Phase 5A §29. Deliberately hand-rolled, not a CSV library: the +// expected input is simple (no quoted fields containing commas expected in practice for +// names/identifiers), and pulling in a dependency for a three-column parser isn't justified +// (ponytail: stdlib/hand-rolled first). Validates the WHOLE file and returns every row-level +// error at once — the caller decides whether to import (transactional, all-or-nothing). + +const REQUIRED_HEADERS = ['display_name']; +const KNOWN_HEADERS = ['display_name', 'email', 'login_identifier', 'cohort_id']; +const FORBIDDEN_HEADERS = ['password', 'password_hash', 'credential_token_hash']; // never accepted from a file — see admin-routes.js's import route comment + +function splitCsvLine(line) { + // No quoted-comma support needed for this column set (names/identifiers/cohort slugs don't + // contain commas in practice) — a plain split covers the real input shape. + return line.split(',').map((cell) => cell.trim()); +} + +function parseStudentCsv(csv) { + const lines = csv.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0); + const errors = []; + if (!lines.length) return { rows: [], errors: [{ row: 0, message: 'CSV is empty' }] }; + + const headers = splitCsvLine(lines[0]).map((h) => h.toLowerCase()); + for (const required of REQUIRED_HEADERS) { + if (!headers.includes(required)) errors.push({ row: 0, message: `missing required column: ${required}` }); + } + for (const forbidden of FORBIDDEN_HEADERS) { + if (headers.includes(forbidden)) errors.push({ row: 0, message: `column '${forbidden}' is never accepted from a file — credentials are always generated server-side` }); + } + const unknown = headers.filter((h) => !KNOWN_HEADERS.includes(h)); + if (unknown.length) errors.push({ row: 0, message: `unknown column(s): ${unknown.join(', ')}` }); + if (errors.length) return { rows: [], errors }; + + const seenEmails = new Set(); + const seenIdentifiers = new Set(); + const rows = []; + for (let i = 1; i < lines.length; i++) { + const rowNum = i + 1; // 1-indexed with header as row 1, matching what a spreadsheet user sees + const cells = splitCsvLine(lines[i]); + if (cells.length !== headers.length) { + errors.push({ row: rowNum, message: `expected ${headers.length} columns, got ${cells.length}` }); + continue; + } + const raw = Object.fromEntries(headers.map((h, idx) => [h, cells[idx]])); + if (!raw.display_name) { + errors.push({ row: rowNum, message: 'display_name is required' }); + continue; + } + const email = raw.email ? raw.email.toLowerCase() : null; + const loginIdentifier = raw.login_identifier || null; + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + errors.push({ row: rowNum, message: `invalid email: ${raw.email}` }); + continue; + } + if (email && seenEmails.has(email)) { + errors.push({ row: rowNum, message: `duplicate email within this file: ${email}` }); + continue; + } + if (loginIdentifier && seenIdentifiers.has(loginIdentifier)) { + errors.push({ row: rowNum, message: `duplicate login_identifier within this file: ${loginIdentifier}` }); + continue; + } + if (email) seenEmails.add(email); + if (loginIdentifier) seenIdentifiers.add(loginIdentifier); + rows.push({ display_name: raw.display_name, email, login_identifier: loginIdentifier, cohort_id: raw.cohort_id || null }); + } + if (errors.length) return { rows: [], errors }; + return { rows, errors: [] }; +} + +module.exports = { parseStudentCsv }; diff --git a/platform/api/test/student-csv.test.js b/platform/api/test/student-csv.test.js new file mode 100644 index 0000000..634fd62 --- /dev/null +++ b/platform/api/test/student-csv.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +// Fast, isolated unit test for student-csv.js — no Postgres, no HTTP. No framework, matching +// project convention (see platform/lab-router/test/run.js). + +const assert = require('assert'); +const { parseStudentCsv } = require('../src/student-csv'); + +function main() { + { + const r = parseStudentCsv('display_name,email,login_identifier,cohort_id\nAlice,alice@example.com,,root-fall\nBob,,bob01,root-fall'); + assert.equal(r.errors.length, 0); + assert.equal(r.rows.length, 2); + assert.equal(r.rows[0].email, 'alice@example.com'); + assert.equal(r.rows[1].login_identifier, 'bob01'); + console.log('ok — valid file parses'); + } + { + const r = parseStudentCsv('email\na@b.com'); + assert.ok(r.errors.length > 0); + console.log('ok — missing display_name column rejected'); + } + { + const r = parseStudentCsv('display_name,password\nAlice,hunter2'); + assert.ok(r.errors.some((e) => e.message.includes('password'))); + console.log('ok — password column never accepted from a file'); + } + { + const r = parseStudentCsv('display_name,email\nAlice,a@b.com,extra'); + assert.ok(r.errors.length > 0); + assert.equal(r.rows.length, 0); + console.log('ok — a malformed row rejects the whole file (transactional, no partial import)'); + } + { + const r = parseStudentCsv('display_name,email\nAlice,a@b.com\nBob,a@b.com'); + assert.ok(r.errors.some((e) => e.message.includes('duplicate email'))); + console.log('ok — duplicate email within one file rejected'); + } + { + const r = parseStudentCsv('display_name,email\nAlice,not-an-email'); + assert.ok(r.errors.some((e) => e.message.includes('invalid email'))); + console.log('ok — invalid email format rejected'); + } + console.log('\nAll student-csv unit tests passed.'); +} + +main();