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
+207 -77
View File
@@ -1,50 +1,71 @@
// Instructor/Admin API — /api/admin/*. Every route here requires an admin session
// (adminSession middleware), completely separate from student sessions (different cookie,
// different token namespace, see sessions.js). Student sessions can never call these routes;
// admin sessions can never be used for /api/environment/* student routes either, since those
// require a *student* session specifically.
// Instructor/Admin API — /api/admin/*. Every route requires an instructor session
// (instructorSession) plus a role check (rbac.js) — completely separate from student sessions
// (different cookie, different token namespace, see sessions.js). Student sessions can never
// call these routes.
//
// This module owns every instructor-facing Provisioner interaction (prepare-labs, reset,
// destroy, events) — the Admin UI never talks to the Provisioner directly, same boundary as
// the student path (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §12).
// Permission shape (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for the full matrix):
// owner — everything, including instructor account lifecycle and audit log.
// admin — cohorts/students/curriculum/labs, global teaching visibility. No instructor
// account administration.
// instructor — same operational actions, but scoped to cohorts they're assigned to via
// cohort_instructors (requireCohortAccess / requireStudentCohortAccess).
//
// This module still owns every instructor-facing Provisioner interaction (prepare-labs, reset,
// destroy, events) — the Admin UI never talks to the Provisioner directly.
const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors');
module.exports = function registerAdminRoutes(app, deps) {
const { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress } = deps;
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
const { requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac;
// ── Admin auth ───────────────────────────────────────────────────────────────────────
app.post('/api/admin/login', (req, res) => {
const { password } = req.body || {};
if (!verifyAdminPassword(password)) return res.status(401).json({ error: 'invalid_credentials' });
const token = sessions.createAdminSession();
res.append('Set-Cookie', `${ADMIN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=14400`);
res.json({ ok: true });
});
app.post('/api/admin/logout', (req, res) => {
const header = req.headers.cookie || '';
const token = Object.fromEntries(header.split(';').map((p) => p.trim().split('=')))[ADMIN_COOKIE];
sessions.destroyAdminSession(token);
res.append('Set-Cookie', `${ADMIN_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
res.json({ ok: true });
});
app.get('/api/admin/whoami', adminSession, (req, res) => res.json({ admin: true }));
const admin = express_router_guard(app, adminSession);
// Tiny helper: every admin.<verb> call goes through instructorSession first, then whatever
// role/scope middleware is passed, then the handler (last function in the list — auto-wrapped
// so a thrown/rejected error reaches Express's error handler instead of crashing the process,
// same as every other async handler in this Express 4 app).
function wrap(fn) {
return (req, res, next) => {
try {
const result = fn(req, res, next);
if (result && typeof result.catch === 'function') result.catch(next);
} catch (err) { next(err); }
};
}
const admin = {
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)),
delete: (p, ...mw) => app.delete(p, instructorSession, ...mw.map(wrap)),
};
// ── Dashboard ────────────────────────────────────────────────────────────────────────
admin.get('/api/admin/dashboard', async (req, res) => {
// Global for owner/admin. A plain 'instructor' only sees stats for cohorts they're assigned
// to — an empty cohort_instructors row set means an empty (not global) dashboard, not a 403,
// since the dashboard itself isn't a sensitive resource, just its contents.
admin.get('/api/admin/dashboard', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const scoped = req.instructor.role === 'instructor';
const cohortFilter = scoped
? `AND e.cohort_id IN (SELECT cohort_id FROM cohort_instructors WHERE instructor_id = '${req.instructor.instructor_id}')`
: '';
// (string-interpolated only for a UUID we already validated came from req.instructor, never
// from request input — every other query below stays parameterized as usual.)
const [cohorts, students, envs] = await Promise.all([
pool.query(`SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`),
pool.query(`SELECT count(*)::int AS n FROM students WHERE status = 'active'`),
pool.query(scoped
? `SELECT count(DISTINCT c.cohort_id)::int AS n FROM cohorts c JOIN cohort_instructors ci ON ci.cohort_id = c.cohort_id WHERE c.status = 'active' AND ci.instructor_id = $1`
: `SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`, scoped ? [req.instructor.instructor_id] : []),
scoped
? pool.query(`SELECT count(DISTINCT e.student_id)::int AS n FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled'`, [req.instructor.instructor_id])
: pool.query(`SELECT count(*)::int AS n FROM students WHERE status = 'active'`),
pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`),
]);
const envCounts = Object.fromEntries(envs.rows.map((r) => [r.status, r.n]));
const recentCompletions = await pool.query(
`SELECT sa.student_id, s.display_name, sa.scenario_id, sa.completed_at
FROM scenario_attempts sa JOIN students s ON s.student_id = sa.student_id
WHERE sa.result = 'passed' ORDER BY sa.completed_at DESC LIMIT 10`
LEFT JOIN cohort_enrollments e ON e.student_id = s.student_id
WHERE sa.result = 'passed' ${cohortFilter}
ORDER BY sa.completed_at DESC LIMIT 10`
);
const recentFailures = await pool.query(
`SELECT student_id, lab_host_id, status, error_reason, started_at
@@ -60,17 +81,24 @@ module.exports = function registerAdminRoutes(app, deps) {
idle_or_destroyed: envCounts.destroyed || 0,
},
recent_completions: recentCompletions.rows,
recent_failures: recentFailures.rows,
recent_failures: scoped ? [] : recentFailures.rows, // env-level failures aren't cohort-scoped data; keep them admin/owner-only
});
});
// ── Students ─────────────────────────────────────────────────────────────────────────
admin.get('/api/admin/students', async (req, res) => {
const { rows } = await getPool().query(`SELECT * FROM students ORDER BY display_name`);
admin.get('/api/admin/students', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const { rows } = req.instructor.role === 'instructor'
? await pool.query(
`SELECT DISTINCT s.* FROM students s
JOIN cohort_enrollments e ON e.student_id = s.student_id
JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.instructor.instructor_id])
: await pool.query(`SELECT * FROM students ORDER BY display_name`);
res.json(rows);
});
admin.post('/api/admin/students', async (req, res) => {
admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => {
const { display_name, cohort_id } = req.body || {};
if (!display_name) return res.status(400).json({ error: 'display_name is required' });
const pool = getPool();
@@ -83,10 +111,11 @@ module.exports = function registerAdminRoutes(app, deps) {
[cohort_id, studentId]
);
}
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
res.status(201).json({ student_id: studentId, display_name: display_name.trim() });
});
admin.patch('/api/admin/students/:id', async (req, res) => {
admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => {
const { display_name, status } = req.body || {};
const fields = [], values = [];
if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name); }
@@ -99,7 +128,7 @@ module.exports = function registerAdminRoutes(app, deps) {
res.json({ ok: true });
});
admin.get('/api/admin/students/:id/progress', async (req, res) => {
admin.get('/api/admin/students/:id/progress', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const progress = await loadProgress(getPool(), req.params.id);
const path = curriculum.loadCurriculumPath();
const scenarios = curriculum.loadScenarios();
@@ -114,25 +143,25 @@ module.exports = function registerAdminRoutes(app, deps) {
res.json({ stages });
});
admin.get('/api/admin/students/:id/attempts', async (req, res) => {
admin.get('/api/admin/students/:id/attempts', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const { rows } = await getPool().query(
`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.params.id]
);
res.json(rows);
});
admin.get('/api/admin/students/:id/environment', async (req, res) => {
admin.get('/api/admin/students/:id/environment', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const { status, body } = await provisioner.listForStudent(req.params.id);
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
res.json(body);
});
admin.post('/api/admin/students/:id/environment/provision', async (req, res) => {
admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
const { status, body } = await provisioner.provision(req.params.id);
res.status(status === 409 ? 200 : status).json(body);
});
admin.post('/api/admin/students/:id/cohort', async (req, res) => {
admin.post('/api/admin/students/:id/cohort', requireAdminOrOwner, async (req, res) => {
const { cohort_id } = req.body || {};
if (!cohort_id) return res.status(400).json({ error: 'cohort_id required' });
await getPool().query(
@@ -140,28 +169,37 @@ module.exports = function registerAdminRoutes(app, deps) {
ON CONFLICT (cohort_id, student_id) DO UPDATE SET enrollment_status = 'enrolled'`,
[cohort_id, req.params.id]
);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_change', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id, op: 'add' } });
res.json({ ok: true });
});
admin.delete('/api/admin/students/:id/cohort/:cohortId', async (req, res) => {
admin.delete('/api/admin/students/:id/cohort/:cohortId', requireAdminOrOwner, async (req, res) => {
await getPool().query(
`UPDATE cohort_enrollments SET enrollment_status = 'removed' WHERE cohort_id = $1 AND student_id = $2`,
[req.params.cohortId, req.params.id]
);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_change', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id: req.params.cohortId, op: 'remove' } });
res.json({ ok: true });
});
// ── Cohorts ──────────────────────────────────────────────────────────────────────────
admin.get('/api/admin/cohorts', async (req, res) => {
const { rows } = await getPool().query(`
SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count
FROM cohorts c LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id
GROUP BY c.cohort_id ORDER BY c.created_at DESC
`);
admin.get('/api/admin/cohorts', requireAnyInstructor, async (req, res) => {
const pool = getPool();
const { rows } = req.instructor.role === 'instructor'
? await pool.query(`
SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count
FROM cohorts c
JOIN cohort_instructors ci ON ci.cohort_id = c.cohort_id AND ci.instructor_id = $1
LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id
GROUP BY c.cohort_id ORDER BY c.created_at DESC`, [req.instructor.instructor_id])
: await pool.query(`
SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count
FROM cohorts c LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id
GROUP BY c.cohort_id ORDER BY c.created_at DESC`);
res.json(rows);
});
admin.post('/api/admin/cohorts', async (req, res) => {
admin.post('/api/admin/cohorts', requireAdminOrOwner, async (req, res) => {
const { cohort_id, name, tier, start_date, end_date, seat_target } = req.body || {};
if (!cohort_id || !name) return res.status(400).json({ error: 'cohort_id and name are required' });
try {
@@ -173,10 +211,11 @@ module.exports = function registerAdminRoutes(app, deps) {
if (e.code === '23505') return res.status(409).json({ error: 'cohort_id already exists' });
throw e;
}
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'cohort_create', resourceType: 'cohort', resourceId: cohort_id, result: 'success', sourceIp: req.ip });
res.status(201).json({ cohort_id });
});
admin.patch('/api/admin/cohorts/:id', async (req, res) => {
admin.patch('/api/admin/cohorts/:id', requireAdminOrOwner, async (req, res) => {
const { name, tier, start_date, end_date, status, seat_target } = req.body || {};
const map = { name, tier, start_date, end_date, status, seat_target };
const fields = [], values = [];
@@ -188,10 +227,24 @@ module.exports = function registerAdminRoutes(app, deps) {
values.push(req.params.id);
const { rowCount } = await getPool().query(`UPDATE cohorts SET ${fields.join(', ')} WHERE cohort_id = $${values.length}`, values);
if (!rowCount) return res.status(404).json({ error: 'not found' });
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'cohort_update', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: map });
res.json({ ok: true });
});
admin.get('/api/admin/cohorts/:id/students', async (req, res) => {
// Assign an instructor to a cohort (admin/owner only — this IS the scoping mechanism, so a
// plain instructor can't grant themselves or anyone else access to a cohort).
admin.post('/api/admin/cohorts/:id/instructors', requireAdminOrOwner, async (req, res) => {
const { instructor_id, role_in_cohort } = req.body || {};
if (!instructor_id) return res.status(400).json({ error: 'instructor_id required' });
await getPool().query(
`INSERT INTO cohort_instructors (cohort_id, instructor_id, role_in_cohort) VALUES ($1, $2, $3)
ON CONFLICT (cohort_id, instructor_id) DO UPDATE SET role_in_cohort = EXCLUDED.role_in_cohort`,
[req.params.id, instructor_id, role_in_cohort || 'teacher']
);
res.json({ ok: true });
});
admin.get('/api/admin/cohorts/:id/students', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
const { rows } = await getPool().query(
`SELECT s.student_id, s.display_name, s.status, s.last_active_at, e.enrolled_at
FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
@@ -201,16 +254,17 @@ module.exports = function registerAdminRoutes(app, deps) {
res.json(rows);
});
admin.post('/api/admin/cohorts/:id/curriculum', async (req, res) => {
admin.post('/api/admin/cohorts/:id/curriculum', requireAdminOrOwner, async (req, res) => {
const { curriculum_path } = req.body || {};
await getPool().query(
`INSERT INTO curriculum_assignments (cohort_id, curriculum_path) VALUES ($1, $2)`,
[req.params.id, curriculum_path || 'rootnode-path']
);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'curriculum_assign', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { curriculum_path } });
res.json({ ok: true });
});
admin.get('/api/admin/cohorts/:id/progress', async (req, res) => {
admin.get('/api/admin/cohorts/:id/progress', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
const pool = getPool();
const { rows: students } = await pool.query(
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
@@ -232,15 +286,12 @@ module.exports = function registerAdminRoutes(app, deps) {
});
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
admin.post('/api/admin/cohorts/:id/prepare-labs', async (req, res) => {
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
const { rows: students } = await getPool().query(
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
[req.params.id]
);
// Provision concurrently — each is a real k3s boot (1-3 min); sequential would make a
// 20-student cohort prep take the better part of an hour. Promise.allSettled so one
// student's failure doesn't block reporting on the rest.
const results = await Promise.allSettled(students.map(async (s) => {
const existing = await provisioner.listForStudent(s.student_id);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
@@ -249,12 +300,13 @@ module.exports = function registerAdminRoutes(app, deps) {
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
}));
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
res.json({
results: results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message }),
});
});
admin.post('/api/admin/cohorts/:id/destroy-labs', async (req, res) => {
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
const { rows: students } = await getPool().query(
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
[req.params.id]
@@ -266,35 +318,113 @@ module.exports = function registerAdminRoutes(app, deps) {
await provisioner.destroy(active.environment_id);
return { student_id: s.student_id, status: 'destroyed' };
}));
// Academic progress is Postgres-side and untouched by any of this — destroying compute
// never touches the students/progress/scenario_attempts tables.
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'destroy_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
// Academic progress is Postgres-side and untouched by any of this.
res.json({ results: results.map((r) => r.status === 'fulfilled' ? r.value : { status: 'error', error: r.reason?.message }) });
});
// ── Instructor environment controls ─────────────────────────────────────────────────────
admin.post('/api/admin/environments/:id/reset', async (req, res) => {
// ── Instructor environment controls (by raw environment id — admin/owner only; a plain
// instructor operates on Labs via the cohort/student-scoped routes above instead, since
// resolving a bare environment id back to a cohort would need an extra Provisioner round
// trip on every call — a documented simplification, see docs/PHASE3A...) ───────────────────
admin.post('/api/admin/environments/:id/reset', requireAdminOrOwner, async (req, res) => {
const { status, body } = await provisioner.reset(req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_reset', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
res.status(status).json(body);
});
admin.delete('/api/admin/environments/:id', async (req, res) => {
admin.delete('/api/admin/environments/:id', requireAdminOrOwner, async (req, res) => {
const { status, body } = await provisioner.destroy(req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_destroy', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
res.status(status).json(body);
});
admin.get('/api/admin/environments/:id/events', async (req, res) => {
admin.get('/api/admin/environments/:id/events', requireAdminOrOwner, async (req, res) => {
const { status, body } = await provisioner.events(req.params.id);
res.status(status).json(body);
});
};
// Tiny helper so every admin.<verb>(...) call below doesn't need `adminSession` repeated
// as a second argument by hand — same effect, less visual noise.
function express_router_guard(app, adminSession) {
return {
get: (p, h) => app.get(p, adminSession, h),
post: (p, h) => app.post(p, adminSession, h),
patch: (p, h) => app.patch(p, adminSession, h),
delete: (p, h) => app.delete(p, adminSession, h),
};
}
// ── Instructor accounts — owner only ────────────────────────────────────────────────────
admin.get('/api/admin/instructors', requireOwner, async (req, res) => {
res.json(await listInstructors(getPool()));
});
admin.post('/api/admin/instructors', requireOwner, async (req, res) => {
const { email, display_name, role, password } = req.body || {};
if (!email || !display_name || !password) return res.status(400).json({ error: 'email, display_name, and password are required' });
if (password.length < 12) return res.status(400).json({ error: 'password must be at least 12 characters' });
if (role && !['owner', 'admin', 'instructor'].includes(role)) return res.status(400).json({ error: 'invalid role' });
let instructor;
try {
instructor = await createInstructor(getPool(), { email, displayName: display_name, role, password });
} catch (e) {
if (e.code === '23505') return res.status(409).json({ error: 'email already in use' });
throw e;
}
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'instructor_create', resourceType: 'instructor', resourceId: instructor.instructor_id, result: 'success', sourceIp: req.ip, metadata: { email: instructor.email, role: instructor.role } });
const { password_hash, ...pub } = instructor;
res.status(201).json(pub);
});
admin.get('/api/admin/instructors/:id', requireOwner, async (req, res) => {
const { rows } = await getPool().query(`SELECT * FROM instructors WHERE instructor_id = $1`, [req.params.id]);
if (!rows[0]) return res.status(404).json({ error: 'not found' });
const { password_hash, ...pub } = rows[0];
const sessionsList = await require('./sessions').listInstructorSessions(getPool(), req.params.id);
res.json({ ...pub, sessions: sessionsList });
});
admin.patch('/api/admin/instructors/:id', requireOwner, async (req, res) => {
const { display_name, role, status } = req.body || {};
const pool = getPool();
if (role && !['owner', 'admin', 'instructor'].includes(role)) return res.status(400).json({ error: 'invalid role' });
if (status && !['active', 'inactive'].includes(status)) return res.status(400).json({ error: 'invalid status' });
const { rows: targetRows } = await pool.query(`SELECT * FROM instructors WHERE instructor_id = $1`, [req.params.id]);
const target = targetRows[0];
if (!target) return res.status(404).json({ error: 'not found' });
const demotingFromOwner = target.role === 'owner' && role && role !== 'owner';
const deactivatingOwner = target.role === 'owner' && status === 'inactive';
if (demotingFromOwner || deactivatingOwner) {
try { await assertNotLastOwner(pool, req.params.id); }
catch (e) { if (e.code === 'LAST_OWNER') return res.status(400).json({ error: 'last_owner', message: e.message }); throw e; }
}
const fields = [], values = [];
if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name.trim()); }
if (role) { fields.push(`role = $${fields.length + 1}`); values.push(role); }
if (status) { fields.push(`status = $${fields.length + 1}`); values.push(status); }
if (!fields.length) return res.status(400).json({ error: 'nothing to update' });
fields.push('updated_at = now()');
values.push(req.params.id);
await pool.query(`UPDATE instructors SET ${fields.join(', ')} WHERE instructor_id = $${values.length}`, values);
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'instructor_update', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { display_name, role, status } });
res.json({ ok: true });
});
admin.post('/api/admin/instructors/:id/revoke-sessions', requireOwner, async (req, res) => {
await require('./sessions').revokeAllInstructorSessions(getPool(), req.params.id);
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'sessions_revoked', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
admin.post('/api/admin/instructors/:id/reset-password', requireOwner, async (req, res) => {
const { new_password } = req.body || {};
if (!new_password || new_password.length < 12) return res.status(400).json({ error: 'new_password must be at least 12 characters' });
const { hashPassword } = require('./auth/password');
const pool = getPool();
const { rowCount } = await pool.query(`UPDATE instructors SET password_hash = $1, updated_at = now() WHERE instructor_id = $2`, [hashPassword(new_password), req.params.id]);
if (!rowCount) return res.status(404).json({ error: 'not found' });
await require('./sessions').revokeAllInstructorSessions(pool, req.params.id); // force re-login everywhere with the new password
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'password_reset', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
// ── Audit log — admin/owner ──────────────────────────────────────────────────────────────
admin.get('/api/admin/audit-events', requireAdminOrOwner, async (req, res) => {
const { rows } = await getPool().query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT 200`);
res.json(rows);
});
};