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:
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user