// 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'); // Core check, factored out so both the cohort_id-param routes below AND the // classroom_session_id-keyed routes (Phase 5A §24, further down) can share one rule instead // of two copies of the same query drifting apart over time. async function checkCohortAccess(req, res, cohortId) { const instructor = req.instructor; if (['owner', 'admin'].includes(instructor.role)) return true; const { rowCount } = await pool.query( `SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [cohortId, instructor.instructor_id] ); if (!rowCount) { await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied', resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip, }); res.status(403).json({ error: 'forbidden' }); return false; } return true; } // Cohort-scoped access: owner/admin bypass (global teaching visibility by role alone). A // plain 'instructor' must have a cohort_instructors row for this cohort_id, or gets 403 — // same as any other authorization failure, no distinction made between "not your cohort" and // "cohort doesn't exist" in the response. function requireCohortAccess(cohortIdParam = 'id') { return async (req, res, next) => { if (await checkCohortAccess(req, res, req.params[cohortIdParam])) 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(); }; } // Phase 4.1 §19 — cohort_instructors.role_in_cohort existed since Phase 2B but was never // read anywhere (a misleading field: it looked authoritative but did nothing). Minimal // useful model: 'teacher' (default, existing full instructor capability within the cohort) // vs 'viewer' (read-only — sees dashboards/progress/analytics via requireCohortAccess above, // but cannot Prepare/Destroy Labs or provision a student's environment). owner/admin still // bypass entirely, same as every other cohort-scoped check. async function checkCohortWriteAccess(req, res, cohortId) { const instructor = req.instructor; if (['owner', 'admin'].includes(instructor.role)) return true; const { rows } = await pool.query( `SELECT role_in_cohort FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`, [cohortId, instructor.instructor_id] ); if (!rows.length || rows[0].role_in_cohort === 'viewer') { await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied', resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip, metadata: rows.length ? { reason: 'viewer_role' } : undefined, }); res.status(403).json({ error: 'forbidden' }); return false; } return true; } function requireCohortWriteAccess(cohortIdParam = 'id') { return async (req, res, next) => { if (await checkCohortWriteAccess(req, res, req.params[cohortIdParam])) next(); }; } // Same, keyed by student_id — resolves through cohort_enrollments the same way // requireStudentCohortAccess does, then applies the same viewer check. function requireStudentCohortWriteAccess(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 { rows } = await pool.query( `SELECT ci.role_in_cohort 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 (!rows.length || rows.every((r) => r.role_in_cohort === 'viewer')) { await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied', resourceType: 'student', resourceId: studentId, result: 'failure', sourceIp: req.ip, metadata: rows.length ? { reason: 'viewer_role' } : undefined, }); return res.status(403).json({ error: 'forbidden' }); } next(); }; } // Phase 5A §24 — classroom routes are keyed by classroom_session_id, not cohort_id directly, // so this resolves the classroom's cohort first (404 if it doesn't exist — a route param // isn't proof the resource exists) and then applies the exact same rule // requireCohortAccess/requireCohortWriteAccess do. Stashes the resolved cohort_id on the // request (req.classroomCohortId) so handlers that need it don't re-query. function requireClassroomAccess(classroomIdParam = 'id') { return async (req, res, next) => { const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]); if (!rows.length) return res.status(404).json({ error: 'not found' }); req.classroomCohortId = rows[0].cohort_id; if (await checkCohortAccess(req, res, rows[0].cohort_id)) next(); }; } function requireClassroomWriteAccess(classroomIdParam = 'id') { return async (req, res, next) => { const { rows } = await pool.query(`SELECT cohort_id FROM classroom_sessions WHERE classroom_session_id = $1`, [req.params[classroomIdParam]]); if (!rows.length) return res.status(404).json({ error: 'not found' }); req.classroomCohortId = rows[0].cohort_id; if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next(); }; } // Same idea, one join further — help_requests routes are keyed by help_request_id, resolved // through classroom_sessions to the cohort. Used by the acknowledge/resolve routes. function requireHelpRequestWriteAccess(helpRequestIdParam = 'id') { return async (req, res, next) => { const { rows } = await pool.query( `SELECT cs.cohort_id, hr.classroom_session_id FROM help_requests hr JOIN classroom_sessions cs ON cs.classroom_session_id = hr.classroom_session_id WHERE hr.help_request_id = $1`, [req.params[helpRequestIdParam]] ); if (!rows.length) return res.status(404).json({ error: 'not found' }); req.classroomCohortId = rows[0].cohort_id; req.classroomSessionId = rows[0].classroom_session_id; if (await checkCohortWriteAccess(req, res, rows[0].cohort_id)) next(); }; } // Phase 5C §16 — same documented rule as requireEnrollmentAccess below, keyed by student_id // directly instead of resolving through one enrollment first: an instructor may see/manage a // student's notifications if they share a cohort with that student OR they created at least // one of that student's enrollments — otherwise 403, never blanket visibility into every // self-paced student's notifications ("Do not grant global notification visibility to every // instructor," §16). Used by the per-student notification list and the resend-activation/ // send-enrollment-confirmation/send-password-reset admin actions. async function checkStudentRelationshipAccess(req, res, studentId) { const instructor = req.instructor; if (['owner', 'admin'].includes(instructor.role)) return true; 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 UNION SELECT 1 FROM enrollments WHERE student_id = $1 AND created_by = $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, }); res.status(403).json({ error: 'forbidden' }); return false; } return true; } function requireStudentRelationshipAccess(studentIdParam = 'id') { return async (req, res, next) => { if (await checkStudentRelationshipAccess(req, res, req.params[studentIdParam])) next(); }; } // Phase 5B §11 — a self-paced enrollment may have NO cohort at all, so // requireStudentCohortAccess (which requires a cohort_enrollments row) can't gate it. The // documented safe rule for a plain instructor: they may see/manage an enrollment if EITHER // they already have cohort-based access to that student (the existing relationship, reused // as-is) OR they personally created the enrollment (enrollments.created_by) — never blanket // global visibility into every self-paced student. owner/admin bypass, same as everywhere // else. Resolves the enrollment first (404 if it doesn't exist) and stashes the owning // student_id on the request. function requireEnrollmentAccess(enrollmentIdParam = 'id') { return async (req, res, next) => { const { rows } = await pool.query(`SELECT student_id, created_by FROM enrollments WHERE enrollment_id = $1`, [req.params[enrollmentIdParam]]); if (!rows.length) return res.status(404).json({ error: 'not found' }); req.enrollmentStudentId = rows[0].student_id; const instructor = req.instructor; if (['owner', 'admin'].includes(instructor.role)) return next(); if (rows[0].created_by === instructor.instructor_id) return next(); 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`, [rows[0].student_id, instructor.instructor_id] ); if (!rowCount) { await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied', resourceType: 'enrollment', resourceId: req.params[enrollmentIdParam], result: 'failure', sourceIp: req.ip, }); return res.status(403).json({ error: 'forbidden' }); } next(); }; } return { requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess, requireCohortWriteAccess, requireStudentCohortWriteAccess, requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess, requireEnrollmentAccess, requireStudentRelationshipAccess, }; };