Files
rootnode-academy/platform/api/src/rbac.js
T
ced4568 611e1ac4ba feat(enrollment): enrollment lifecycle, entitlement gating, self-paced student API
- enrollment.js: server-only status transitions (pending->active->completed, cancel),
  deriveEnrollmentAccess (pure, derived-at-read-time like classroom.js's derivePresence —
  'expired' is never stored, computed from access_ends_at), maybeCompleteEnrollment
  (idempotent, server-derived from the existing capstone achievement).
- environment-view.js: publicEnvView/launchEnvironmentForStudent extracted out of server.js's
  original /api/environment/launch route body so the new entitlement-gated self-paced launch
  reuses the identical disposable-Lab sequence instead of a second copy — behavior for the
  existing cohort/pilot route is completely unchanged.
- program-status.js: combines an enrollment with real curriculum/progress state into one view
  (access, percent-complete, current stage, deterministic next-activity/resume target) — shared
  by the student and admin surfaces so both show identical information.
- selfpaced-routes.js: GET /api/student/programs(/:id), POST .../launch — entitlement enforced
  server-side before ever touching the Provisioner, never just hidden in the UI.
- rbac.js: requireEnrollmentAccess — a plain instructor may manage an enrollment they created
  or that belongs to a student sharing one of their cohorts; a self-paced enrollment can have
  no cohort at all, so the existing cohort-only checks can't cover it.
- server.js: found and fixed a real bug — POST /api/progress/record's handler predated the
  wrap() convention, so an unexpected internal error (a malformed environment_id) crashed the
  entire process instead of just 500ing that request. Now wrapped + input-validated.
2026-08-25 12:08:12 -05:00

222 lines
11 KiB
JavaScript

// 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 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,
};
};