feat(rbac): enforce owner/admin/instructor permissions across the admin API

Three roles (owner > admin > instructor), enforced server-side on every /api/admin/* route
via reusable middleware (requireOwner/requireAdminOrOwner/requireAnyInstructor) — the
frontend hides controls a role can't use, but that's UX, never the actual boundary. A plain
'instructor' is further scoped to cohorts assigned via the new cohort_instructors table
(requireCohortAccess/requireStudentCohortAccess); owner/admin bypass that check entirely.

Owner-only: instructor account lifecycle, audit log. Admin/owner: cohort/student CRUD,
curriculum assignment. Any instructor role (cohort-scoped for 'instructor'): dashboard,
cohort/student views, Prepare/Destroy Labs, provisioning. Deliberately NOT cohort-scoped:
the raw /api/admin/environments/:id/* routes (no cohort context in a bare environment id
without an extra Provisioner round trip on every call) — a plain instructor still has full
Lab control via the cohort/student-scoped routes instead, which already carry the check.

server.js wires the new persistent-session middleware (studentSession/instructorSession —
re-fetches the instructor row from Postgres every request, so a role change or
deactivation takes effect on the very next request with no separate revoke needed) and adds
a last-resort error handler so a DB error in any async route returns 500 instead of
crashing the process or leaking a stack trace.
This commit is contained in:
2026-08-18 13:36:11 -05:00
parent 37230d4ef1
commit 62726455d5
4 changed files with 391 additions and 127 deletions
+89 -50
View File
@@ -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}`));
}