- 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.
222 lines
11 KiB
JavaScript
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,
|
|
};
|
|
};
|