diff --git a/platform/api/migrations/010_cohort_instructors.sql b/platform/api/migrations/010_cohort_instructors.sql new file mode 100644 index 0000000..a2a436f --- /dev/null +++ b/platform/api/migrations/010_cohort_instructors.sql @@ -0,0 +1,13 @@ +-- cohort_instructors — scopes the 'instructor' role to specific cohorts. 'owner' and 'admin' +-- are never checked against this table (they have global teaching visibility by role alone, +-- see src/rbac.js) — this only matters for the plain 'instructor' role. + +CREATE TABLE IF NOT EXISTS cohort_instructors ( + cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id), + instructor_id UUID NOT NULL REFERENCES instructors(instructor_id), + role_in_cohort TEXT NOT NULL DEFAULT 'teacher', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (cohort_id, instructor_id) +); + +CREATE INDEX IF NOT EXISTS idx_cohort_instructors_instructor ON cohort_instructors(instructor_id); diff --git a/platform/api/src/admin-routes.js b/platform/api/src/admin-routes.js index 15c918b..e46e5d9 100644 --- a/platform/api/src/admin-routes.js +++ b/platform/api/src/admin-routes.js @@ -1,50 +1,71 @@ -// Instructor/Admin API — /api/admin/*. Every route here requires an admin session -// (adminSession middleware), completely separate from student sessions (different cookie, -// different token namespace, see sessions.js). Student sessions can never call these routes; -// admin sessions can never be used for /api/environment/* student routes either, since those -// require a *student* session specifically. +// 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. // -// This module owns every instructor-facing Provisioner interaction (prepare-labs, reset, -// destroy, events) — the Admin UI never talks to the Provisioner directly, same boundary as -// the student path (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §12). +// 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 { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress } = deps; + const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps; + const { requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac; - // ── Admin auth ─────────────────────────────────────────────────────────────────────── - app.post('/api/admin/login', (req, res) => { - const { password } = req.body || {}; - if (!verifyAdminPassword(password)) return res.status(401).json({ error: 'invalid_credentials' }); - const token = sessions.createAdminSession(); - res.append('Set-Cookie', `${ADMIN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=14400`); - res.json({ ok: true }); - }); - - app.post('/api/admin/logout', (req, res) => { - const header = req.headers.cookie || ''; - const token = Object.fromEntries(header.split(';').map((p) => p.trim().split('=')))[ADMIN_COOKIE]; - sessions.destroyAdminSession(token); - res.append('Set-Cookie', `${ADMIN_COOKIE}=; Path=/; HttpOnly; Max-Age=0`); - res.json({ ok: true }); - }); - - app.get('/api/admin/whoami', adminSession, (req, res) => res.json({ admin: true })); - - const admin = express_router_guard(app, adminSession); + // 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 ──────────────────────────────────────────────────────────────────────── - admin.get('/api/admin/dashboard', async (req, res) => { + // 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(`SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`), - pool.query(`SELECT count(*)::int AS n FROM students WHERE status = 'active'`), + 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 - WHERE sa.result = 'passed' ORDER BY sa.completed_at DESC LIMIT 10` + 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 @@ -60,17 +81,24 @@ module.exports = function registerAdminRoutes(app, deps) { idle_or_destroyed: envCounts.destroyed || 0, }, recent_completions: recentCompletions.rows, - recent_failures: recentFailures.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', async (req, res) => { - const { rows } = await getPool().query(`SELECT * FROM students ORDER BY display_name`); + 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', async (req, res) => { + 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(); @@ -83,10 +111,11 @@ module.exports = function registerAdminRoutes(app, deps) { [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', async (req, res) => { + 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); } @@ -99,7 +128,7 @@ module.exports = function registerAdminRoutes(app, deps) { res.json({ ok: true }); }); - admin.get('/api/admin/students/:id/progress', async (req, res) => { + 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(); @@ -114,25 +143,25 @@ module.exports = function registerAdminRoutes(app, deps) { res.json({ stages }); }); - admin.get('/api/admin/students/:id/attempts', async (req, res) => { + 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', async (req, res) => { + 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', async (req, res) => { + 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', async (req, res) => { + 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( @@ -140,28 +169,37 @@ module.exports = function registerAdminRoutes(app, deps) { 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', async (req, res) => { + 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', async (req, res) => { - const { rows } = await getPool().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 - `); + 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', async (req, res) => { + 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 { @@ -173,10 +211,11 @@ module.exports = function registerAdminRoutes(app, deps) { 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', async (req, res) => { + 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 = []; @@ -188,10 +227,24 @@ module.exports = function registerAdminRoutes(app, deps) { 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 }); }); - admin.get('/api/admin/cohorts/:id/students', async (req, res) => { + // 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 @@ -201,16 +254,17 @@ module.exports = function registerAdminRoutes(app, deps) { res.json(rows); }); - admin.post('/api/admin/cohorts/:id/curriculum', async (req, res) => { + 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', async (req, res) => { + 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'`, @@ -232,15 +286,12 @@ module.exports = function registerAdminRoutes(app, deps) { }); // ── Classroom prep: Prepare Labs / Destroy Cohort Labs ────────────────────────────────── - admin.post('/api/admin/cohorts/:id/prepare-labs', async (req, res) => { + 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] ); - // Provision concurrently — each is a real k3s boot (1-3 min); sequential would make a - // 20-student cohort prep take the better part of an hour. Promise.allSettled so one - // student's failure doesn't block reporting on the rest. 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)); @@ -249,12 +300,13 @@ module.exports = function registerAdminRoutes(app, deps) { 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', async (req, res) => { + 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] @@ -266,35 +318,113 @@ module.exports = function registerAdminRoutes(app, deps) { await provisioner.destroy(active.environment_id); return { student_id: s.student_id, status: 'destroyed' }; })); - // Academic progress is Postgres-side and untouched by any of this — destroying compute - // never touches the students/progress/scenario_attempts tables. + 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 ───────────────────────────────────────────────────── - admin.post('/api/admin/environments/:id/reset', async (req, res) => { + // ── 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', async (req, res) => { + 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', async (req, res) => { + 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); }); -}; -// Tiny helper so every admin.(...) call below doesn't need `adminSession` repeated -// as a second argument by hand — same effect, less visual noise. -function express_router_guard(app, adminSession) { - return { - get: (p, h) => app.get(p, adminSession, h), - post: (p, h) => app.post(p, adminSession, h), - patch: (p, h) => app.patch(p, adminSession, h), - delete: (p, h) => app.delete(p, adminSession, h), - }; -} + // ── 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); + }); +}; diff --git a/platform/api/src/rbac.js b/platform/api/src/rbac.js new file mode 100644 index 0000000..c242947 --- /dev/null +++ b/platform/api/src/rbac.js @@ -0,0 +1,82 @@ +// Role-based authorization — enforced server-side only. The admin frontend hides controls a +// role can't use, but that's UX, not security: every route below re-checks on the server +// regardless of what the browser sent or hid. +// +// Roles: owner > admin > instructor. See docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for +// the full permission matrix. Deny reasons are never leaked in the response body — a 403 just +// says "forbidden," not which check failed or whether the resource exists. + +const { recordAudit } = require('./audit'); + +// Factory bound to a pool (for audit logging) rather than a bare export — every guard here +// needs to write an audit_events row on denial, so they all need the pool. +module.exports = function createRbac(pool) { + function requireRole(...allowedRoles) { + return (req, res, next) => { + if (!req.instructor) return res.status(401).json({ error: 'no_session' }); + if (!allowedRoles.includes(req.instructor.role)) { + recordAudit(pool, { + actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'authz_denied', + resourceType: 'route', resourceId: `${req.method} ${req.path}`, result: 'failure', sourceIp: req.ip, + metadata: { required: allowedRoles, actual: req.instructor.role }, + }); + return res.status(403).json({ error: 'forbidden' }); + } + next(); + }; + } + + const requireOwner = requireRole('owner'); + const requireAdminOrOwner = requireRole('owner', 'admin'); + const requireAnyInstructor = requireRole('owner', 'admin', 'instructor'); + + // 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(); + }; + } + + // Same idea, for routes keyed by student_id — checks whether the student is enrolled in ANY + // cohort this instructor is assigned to. + function requireStudentCohortAccess(studentIdParam = 'id') { + return async (req, res, next) => { + const instructor = req.instructor; + if (['owner', 'admin'].includes(instructor.role)) return next(); + const studentId = req.params[studentIdParam]; + const { rowCount } = await pool.query( + `SELECT 1 FROM cohort_enrollments e + JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id + WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2`, + [studentId, instructor.instructor_id] + ); + if (!rowCount) { + await recordAudit(pool, { + actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied', + resourceType: 'student', resourceId: studentId, result: 'failure', sourceIp: req.ip, + }); + return res.status(403).json({ error: 'forbidden' }); + } + next(); + }; + } + + return { requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess }; +}; diff --git a/platform/api/src/server.js b/platform/api/src/server.js index a0506ce..4a6e6ec 100644 --- a/platform/api/src/server.js +++ b/platform/api/src/server.js @@ -1,60 +1,77 @@ -// RootNode Academy — Platform API (Phase 2B). +// RootNode Academy — Platform API (Phase 3A revision). // // Lightweight service: no k3s, no --privileged, no Lab terminal runtime. Owns students, -// cohorts, enrollments, curriculum assignments, progress, scenario attempts, student/admin -// session identity, and all Provisioner (environment orchestration) calls — both the -// student-facing launch/reset path and the instructor/admin control plane. See -// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md. +// cohorts, enrollments, curriculum assignments, progress, scenario attempts, instructor +// accounts, persistent student/instructor sessions, RBAC, audit logging, and all Provisioner +// (environment orchestration) calls. See docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md. // -// Reached only through the Gateway (platform/gateway) — never directly by browser JS, the -// same trust boundary the Provisioner already has relative to this service. +// Reached only through the Gateway (platform/gateway) — never directly by browser JS. const express = require('express'); const path = require('path'); const { getPool, runMigrations } = require('./db'); const sessions = require('./sessions'); -const { verifyAdminPassword } = require('./admin-auth'); +const { getActiveInstructor } = require('./instructors'); const { provisioner } = require('./provisioner-client'); const curriculum = require('./curriculum'); const { loadProgress, saveOneProgress, touchStudent } = require('./progress'); +const { recordAudit } = require('./audit'); +const { parseCookies, checkCsrf, makeCsrfToken } = require('./security'); +const { buildCookie, clearCookie } = require('./cookies'); +const createRbac = require('./rbac'); +const { INSTRUCTOR_COOKIE, CSRF_COOKIE: INSTRUCTOR_CSRF_COOKIE } = require('./instructor-auth-routes'); const PORT = Number(process.env.PLATFORM_API_PORT) || 4300; const STUDENT_COOKIE = 'rn_session'; -const ADMIN_COOKIE = 'rn_admin'; +const STUDENT_CSRF_COOKIE = 'rn_csrf_s'; +// Deliberate, not automatic: honoring X-Forwarded-For for req.ip (audit source_ip, login rate +// limiting) is only correct when there's a trusted reverse proxy in front of this process. Off +// by default (direct connections / dev); set TRUST_PROXY=1 once fronted by one in production. const app = express(); +if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1); + app.use(express.json()); const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist'); app.use(express.static(FRONTEND_DIST)); -function parseCookies(req) { - const header = req.headers.cookie; - if (!header) return {}; - return Object.fromEntries( - header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)]) - ); -} - // ── Student session middleware ────────────────────────────────────────────────────────── -// This is the actual fix for the Phase 2A pilot ?sid= mechanism: student_id is derived ONLY -// from an opaque, server-issued session cookie the browser cannot fabricate — never from a -// header or query parameter a client controls. See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3. -function studentSession(req, res, next) { +// 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. +async function studentSession(req, res, next) { const token = parseCookies(req)[STUDENT_COOKIE]; - const studentId = sessions.resolveStudentSession(token); + const studentId = await sessions.resolveStudentSession(getPool(), token); if (!studentId) 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; next(); } -function adminSession(req, res, next) { - const token = parseCookies(req)[ADMIN_COOKIE]; - if (!sessions.resolveAdminSession(token)) { - return res.status(401).json({ error: 'no_admin_session', message: 'Instructor login required.' }); +// ── Instructor session middleware ─────────────────────────────────────────────────────── +// Re-fetches the instructor row from Postgres on every request rather than trusting anything +// cached in the session — a role change or deactivation takes effect on the very next request, +// with no separate "revoke sessions on role change" mechanism needed. +async function instructorSession(req, res, next) { + const token = parseCookies(req)[INSTRUCTOR_COOKIE]; + const instructorId = await sessions.resolveInstructorSession(getPool(), token); + if (!instructorId) return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' }); + const instructor = await getActiveInstructor(getPool(), instructorId); + if (!instructor) { + await sessions.destroyInstructorSession(getPool(), token); // deactivated since the session was issued + return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' }); } + if (!checkCsrf(req, INSTRUCTOR_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' }); + req.instructor = instructor; + req.sessionToken = token; next(); } +function wrap(fn) { + return (req, res, next) => fn(req, res, next).catch(next); +} + // ═══════════════════════════════════════════════════════════════════════════════════════ // STUDENT-FACING: roster / login / session // ═══════════════════════════════════════════════════════════════════════════════════════ @@ -81,27 +98,38 @@ app.get('/api/roster', async (req, res) => { res.json(rows); }); +// Roster name-picking is CLASSROOM/PILOT IDENTIFICATION, not production authentication — there +// is no password or possession factor here, only "does this student_id exist." What Phase 3A +// hardens is everything AFTER that pick: the resulting session is server-controlled, persistent, +// 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. 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 student = rows[0]; if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' }); await touchStudent(getPool(), student.student_id); - const token = sessions.createStudentSession(student.student_id); - res.append('Set-Cookie', `${STUDENT_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`); + const token = await sessions.createStudentSession(getPool(), student.student_id); + const csrf = makeCsrfToken(); + res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, token, { maxAgeSec: 8 * 3600 })); + res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: 8 * 3600, httpOnly: false })); res.json({ student_id: student.student_id, display_name: student.display_name }); }); -app.post('/api/logout', (req, res) => { +app.post('/api/logout', async (req, res) => { + if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' }); const token = parseCookies(req)[STUDENT_COOKIE]; - sessions.destroyStudentSession(token); - res.append('Set-Cookie', `${STUDENT_COOKIE}=; Path=/; HttpOnly; Max-Age=0`); + await sessions.destroyStudentSession(getPool(), token); + res.append('Set-Cookie', clearCookie(STUDENT_COOKIE)); + res.append('Set-Cookie', clearCookie(STUDENT_CSRF_COOKIE)); res.json({ ok: true }); }); // GET /api/session/whoami — used by the Gateway to resolve "who is this browser" for Lab -// ownership checks (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3). Also usable by the frontend. -app.get('/api/session/whoami', studentSession, (req, res) => { +// ownership checks. Read-only, so it isn't behind the CSRF check (studentSession only enforces +// CSRF on non-GET methods — see checkCsrf). +app.get('/api/session/whoami', wrap(studentSession), (req, res) => { res.json({ student_id: req.studentId }); }); @@ -109,7 +137,7 @@ app.get('/api/session/whoami', studentSession, (req, res) => { // STUDENT-FACING: curriculum / progress // ═══════════════════════════════════════════════════════════════════════════════════════ -app.get('/api/curriculum/path', studentSession, async (req, res) => { +app.get('/api/curriculum/path', wrap(studentSession), async (req, res) => { const path = curriculum.loadCurriculumPath(); if (!path) return res.status(404).json({ error: 'Curriculum path not found' }); const scenarios = curriculum.loadScenarios(); @@ -130,7 +158,7 @@ app.get('/api/curriculum/path', studentSession, async (req, res) => { res.json({ id: path.id, name: path.name, tagline: path.tagline, stages, next_stage: firstIncompleteStage }); }); -app.get('/api/progress', studentSession, async (req, res) => { +app.get('/api/progress', wrap(studentSession), async (req, res) => { const scenarios = curriculum.loadScenarios(); const progress = await loadProgress(getPool(), req.studentId); const total = scenarios.length; @@ -138,12 +166,7 @@ app.get('/api/progress', studentSession, async (req, res) => { res.json({ total, completed, details: progress }); }); -// POST /api/progress/record — Phase 2A's progress relay, now Postgres-backed and also logging -// a scenario_attempts row (new in Phase 2B — real per-attempt history, not just aggregate -// status). Called by the Gateway after a real validate/answer succeeded against the student's -// own Lab — never re-runs validation itself. See relayScenarioOutcome in -// platform/gateway/src/server.js. -app.post('/api/progress/record', studentSession, async (req, res) => { +app.post('/api/progress/record', wrap(studentSession), async (req, res) => { const { scenario_id, status, environment_id, validation_count } = req.body || {}; if (!scenario_id || !['completed', 'in_progress'].includes(status)) { return res.status(400).json({ error: 'scenario_id and a valid status are required' }); @@ -166,8 +189,7 @@ app.post('/api/progress/record', studentSession, async (req, res) => { }); // ═══════════════════════════════════════════════════════════════════════════════════════ -// STUDENT-FACING: environment lifecycle (moved here from the Gateway in Phase 2A — Phase 2B -// consolidates ALL Provisioner access into this one service) +// STUDENT-FACING: environment lifecycle // ═══════════════════════════════════════════════════════════════════════════════════════ async function publicEnvView(env) { @@ -185,14 +207,14 @@ async function publicEnvView(env) { return view; } -app.get('/api/environment/status', studentSession, async (req, res) => { +app.get('/api/environment/status', wrap(studentSession), async (req, res) => { const { status, body } = await provisioner.listForStudent(req.studentId); if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' }); const active = body.find((e) => !['destroyed', 'error'].includes(e.status)) || body.find((e) => e.status === 'error'); res.json(await publicEnvView(active)); }); -app.post('/api/environment/launch', studentSession, async (req, res) => { +app.post('/api/environment/launch', wrap(studentSession), async (req, res) => { const existing = await provisioner.listForStudent(req.studentId); const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status)); if (active) return res.json(await publicEnvView(active)); @@ -206,7 +228,7 @@ app.post('/api/environment/launch', studentSession, async (req, res) => { res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token })); }); -app.post('/api/environment/reset', studentSession, async (req, res) => { +app.post('/api/environment/reset', wrap(studentSession), async (req, res) => { const existing = await provisioner.listForStudent(req.studentId); const env = existing.body?.find((e) => e.status !== 'destroyed'); if (!env) return res.status(404).json({ error: 'no_environment' }); @@ -215,24 +237,41 @@ app.post('/api/environment/reset', studentSession, async (req, res) => { res.json(await publicEnvView(body)); }); -// Internal — the Gateway calls this to resolve a routing token to its owning environment -// (student_id, internal_ip, status) for Lab proxy ownership checks. Same trust boundary as -// the Provisioner's own by-token endpoint (internal callers only, never the browser). +// Internal — the Gateway calls this to resolve a routing token to its owning environment. app.get('/api/environment/by-token/:token', async (req, res) => { const { status, body } = await provisioner.getEnvironmentByToken(req.params.token); res.status(status).json(body); }); -require('./admin-routes')(app, { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress, saveOneProgress }); +// ═══════════════════════════════════════════════════════════════════════════════════════ +// INSTRUCTOR-FACING: auth + admin API +// ═══════════════════════════════════════════════════════════════════════════════════════ + +require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) }); + +require('./admin-routes')(app, { + instructorSession: wrap(instructorSession), rbac: createRbac(getPool()), provisioner, curriculum, + getPool, loadProgress, saveOneProgress, 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')); }); +// Last-resort error handler — an unhandled DB error (or anything else) in any async route +// reaches here via next(err) instead of crashing the process or leaking a stack trace to the +// client. Every new async route/middleware added in Phase 3A funnels through this. +app.use((err, req, res, next) => { + console.error('[unhandled route error]', err); + if (res.headersSent) return next(err); + res.status(500).json({ error: 'internal_error' }); +}); + async function main() { const applied = await runMigrations(); if (applied.length) console.log('Applied migrations:', applied.join(', ')); + sessions.startSessionSweeper(getPool()); app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`)); }