feat(rbac): enforce owner/admin/instructor permissions across the admin API

Three roles (owner > admin > instructor), enforced server-side on every /api/admin/* route
via reusable middleware (requireOwner/requireAdminOrOwner/requireAnyInstructor) — the
frontend hides controls a role can't use, but that's UX, never the actual boundary. A plain
'instructor' is further scoped to cohorts assigned via the new cohort_instructors table
(requireCohortAccess/requireStudentCohortAccess); owner/admin bypass that check entirely.

Owner-only: instructor account lifecycle, audit log. Admin/owner: cohort/student CRUD,
curriculum assignment. Any instructor role (cohort-scoped for 'instructor'): dashboard,
cohort/student views, Prepare/Destroy Labs, provisioning. Deliberately NOT cohort-scoped:
the raw /api/admin/environments/:id/* routes (no cohort context in a bare environment id
without an extra Provisioner round trip on every call) — a plain instructor still has full
Lab control via the cohort/student-scoped routes instead, which already carry the check.

server.js wires the new persistent-session middleware (studentSession/instructorSession —
re-fetches the instructor row from Postgres every request, so a role change or
deactivation takes effect on the very next request with no separate revoke needed) and adds
a last-resort error handler so a DB error in any async route returns 500 instead of
crashing the process or leaking a stack trace.
This commit is contained in:
2026-08-18 13:36:11 -05:00
parent 37230d4ef1
commit 62726455d5
4 changed files with 391 additions and 127 deletions
+82
View File
@@ -0,0 +1,82 @@
// 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');
// 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();
};
}
// 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();
};
}
return { requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess };
};