// Instructor/Admin API — /api/admin/*. Every route requires an instructor session // (instructorSession) plus a role check (rbac.js) — completely separate from student sessions // (different cookie, different token namespace, see sessions.js). Student sessions can never // call these routes. // // Permission shape (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for the full matrix): // owner — everything, including instructor account lifecycle and audit log. // admin — cohorts/students/curriculum/labs, global teaching visibility. No instructor // account administration. // instructor — same operational actions, but scoped to cohorts they're assigned to via // cohort_instructors (requireCohortAccess / requireStudentCohortAccess). // // This module still owns every instructor-facing Provisioner interaction (prepare-labs, reset, // destroy, events) — the Admin UI never talks to the Provisioner directly. const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors'); module.exports = function registerAdminRoutes(app, deps) { const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps; const { requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac; // Tiny helper: every admin. call goes through instructorSession first, then whatever // role/scope middleware is passed, then the handler (last function in the list — auto-wrapped // so a thrown/rejected error reaches Express's error handler instead of crashing the process, // same as every other async handler in this Express 4 app). function wrap(fn) { return (req, res, next) => { try { const result = fn(req, res, next); if (result && typeof result.catch === 'function') result.catch(next); } catch (err) { next(err); } }; } const admin = { get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)), post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)), patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)), delete: (p, ...mw) => app.delete(p, instructorSession, ...mw.map(wrap)), }; // ── Dashboard ──────────────────────────────────────────────────────────────────────── // Global for owner/admin. A plain 'instructor' only sees stats for cohorts they're assigned // to — an empty cohort_instructors row set means an empty (not global) dashboard, not a 403, // since the dashboard itself isn't a sensitive resource, just its contents. admin.get('/api/admin/dashboard', requireAnyInstructor, async (req, res) => { const pool = getPool(); const scoped = req.instructor.role === 'instructor'; const cohortFilter = scoped ? `AND e.cohort_id IN (SELECT cohort_id FROM cohort_instructors WHERE instructor_id = '${req.instructor.instructor_id}')` : ''; // (string-interpolated only for a UUID we already validated came from req.instructor, never // from request input — every other query below stays parameterized as usual.) const [cohorts, students, envs] = await Promise.all([ pool.query(scoped ? `SELECT count(DISTINCT c.cohort_id)::int AS n FROM cohorts c JOIN cohort_instructors ci ON ci.cohort_id = c.cohort_id WHERE c.status = 'active' AND ci.instructor_id = $1` : `SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`, scoped ? [req.instructor.instructor_id] : []), scoped ? pool.query(`SELECT count(DISTINCT e.student_id)::int AS n FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled'`, [req.instructor.instructor_id]) : pool.query(`SELECT count(*)::int AS n FROM students WHERE status = 'active'`), pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`), ]); const envCounts = Object.fromEntries(envs.rows.map((r) => [r.status, r.n])); const recentCompletions = await pool.query( `SELECT sa.student_id, s.display_name, sa.scenario_id, sa.completed_at FROM scenario_attempts sa JOIN students s ON s.student_id = sa.student_id LEFT JOIN cohort_enrollments e ON e.student_id = s.student_id WHERE sa.result = 'passed' ${cohortFilter} ORDER BY sa.completed_at DESC LIMIT 10` ); const recentFailures = await pool.query( `SELECT student_id, lab_host_id, status, error_reason, started_at FROM environments WHERE status = 'error' ORDER BY started_at DESC NULLS LAST LIMIT 10` ); res.json({ active_cohorts: cohorts.rows[0].n, total_students: students.rows[0].n, labs: { running: envCounts.running || 0, provisioning: (envCounts.requested || 0) + (envCounts.provisioning || 0) + (envCounts.resetting || 0), error: envCounts.error || 0, idle_or_destroyed: envCounts.destroyed || 0, }, recent_completions: recentCompletions.rows, recent_failures: scoped ? [] : recentFailures.rows, // env-level failures aren't cohort-scoped data; keep them admin/owner-only }); }); // ── Students ───────────────────────────────────────────────────────────────────────── 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 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`); res.json(rows); }); admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => { const { display_name, cohort_id } = 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()]); 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() }); }); admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => { const { display_name, status } = req.body || {}; const fields = [], values = []; if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name); } if (status && ['active', 'inactive'].includes(status)) { fields.push(`status = $${fields.length + 1}`); values.push(status); } if (!fields.length) return res.status(400).json({ error: 'nothing to update' }); fields.push(`updated_at = now()`); values.push(req.params.id); const { rowCount } = await getPool().query(`UPDATE students SET ${fields.join(', ')} WHERE student_id = $${values.length}`, values); if (!rowCount) return res.status(404).json({ error: 'not found' }); res.json({ ok: true }); }); admin.get('/api/admin/students/:id/progress', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => { const progress = await loadProgress(getPool(), req.params.id); const path = curriculum.loadCurriculumPath(); const scenarios = curriculum.loadScenarios(); if (!path) return res.json({ progress }); const stages = path.stages.map((stage) => ({ id: stage.id, name: stage.name, scenarios: stage.scenario_ids.map((sid) => ({ id: sid, title: scenarios.find((s) => s.id === sid)?.title || sid, status: progress[sid]?.status || 'not_started', })), })); res.json({ stages }); }); admin.get('/api/admin/students/:id/attempts', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => { const { rows } = await getPool().query( `SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.params.id] ); res.json(rows); }); admin.get('/api/admin/students/:id/environment', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => { const { status, body } = await provisioner.listForStudent(req.params.id); if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' }); res.json(body); }); admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => { const { status, body } = await provisioner.provision(req.params.id); res.status(status === 409 ? 200 : status).json(body); }); admin.post('/api/admin/students/:id/cohort', requireAdminOrOwner, async (req, res) => { const { cohort_id } = req.body || {}; if (!cohort_id) return res.status(400).json({ error: 'cohort_id required' }); await getPool().query( `INSERT INTO cohort_enrollments (cohort_id, student_id, enrollment_status) VALUES ($1, $2, 'enrolled') ON CONFLICT (cohort_id, student_id) DO UPDATE SET enrollment_status = 'enrolled'`, [cohort_id, req.params.id] ); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_change', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id, op: 'add' } }); res.json({ ok: true }); }); admin.delete('/api/admin/students/:id/cohort/:cohortId', requireAdminOrOwner, async (req, res) => { await getPool().query( `UPDATE cohort_enrollments SET enrollment_status = 'removed' WHERE cohort_id = $1 AND student_id = $2`, [req.params.cohortId, req.params.id] ); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_change', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id: req.params.cohortId, op: 'remove' } }); res.json({ ok: true }); }); // ── Cohorts ────────────────────────────────────────────────────────────────────────── admin.get('/api/admin/cohorts', requireAnyInstructor, async (req, res) => { const pool = getPool(); const { rows } = req.instructor.role === 'instructor' ? await pool.query(` SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count FROM cohorts c JOIN cohort_instructors ci ON ci.cohort_id = c.cohort_id AND ci.instructor_id = $1 LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id GROUP BY c.cohort_id ORDER BY c.created_at DESC`, [req.instructor.instructor_id]) : await pool.query(` SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count FROM cohorts c LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id GROUP BY c.cohort_id ORDER BY c.created_at DESC`); res.json(rows); }); admin.post('/api/admin/cohorts', requireAdminOrOwner, async (req, res) => { const { cohort_id, name, tier, start_date, end_date, seat_target } = req.body || {}; if (!cohort_id || !name) return res.status(400).json({ error: 'cohort_id and name are required' }); try { await getPool().query( `INSERT INTO cohorts (cohort_id, name, tier, start_date, end_date, seat_target) VALUES ($1, $2, $3, $4, $5, $6)`, [cohort_id, name, tier || null, start_date || null, end_date || null, seat_target || null] ); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'cohort_id already exists' }); throw e; } await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'cohort_create', resourceType: 'cohort', resourceId: cohort_id, result: 'success', sourceIp: req.ip }); res.status(201).json({ cohort_id }); }); admin.patch('/api/admin/cohorts/:id', requireAdminOrOwner, async (req, res) => { const { name, tier, start_date, end_date, status, seat_target } = req.body || {}; const map = { name, tier, start_date, end_date, status, seat_target }; const fields = [], values = []; for (const [k, v] of Object.entries(map)) { if (v !== undefined) { fields.push(`${k} = $${fields.length + 1}`); values.push(v); } } if (!fields.length) return res.status(400).json({ error: 'nothing to update' }); fields.push('updated_at = now()'); values.push(req.params.id); const { rowCount } = await getPool().query(`UPDATE cohorts SET ${fields.join(', ')} WHERE cohort_id = $${values.length}`, values); if (!rowCount) return res.status(404).json({ error: 'not found' }); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'cohort_update', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: map }); res.json({ ok: true }); }); // Assign an instructor to a cohort (admin/owner only — this IS the scoping mechanism, so a // plain instructor can't grant themselves or anyone else access to a cohort). admin.post('/api/admin/cohorts/:id/instructors', requireAdminOrOwner, async (req, res) => { const { instructor_id, role_in_cohort } = req.body || {}; if (!instructor_id) return res.status(400).json({ error: 'instructor_id required' }); await getPool().query( `INSERT INTO cohort_instructors (cohort_id, instructor_id, role_in_cohort) VALUES ($1, $2, $3) ON CONFLICT (cohort_id, instructor_id) DO UPDATE SET role_in_cohort = EXCLUDED.role_in_cohort`, [req.params.id, instructor_id, role_in_cohort || 'teacher'] ); res.json({ ok: true }); }); admin.get('/api/admin/cohorts/:id/students', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => { const { rows } = await getPool().query( `SELECT s.student_id, s.display_name, s.status, s.last_active_at, e.enrolled_at FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.params.id] ); res.json(rows); }); admin.post('/api/admin/cohorts/:id/curriculum', requireAdminOrOwner, async (req, res) => { const { curriculum_path } = req.body || {}; await getPool().query( `INSERT INTO curriculum_assignments (cohort_id, curriculum_path) VALUES ($1, $2)`, [req.params.id, curriculum_path || 'rootnode-path'] ); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'curriculum_assign', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { curriculum_path } }); res.json({ ok: true }); }); admin.get('/api/admin/cohorts/:id/progress', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => { const pool = getPool(); const { rows: students } = await pool.query( `SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`, [req.params.id] ); const path = curriculum.loadCurriculumPath(); if (!path) return res.json({ stages: [] }); const stageTotals = path.stages.map((s) => ({ id: s.id, name: s.name, total: s.scenario_ids.length * students.length, completed: 0 })); for (const { student_id } of students) { const progress = await loadProgress(pool, student_id); path.stages.forEach((stage, i) => { stageTotals[i].completed += stage.scenario_ids.filter((sid) => progress[sid]?.status === 'completed').length; }); } res.json({ student_count: students.length, stages: stageTotals.map((s) => ({ ...s, pct: s.total > 0 ? Math.round((s.completed / s.total) * 100) : 0 })), }); }); // ── Classroom prep: Prepare Labs / Destroy Cohort Labs ────────────────────────────────── admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortAccess('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 }), }); }); admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => { const { rows: students } = await getPool().query( `SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`, [req.params.id] ); const results = await Promise.allSettled(students.map(async (s) => { const { body } = await provisioner.listForStudent(s.student_id); const active = body?.find((e) => e.status !== 'destroyed'); if (!active) return { student_id: s.student_id, status: 'none' }; await provisioner.destroy(active.environment_id); return { student_id: s.student_id, status: 'destroyed' }; })); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'destroy_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } }); // Academic progress is Postgres-side and untouched by any of this. res.json({ results: results.map((r) => r.status === 'fulfilled' ? r.value : { status: 'error', error: r.reason?.message }) }); }); // ── Instructor environment controls (by raw environment id — admin/owner only; a plain // instructor operates on Labs via the cohort/student-scoped routes above instead, since // resolving a bare environment id back to a cohort would need an extra Provisioner round // trip on every call — a documented simplification, see docs/PHASE3A...) ─────────────────── admin.post('/api/admin/environments/:id/reset', requireAdminOrOwner, async (req, res) => { const { status, body } = await provisioner.reset(req.params.id); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_reset', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip }); res.status(status).json(body); }); admin.delete('/api/admin/environments/:id', requireAdminOrOwner, async (req, res) => { const { status, body } = await provisioner.destroy(req.params.id); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_destroy', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip }); res.status(status).json(body); }); admin.get('/api/admin/environments/:id/events', requireAdminOrOwner, async (req, res) => { const { status, body } = await provisioner.events(req.params.id); res.status(status).json(body); }); // ── Instructor accounts — owner only ──────────────────────────────────────────────────── admin.get('/api/admin/instructors', requireOwner, async (req, res) => { res.json(await listInstructors(getPool())); }); admin.post('/api/admin/instructors', requireOwner, async (req, res) => { const { email, display_name, role, password } = req.body || {}; if (!email || !display_name || !password) return res.status(400).json({ error: 'email, display_name, and password are required' }); if (password.length < 12) return res.status(400).json({ error: 'password must be at least 12 characters' }); if (role && !['owner', 'admin', 'instructor'].includes(role)) return res.status(400).json({ error: 'invalid role' }); let instructor; try { instructor = await createInstructor(getPool(), { email, displayName: display_name, role, password }); } catch (e) { if (e.code === '23505') return res.status(409).json({ error: 'email already in use' }); throw e; } await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'instructor_create', resourceType: 'instructor', resourceId: instructor.instructor_id, result: 'success', sourceIp: req.ip, metadata: { email: instructor.email, role: instructor.role } }); const { password_hash, ...pub } = instructor; res.status(201).json(pub); }); admin.get('/api/admin/instructors/:id', requireOwner, async (req, res) => { const { rows } = await getPool().query(`SELECT * FROM instructors WHERE instructor_id = $1`, [req.params.id]); if (!rows[0]) return res.status(404).json({ error: 'not found' }); const { password_hash, ...pub } = rows[0]; const sessionsList = await require('./sessions').listInstructorSessions(getPool(), req.params.id); res.json({ ...pub, sessions: sessionsList }); }); admin.patch('/api/admin/instructors/:id', requireOwner, async (req, res) => { const { display_name, role, status } = req.body || {}; const pool = getPool(); if (role && !['owner', 'admin', 'instructor'].includes(role)) return res.status(400).json({ error: 'invalid role' }); if (status && !['active', 'inactive'].includes(status)) return res.status(400).json({ error: 'invalid status' }); const { rows: targetRows } = await pool.query(`SELECT * FROM instructors WHERE instructor_id = $1`, [req.params.id]); const target = targetRows[0]; if (!target) return res.status(404).json({ error: 'not found' }); const demotingFromOwner = target.role === 'owner' && role && role !== 'owner'; const deactivatingOwner = target.role === 'owner' && status === 'inactive'; if (demotingFromOwner || deactivatingOwner) { try { await assertNotLastOwner(pool, req.params.id); } catch (e) { if (e.code === 'LAST_OWNER') return res.status(400).json({ error: 'last_owner', message: e.message }); throw e; } } const fields = [], values = []; if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name.trim()); } if (role) { fields.push(`role = $${fields.length + 1}`); values.push(role); } if (status) { fields.push(`status = $${fields.length + 1}`); values.push(status); } if (!fields.length) return res.status(400).json({ error: 'nothing to update' }); fields.push('updated_at = now()'); values.push(req.params.id); await pool.query(`UPDATE instructors SET ${fields.join(', ')} WHERE instructor_id = $${values.length}`, values); await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'instructor_update', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { display_name, role, status } }); res.json({ ok: true }); }); admin.post('/api/admin/instructors/:id/revoke-sessions', requireOwner, async (req, res) => { await require('./sessions').revokeAllInstructorSessions(getPool(), req.params.id); await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'sessions_revoked', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); res.json({ ok: true }); }); admin.post('/api/admin/instructors/:id/reset-password', requireOwner, async (req, res) => { const { new_password } = req.body || {}; if (!new_password || new_password.length < 12) return res.status(400).json({ error: 'new_password must be at least 12 characters' }); const { hashPassword } = require('./auth/password'); const pool = getPool(); const { rowCount } = await pool.query(`UPDATE instructors SET password_hash = $1, updated_at = now() WHERE instructor_id = $2`, [hashPassword(new_password), req.params.id]); if (!rowCount) return res.status(404).json({ error: 'not found' }); await require('./sessions').revokeAllInstructorSessions(pool, req.params.id); // force re-login everywhere with the new password await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'password_reset', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip }); res.json({ ok: true }); }); // ── Audit log — admin/owner ────────────────────────────────────────────────────────────── admin.get('/api/admin/audit-events', requireAdminOrOwner, async (req, res) => { const { rows } = await getPool().query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT 200`); res.json(rows); }); };