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.
This commit is contained in:
@@ -179,10 +179,43 @@ module.exports = function createRbac(pool) {
|
||||
};
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user