feat(student): admin student-account management + CSV import, session hardening
- 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.
This commit is contained in:
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user