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
@@ -0,0 +1,13 @@
-- cohort_instructors — scopes the 'instructor' role to specific cohorts. 'owner' and 'admin'
-- are never checked against this table (they have global teaching visibility by role alone,
-- see src/rbac.js) — this only matters for the plain 'instructor' role.
CREATE TABLE IF NOT EXISTS cohort_instructors (
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
instructor_id UUID NOT NULL REFERENCES instructors(instructor_id),
role_in_cohort TEXT NOT NULL DEFAULT 'teacher',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (cohort_id, instructor_id)
);
CREATE INDEX IF NOT EXISTS idx_cohort_instructors_instructor ON cohort_instructors(instructor_id);