feat(student): admin student-account management + CSV import, session hardening
- admin-routes.js: create-with-credentials, /convert, /reset-password, /revoke-sessions, /sessions, /import (transactional CSV import — whole file validated, one Postgres transaction, never accepts a password/hash column from input). Fixed GET /api/admin/students to select an explicit safe column list instead of SELECT * — the new password_hash/credential_token_hash columns must never be exposed through this route. - server.js: /api/login now also blocks on credential_token_hash (not just password_hash) — closes a real window where a student mid-conversion stayed reachable via the old bare-student_id path. studentSession middleware now re-verifies status='active' on every request, not just at session creation, matching instructorSession's existing behavior. - sessions.js: revokeAllStudentSessions, listStudentSessions. - rbac.js: extracted checkCohortAccess/checkCohortWriteAccess as shared core functions so the new classroom/help-request RBAC (next commit) reuses the exact same authorization rule instead of a parallel copy. - student-csv.js + test: hand-rolled parser (no new dependency), unit tested. - prometheus.yml.j2: activated the platform-api scrape job.
This commit is contained in:
+81
-31
@@ -30,27 +30,34 @@ module.exports = function createRbac(pool) {
|
||||
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) => {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const cohortId = req.params[cohortIdParam];
|
||||
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,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
if (await checkCohortAccess(req, res, req.params[cohortIdParam])) next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,24 +91,27 @@ module.exports = function createRbac(pool) {
|
||||
// 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) => {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const cohortId = req.params[cohortIdParam];
|
||||
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,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
if (await checkCohortWriteAccess(req, res, req.params[cohortIdParam])) next();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,9 +140,49 @@ module.exports = function createRbac(pool) {
|
||||
};
|
||||
}
|
||||
|
||||
// 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();
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor,
|
||||
requireCohortAccess, requireStudentCohortAccess,
|
||||
requireCohortWriteAccess, requireStudentCohortWriteAccess,
|
||||
requireClassroomAccess, requireClassroomWriteAccess, requireHelpRequestWriteAccess,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user