9 Commits
Author SHA1 Message Date
ced4568 40692926ee docs(phase3a): document production identity architecture
New docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md covers the schema, persistent session
design, instructor accounts, the full RBAC matrix, cohort scoping (including the documented
raw-environment-id simplification), student session hardening (explicitly still
classroom/pilot identification, not production auth), CSRF, rate limiting, audit logging,
the API inventory, a security hardening checklist, test results, known limitations, the
future OIDC path, and production HTTPS/cookie expectations — with an explicit BUILT NOW vs.
FUTURE split.

Updates ENGINEERING_SOP.md (new §9/§10, closing a stale '(§9)' forward-reference that dated
back to Phase 2B), LAB_ARCHITECTURE.md status line, README.md, platform/README.md, and
platform/api/README.md (bootstrap-owner usage, new config vars, no more ADMIN_PASSWORD).
2026-08-18 13:36:45 -05:00
ced4568 face0ae132 test(phase3a): add identity and RBAC end-to-end coverage
New e2e-phase3a.js (21 cases): no account enumeration, CSRF positive/negative, login rate
limiting, RBAC across all three roles, cohort-scoped instructor access (visibility + route
access), last-owner protection, live role/status re-evaluation (no separate revoke needed),
password change (old password rejected after, other sessions revoked), audit log content
(including a no-secrets-in-metadata assertion), student session RBAC/logout, and session
persistence across a REAL docker compose restart api mid-test.

New session-helper.js — a shared cookie-jar test helper (Node's fetch doesn't persist
cookies across calls) that also auto-attaches X-CSRF-Token from whichever rn_csrf_*
cookie it's holding, replacing the single-cookie-string trackers e2e-phase2a.js and
e2e-phase2b.js each had their own copy of.

e2e-phase2a.js and e2e-phase2b.js updated for the new auth model: admin login is now a real
bootstrapped OWNER_EMAIL/OWNER_PASSWORD account instead of the old shared ADMIN_PASSWORD.
All cases still passing: 10/10 and 12/12 respectively.
2026-08-18 13:36:45 -05:00
ced4568 1ca5dd3995 chore(platform): retire ADMIN_PASSWORD from the dev stack
No more shared instructor password env var — the api service now needs only
PROVISIONER_SHARED_SECRET. First-run setup is npm run bootstrap-owner instead.
2026-08-18 13:36:29 -05:00
ced4568 777a1ef880 feat(admin): add instructor management, audit, and account UI
Login form is now email+password (was a bare shared password field). Nav conditionally
shows Instructors (owner only) and Audit (owner/admin) tabs based on the signed-in
instructor's role. New InstructorsView (create/list/edit accounts, role/status controls,
session list + revoke-all, password reset — owner only), AuditView (read-only audit event
table), AccountView (change-password form for the signed-in instructor). CohortsView hides
the create-cohort form and curriculum-assignment button for the 'instructor' role — the
server already enforces this; this just avoids showing controls that would 403.
2026-08-18 13:36:29 -05:00
ced4568 7ce435ad32 feat(security): add CSRF double-submit protection and login rate limiting
Non-HttpOnly rn_csrf_s/rn_csrf_i cookies, set on login, echoed back as X-CSRF-Token on every
state-changing request — defense in depth on top of (not instead of) SameSite=Lax. In-memory
sliding-window rate limiting on instructor login (8 attempts / 5 min per ip:email, no new
dependency).

Real bug found while wiring this up: the Gateway's own progress-relay call (triggered when a
student solves a scenario) is a server-to-server POST to /api/progress/record relaying the
browser's session — it needs to forward the CSRF token too, and needs to forward BOTH the
rn_csrf_s cookie and a matching X-CSRF-Token header (forwarding only the header still 403'd,
since the double-submit check needs both on the request Platform API actually receives).

Also tightens the Gateway's session-resolution cache from 5000ms to 2000ms, so a
revoked/expired session stops working at the Gateway within a tighter, tested bound.

Frontend (admin + student API clients) now reads the relevant rn_csrf_* cookie via
document.cookie and attaches it on every POST/PATCH/DELETE.
2026-08-18 13:36:20 -05:00
ced4568 62726455d5 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.
2026-08-18 13:36:11 -05:00
ced4568 37230d4ef1 feat(audit): add platform security audit log
New audit_events table, distinct from the Provisioner's environment_events (Lab-lifecycle
only). recordAudit() is called from the auth/RBAC/admin layers added in this phase — login
success/failure, logout, password change, instructor CRUD, role changes, session
revocation, cohort/roster changes, curriculum assignment, Prepare/Destroy Labs, and every
403 authorization denial (recorded centrally by the RBAC middleware, not per-route). Never
records plaintext passwords, raw session tokens, Provisioner secrets, or raw routing tokens.
2026-08-18 13:36:11 -05:00
ced4568 85fa920f96 feat(auth): add per-instructor accounts and local password login
Replaces the single shared ADMIN_PASSWORD (Phase 2B) with real accounts: instructors table
(email/display_name/role/status), scrypt password hashing with a fresh random salt PER
INSTRUCTOR (Phase 2B used one fixed salt because there was exactly one shared password —
that assumption is gone), auth_identities table so a future OIDC login can map to the same
instructor_id without a schema change.

New POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session,
POST /api/auth/change-password (old password required, revokes other sessions). No account
enumeration: identical response whether the email doesn't exist or the password is wrong.

bootstrap-owner.js is the only supported way to create the first account — no manual
database editing to stand up a fresh deployment. Refuses to run if an active owner already
exists unless the target email is an existing instructor (zero-owners recovery path).
2026-08-18 13:35:54 -05:00
ced4568 52c17c1907 feat(identity): add persistent Postgres-backed session store
Replaces the in-memory studentSessions/adminSessions Maps (Phase 2B — gone on every
restart) with a sessions table. Browser holds an opaque CSPRNG bearer token; only its
SHA-256 hash is ever stored server-side, same principle as password hashing. Each session
has an absolute expiry (fixed at creation) and a sliding idle expiry (pushed forward on
every authenticated request, capped at the absolute one) — one atomic UPDATE...RETURNING
per resolve, no read-then-write race. Student and instructor sessions stay fully separate
token namespaces, matching Phase 2B's cookie-name separation.

cookies.js centralizes Set-Cookie construction so the Secure attribute (COOKIE_SECURE=1,
for real HTTPS deployments) is applied consistently instead of hand-built per call site.
2026-08-18 13:35:54 -05:00
37 changed files with 2194 additions and 393 deletions
+18 -12
View File
@@ -135,18 +135,19 @@ layers rather than changing the image itself:
- `platform/provisioner/` — Postgres-backed environment registry, per-student Docker
networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`).
- `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress,
scenario attempts, student/admin sessions, instructor/admin control plane. No k3s, no
`--privileged` (`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`).
scenario attempts, persistent student/instructor sessions, per-instructor accounts (RBAC:
owner/admin/instructor), audit log, instructor/admin control plane. No k3s, no `--privileged`
(`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`).
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity from a
server-controlled session cookie (not a client-suppliable header/param), proxies
server-controlled, persistent session cookie (not a client-suppliable header/param), proxies
HTTP/WebSocket into that one student's container, never a raw container IP/port
(`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3).
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3).
- The student flow (roster login → curriculum dashboard → Launch Lab → Lab session →
Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal
(cohorts, students, classroom "Prepare Labs," environment controls) lives in
`frontend/src/admin/` at the `/admin` route — both small additions alongside the existing,
unmodified Lab UI, not a rewrite of it.
Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal (per-instructor
login, cohorts, students, instructor accounts, audit log, classroom "Prepare Labs,"
environment controls) lives in `frontend/src/admin/` at the `/admin` route — both small
additions alongside the existing, unmodified Lab UI, not a rewrite of it.
**Never publish the raw privileged container** — front it with isolation (that's what the
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
@@ -163,16 +164,21 @@ Key properties of the platform (not this image):
loses their academic record. Verified with a full class-sized (3-student) cohort workflow:
provision, complete different scenarios, reset one student's Lab, destroy all Labs after
class — progress survives every step.
- Instructors manage cohorts, students, curriculum assignment, and classroom Lab prep
(provision an entire cohort before class, verify readiness, destroy Labs after) from
`/admin` — no manual database, Docker, or Kubernetes access needed.
- Instructors sign in with their own account (no shared password) and manage cohorts, students,
curriculum assignment, and classroom Lab prep (provision an entire cohort before class, verify
readiness, destroy Labs after) from `/admin` — no manual database, Docker, or Kubernetes
access needed. Owners manage instructor accounts and roles; every instructor/admin action is
recorded in an audit log.
- Sessions (student and instructor) are Postgres-backed and survive a Platform API restart —
verified by actually restarting it mid-session and confirming access still works.
- CPU/memory/PID/storage limits are enforced per container, config-driven.
- Egress is restricted to an allowlisted proxy at the firewall layer, not left open.
- Idle/abandoned environments are automatically cleaned up, and a student can launch a fresh
one and keep going without losing progress.
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`.
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`.
---
+59 -15
View File
@@ -139,17 +139,22 @@ Order agreed 2026-08-17/18. Status as of this doc:
(`⎈ RootNode Academy Lab — node: k8s-lab`). Upstream KubeKosh attribution left untouched in
footer/README/LICENSE. Broader "make Real Ops first-class" UI work not started.
2. RootNode curriculum mode — **backend done** (§4), frontend not started.
3. Student accounts / persistent progress — **backend done** (§5), frontend not started,
no instructor auth yet.
4. Instructor dashboard — not started.
5. Per-student isolation — **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow)
done**, see §8/§9. Ephemeral per-student Lab environments, Gateway-mediated routing, and a
working entry→dashboard→launch→lab→progress flow are implemented and end-to-end tested on a
single dev machine, including two simultaneous students with verified cross-student
isolation. **Still not cleared for minors** — pilot-grade identity only (no
password/SSO), no instructor auth layer, and the production two-VM Proxmox split this
depends on doesn't exist yet. See `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` for exactly
what's verified vs. not.
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
identification, not strong student auth — documented, not silently glossed over
(docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §7).
4. Instructor dashboard — **done** (§9/§10). Full admin portal at `/admin`: dashboard, cohort
and student management, classroom Prepare/Destroy Labs, per-instructor accounts with
owner/admin/instructor roles, audit log.
5. Per-student isolation — **Phase 1 + Phase 2A + Phase 2B + Phase 3A done**, see §8/§9/§10.
Ephemeral per-student Lab environments, Gateway-mediated routing with persistent
server-controlled sessions, a full instructor/admin control plane with real per-person
accounts and RBAC, and a working entry→dashboard→launch→lab→progress flow are implemented
and end-to-end tested on a single dev machine, including a 3-student cohort classroom
workflow and a real Platform API restart mid-session. **Still not cleared for minors** — the
production two-VM Proxmox split this depends on doesn't exist yet, and OIDC/SSO is a
documented adapter, not a working integration (docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md
§16).
6. Scenarios → teaching modules (incident context/investigation/prevention) — not started.
7. Capstone/exam engine — not started (placeholder stage only, see §4).
8. Progressive hint levels — not started.
@@ -157,9 +162,9 @@ Order agreed 2026-08-17/18. Status as of this doc:
10. CI scenario testing — not started.
11. Container/build optimization — explicitly deprioritized by design (platform works, not urgent).
Next slice: Phase 2B — instructor dashboard, production auth/SSO, and the identity/progress
Postgres migration are the biggest remaining items. See
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 for the full Phase 2B list.
Next slice: production OIDC/SSO, the production two-VM Proxmox deployment, and teaching-module
scenario content are the biggest remaining items. See
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §18 for the full BUILT NOW vs. FUTURE split.
## 8. Platform Provisioner (Phase 1)
@@ -177,7 +182,8 @@ section is just the map.
auth) or `platform/provisioner/test/run.js` for isolated Provisioner testing.
- **Where identity lives:** as of Phase 2B, Postgres is authoritative —
`students`/`cohorts`/`progress`/`scenario_attempts` live in the same `rootnode_platform`
database as `environments`, owned by the Platform API's own migrations. See §9.
database as `environments`, owned by the Platform API's own migrations. See §9. As of Phase
3A, `instructors`/`sessions`/`cohort_instructors`/`audit_events` live there too. See §10.
`environments.student_id` is still a plain opaque reference, not a real FK — that's a
deliberate, documented choice (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §2), not an oversight.
- **Local dev:** `platform/provisioner/docker-compose.dev.yml` runs Postgres + the egress
@@ -189,3 +195,41 @@ section is just the map.
events). Some properties (idle reaper timing, remote TLS lab-host, reconciliation-on-crash,
real-image k3s behavior) were validated manually during implementation rather than
automated — see the Phase 1 final report for what and why.
## 9. Platform API (Phase 2B)
Full design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`. Operational reference:
`platform/api/README.md`.
- **What it is:** `platform/api/` — a lightweight Node/Express service (no k3s, no
`--privileged`, no Lab terminal runtime) owning students, cohorts, enrollments, curriculum
assignments, progress, scenario attempts, and — as of Phase 3A — instructor accounts,
persistent sessions, and the audit log (§10). The sole caller of the Provisioner for both the
student launch/reset path and the instructor/admin control plane.
- **What it is not:** reachable directly by a browser — always through the Gateway
(`platform/gateway`), same trust boundary the Provisioner has relative to this service.
- **Local dev:** part of the same `platform/docker-compose.dev.yml` stack as the Provisioner —
no separate compose file.
- **Test procedure:** `platform/test/e2e-phase2a.js` (student flow) and
`platform/test/e2e-phase2b.js` (classroom acceptance workflow) — both need a bootstrapped
instructor account as of Phase 3A, see §10.
## 10. Identity and instructor access (Phase 3A)
Full design: `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`. Operational reference:
`platform/api/README.md` (bootstrap, config).
- **What it is:** persistent Postgres-backed sessions (student + instructor, survive a Platform
API restart), real per-instructor accounts (`owner`/`admin`/`instructor` roles, scrypt
password hashing, per-instructor salt), server-enforced RBAC on every `/api/admin/*` route,
cohort-scoped access for the plain `instructor` role, CSRF double-submit protection, login
rate limiting, and a platform-level `audit_events` log.
- **What it replaced:** Phase 2B's in-memory session `Map`s (gone on every restart) and the
single shared `ADMIN_PASSWORD` env var (no accountability, no roles, no revocation).
- **Bootstrap:** `node platform/api/bootstrap-owner.js <email> <name> <password>` — the only
supported way to create the first instructor account; no manual database editing.
- **Test procedure:** `platform/test/e2e-phase3a.js` — 21 automated tests covering session
persistence (including a real `docker compose restart api`), instructor auth, RBAC, cohort
scoping, CSRF, rate limiting, password change, and audit logging.
- **Known limitations, future OIDC path, production HTTPS expectations:** see
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §15–17.
+8 -6
View File
@@ -1,12 +1,14 @@
# RootNode Academy — Ephemeral Lab Architecture
Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) + Phase 2B
(Platform API split, Postgres academic data, server sessions, admin portal) IMPLEMENTED,
2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
and `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for concrete schema/API/test results, and
`platform/README.md` for how to run the stack. This document remains the architecture record
for the full ephemeral-lab direction; production OIDC/SSO, per-instructor accounts, and
production Proxmox deployment are still future work (Phase 3+ — see the Phase 2B doc's §13).
(Platform API split, Postgres academic data, server sessions, admin portal) + Phase 3A
(persistent sessions, per-instructor accounts, RBAC, audit log) IMPLEMENTED, 2026-08-18** — see
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, and `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`
for concrete schema/API/test results, and `platform/README.md` for how to run the stack. This
document remains the architecture record for the full ephemeral-lab direction; production
OIDC/SSO (an adapter interface exists, not a working integration — see the Phase 3A doc §16)
and production Proxmox deployment are still future work (Phase 3B+ — see the Phase 3A doc §18).
Supersedes the namespace-isolation
(`shared_namespace`/`ephemeral_cluster`) approach that was in flight before this redirect — see
`scenarios/SCHEMA.md`'s `isolation_type` note for the pointer back here.
@@ -0,0 +1,344 @@
# Phase 3A — Identity and Instructor Access
Productionizes identity for RootNode Academy: persistent sessions, real per-instructor accounts
with roles, server-enforced RBAC, CSRF/rate-limiting hardening, and a platform-level audit log.
Builds on `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (Platform API split, Postgres migration,
first-generation session cookies, admin portal) — read that first if you haven't.
## 1. What changed, in one paragraph
Phase 2B's sessions lived in an in-memory `Map` (gone on every restart) and instructor access
was one shared `ADMIN_PASSWORD` env var (no accountability, no roles, no revocation). Phase 3A
replaces both: sessions are now rows in Postgres (`sessions` table, survive a restart, are
individually revocable), and instructors are real accounts (`instructors` table) with a role —
`owner`, `admin`, or `instructor` — enforced server-side on every `/api/admin/*` route, with a
plain `instructor` further scoped to the cohorts they're assigned to. Every security-relevant
action is now recorded in a platform-level audit log (`audit_events`).
## 2. Schema added
```
instructors instructor_id, email (unique), display_name, password_hash, role, status,
created_at, updated_at, last_login_at
auth_identities auth_identity_id, instructor_id, provider ('local' today), provider_subject,
provider_email, created_at, last_login_at — one 'local' row per instructor;
a future OIDC login adds a second row for the SAME instructor_id, no schema
change needed then.
sessions session_id, session_type ('student'|'instructor'), student_id, instructor_id,
token_hash (SHA-256 of the bearer token — the raw token is NEVER stored),
created_at, last_seen_at, expires_at (absolute), idle_expires_at (sliding,
capped at expires_at), revoked_at, metadata
cohort_instructors cohort_id, instructor_id, role_in_cohort — scopes the plain 'instructor'
role; owner/admin never consult this table (global visibility by role alone)
audit_events audit_event_id, actor_type, actor_id, action, resource_type, resource_id,
result, source_ip, metadata, created_at
```
All five are new migrations (`platform/api/migrations/007`–`011`), idempotent (the existing
`schema_migrations` tracking table already guarantees that), and applied automatically on
Platform API startup — no manual migration step. None of Phase 2B's tables
(`students`/`cohorts`/`progress`/`scenario_attempts`/etc.) were touched; all Phase 2B data is
preserved exactly as-is. `sessions.instructor_id` and `cohort_instructors.instructor_id` are
real foreign keys to `instructors` (both tables live in the same Platform API-owned migration
set) — unlike `environments.student_id` in the Provisioner's schema, there's no
cross-service-database reason to keep this one an opaque reference.
## 3. Persistent session design
Browser holds one opaque, CSPRNG bearer token (32 random bytes, base64url) per session, in an
`HttpOnly`, `SameSite=Lax` cookie (`Secure` too, once `COOKIE_SECURE=1`). The server stores only
`SHA-256(token)` — a Postgres dump or backup can't be replayed as a working session, same
principle as password hashing.
Two independent lifecycles (`sessions.session_type`), separate cookie names
(`rn_session`/`rn_instructor`), separate token namespaces — a bug can't confuse a student
session for an instructor one. Each session has:
- **Absolute expiry** (`expires_at`, fixed at creation) — 8h student / 12h instructor by default,
configurable via `STUDENT_SESSION_TTL_HOURS` / `INSTRUCTOR_SESSION_TTL_HOURS`.
- **Idle expiry** (`idle_expires_at`, slides forward on every authenticated request, never past
`expires_at`) — 2h default for both, configurable via `*_SESSION_IDLE_HOURS`.
Resolving a session (`sessions.js` `resolveSession`) is one atomic `UPDATE ... RETURNING` that
checks `revoked_at IS NULL AND expires_at > now() AND idle_expires_at > now()` and slides the
idle window forward in the same round trip — no separate read-then-write race.
**Restart behavior:** a Platform API restart no longer logs anyone out — verified in
`platform/test/e2e-phase3a.js` by actually running `docker compose restart api` mid-test and
confirming both an instructor session and a student session still resolve afterward.
**Role/status changes take effect immediately, no revocation needed:** the instructor session
middleware re-fetches the instructor row from Postgres on *every* request rather than trusting
anything cached in the session token — so demoting a role or deactivating an account is live on
the very next request that account makes, with no separate "walk every session and revoke"
step required. Explicit revocation (owner-triggered "revoke all sessions," and automatic on
password change) exists too, for the case where you want to kill an active session immediately
rather than wait for its next request.
## 4. Instructor accounts
`instructors.password_hash` is `scrypt` with a **per-instructor random salt** (Phase 2B's
`admin-auth.js` used one fixed salt because there was exactly one shared password — that's gone,
this is real per-row salting now), stored as `"<salt-hex>:<hash-hex>"`, verified with
`crypto.timingSafeEqual`. No plaintext password ever touches a log line, a database column, or
an API response body (`publicInstructor()` strips `password_hash` before any JSON response).
Bootstrap: `node platform/api/bootstrap-owner.js <email> <name> [password]` — the only supported
way to create the first account, so a fresh deployment never requires editing Postgres by hand.
Refuses to run if an active owner already exists unless the target email is an existing
instructor (the intended zero-owners recovery path).
## 5. Role model (RBAC matrix)
| Action | owner | admin | instructor |
|---|---|---|---|
| Manage instructor accounts (create/edit/deactivate/reset password/revoke sessions) | ✅ | ❌ | ❌ |
| View audit log | ✅ | ✅ | ❌ |
| Create/edit cohorts, create/edit students, assign curriculum | ✅ | ✅ | ❌ |
| Assign an instructor to a cohort | ✅ | ✅ | ❌ |
| View dashboard, cohort list, cohort/student detail, progress | ✅ (global) | ✅ (global) | ✅ (**assigned cohorts only**) |
| Prepare Labs / Destroy Cohort Labs, provision a student's Lab | ✅ (any cohort) | ✅ (any cohort) | ✅ (**assigned cohorts only**) |
| Reset/destroy/view-events by raw environment id | ✅ | ✅ | ❌ — see §6 |
Enforced by `src/rbac.js`, applied per-route in `src/admin-routes.js` — never in the frontend
alone (`frontend/src/admin/*.jsx` hides controls a role can't use, but that's UX; every route
re-checks server-side regardless of what the browser sends or hides). A denied request gets a
flat `403 {"error":"forbidden"}` — no distinction in the response between "wrong role" and
"right role, wrong cohort," and no distinction between "resource doesn't exist" and "you can't
see it," so a 403 never confirms anything about what exists to someone probing.
An unauthenticated request gets `401`, not `403` — the two are kept distinct throughout.
## 6. Cohort-scoped instructor access
`cohort_instructors` maps `instructor_id → cohort_id` (assigned by an owner/admin, never
self-service). A plain `instructor` role's requests to cohort- or student-keyed routes go
through `requireCohortAccess`/`requireStudentCohortAccess`, which check this table;
owner/admin bypass it entirely (global visibility by role alone).
**Deliberate simplification:** the raw `/api/admin/environments/:id/{reset,DELETE,events}`
routes (operate directly on a Provisioner environment id, no cohort context in the URL) stay
owner/admin-only rather than resolving cohort scope from a bare environment id — that resolution
would need an extra Provisioner round trip on every single call. A plain instructor still has
full Lab control, just via the cohort/student-scoped routes (`/cohorts/:id/prepare-labs`,
`/students/:id/environment/provision`, etc.), which already carry the scope check. Upgrade path
if this ever needs to change: add a `student_id`/`cohort_id` lookup to the Provisioner's
`GET /environments/:id` response and extend `requireCohortAccess` to resolve through it.
## 7. Student session hardening
The pick-your-name roster flow is unchanged UX — **this is classroom/pilot identification, not
production authentication.** There is still no password or possession factor at the identity
step; anyone who knows a display name (or can see the roster dropdown) can "become" that
student. What Phase 3A hardens is everything *after* that pick:
- The resulting session is server-controlled and now **persistent** (survives a Platform API
restart — verified, see §3).
- A student cannot switch identity by editing client state — there's nothing client-side to
edit; the cookie is `HttpOnly` and opaque.
- Logout revokes the session server-side (`sessions.destroyStudentSession`), not just a
client-side cookie clear.
- Every Gateway-proxied Lab request (HTTP and WebSocket) re-verifies ownership against the
session on every request, not just the initial navigation — unchanged from Phase 2B, still
true here.
- A student session can never call `/api/admin/*` — those routes require an `rn_instructor`
session in a completely separate cookie/token namespace.
- CSRF (see §8) now also protects student mutating routes (`/api/environment/launch`, `/reset`,
`/api/progress/record`, `/api/logout`) the same way it protects instructor ones.
**Future upgrade path that doesn't require touching the session system:** one-time student
enrollment codes, parent-issued codes, or institutional SSO would all still terminate in the
exact same `sessions` table and `rn_session` cookie — only the *identity verification step*
before `createStudentSession()` is called would change. This is why the session store was worth
building as a shared, session-type-agnostic module rather than something bespoke to the current
roster-picker.
## 8. CSRF protection
Double-submit cookie pattern, on top of (not instead of) `SameSite=Lax`. On login, a
**non-HttpOnly** cookie is set — `rn_csrf_s` (student) / `rn_csrf_i` (instructor) — deliberately
readable by the frontend's own JS via `document.cookie`. Every state-changing request
(`POST`/`PATCH`/`DELETE`) must echo that value back as an `X-CSRF-Token` header; the server
checks the header matches the cookie it's holding for that same request. A cross-origin
attacker's page can trigger a cookie-carrying request but can't read the cookie's value (browser
same-origin policy) or attach a matching custom header without CORS, which isn't granted to
other origins — so it can never produce a request where the two match.
`GET`/`HEAD`/`OPTIONS` are exempt (nothing to protect). Applied uniformly, including to
`/api/logout` and `/api/auth/logout` — a forged logout is low-severity, but consistent
enforcement was simpler to reason about than a per-route exception.
**One real bug found while testing this:** the Gateway's own progress-relay call
(`platform/gateway/src/server.js` `relayScenarioOutcome`, triggered when a student solves a
scenario inside their Lab) is a server-to-server call to `POST /api/progress/record`, not a
browser one — but it's relaying the browser's own session, so it needs to forward the CSRF
token too. The first fix attempt forwarded only the `X-CSRF-Token` header and still 403'd,
because the double-submit check needs BOTH the cookie *and* a matching header on the request
Platform API actually receives — the fix forwards both.
## 9. Login rate limiting
In-memory sliding window (`src/security.js`), keyed by `ip:email`, max 8 attempts per 5 minutes,
`429` past that. No new dependency — small enough to hand-roll. **Known limitation:** in-memory
means it resets on a Platform API restart and doesn't share state across multiple instances; a
Postgres- or Redis-backed limiter would be the upgrade if this ever needs to survive a restart
or scale beyond one process. Not needed at pilot scale.
## 10. Audit log
`audit_events`, distinct from the Provisioner's `environment_events` (Lab-lifecycle-only).
Records: instructor login success/failure, logout, password change, instructor
created/updated/deactivated, role changes, session revocation, cohort create/update, roster
changes, curriculum assignment, Prepare Labs, Lab reset/destroy, and every `403` authorization
denial (recorded centrally by the RBAC middleware itself, not sprinkled per-route). Never
records plaintext passwords, raw session tokens, Provisioner secrets, or raw routing tokens —
verified by an explicit test assertion (`e2e-phase3a.js` "audit log" case scans recorded
metadata for anything password-shaped). Viewable at `/admin` → Audit (owner/admin only), latest
200 events, no pagination UI yet — pilot-scale, not a SIEM.
## 11. API inventory (new/changed)
```
POST /api/auth/instructor/login email+password login, rate-limited, no enumeration
POST /api/auth/logout revokes the current instructor session
GET /api/auth/session current instructor identity (role, etc.)
POST /api/auth/change-password requires current password; revokes other sessions
POST /api/login UNCHANGED route, now backed by persistent sessions
POST /api/logout UNCHANGED route, now CSRF-checked + persistent revoke
GET /api/session/whoami UNCHANGED — Gateway's ownership-check dependency
GET /api/admin/instructors owner only
POST /api/admin/instructors owner only
GET /api/admin/instructors/:id owner only — includes session list
PATCH /api/admin/instructors/:id owner only — role/status/name; blocks removing the last active owner
POST /api/admin/instructors/:id/revoke-sessions owner only
POST /api/admin/instructors/:id/reset-password owner only — also revokes that instructor's sessions
GET /api/admin/audit-events admin/owner
POST /api/admin/cohorts/:id/instructors owner/admin — assigns an instructor to a cohort
```
Every pre-existing `/api/admin/*` route from Phase 2B still exists at the same path — behavior
unchanged for owner/admin, newly scoped for the `instructor` role (see §5/§6). `/api/admin/login`,
`/api/admin/logout`, `/api/admin/whoami` (the old shared-password routes) are gone — replaced by
the `/api/auth/instructor/*` routes above.
## 12. Frontend/admin changes
- `AdminPortal.jsx` — email+password login form (was a bare password field); nav now
conditionally shows Instructors (owner) and Audit (owner/admin) tabs; adds an Account tab.
- `InstructorsView.jsx` (new) — create/list/edit instructors, role/status controls, session list
+ revoke-all, password reset.
- `AuditView.jsx` (new) — read-only audit event table.
- `AccountView.jsx` (new) — change-password form for the signed-in instructor.
- `CohortsView.jsx` — create-cohort form and curriculum-assignment button hidden for the
`instructor` role (server already enforces this; this just avoids showing controls that would
403).
- `frontend/src/admin/api.js` / `frontend/src/platform/api.js` — both now read the relevant
`rn_csrf_*` cookie and attach `X-CSRF-Token` on every mutating call.
## 13. Security hardening review (what was checked)
- **Cookie settings** — `HttpOnly` on session cookies, `SameSite=Lax`, `Secure` gated behind
`COOKIE_SECURE=1` (off by default for local HTTP dev).
- **CSRF** — §8.
- **Login brute force** — §9.
- **Session fixation** — every login issues a brand-new session row (never reuses/upgrades an
existing token), so there's nothing to "fix" a pre-auth session into.
- **Password handling** — §4.
- **Account enumeration** — identical response for unknown-email and wrong-password (tested).
- **Authorization bypass** — every `/api/admin/*` route re-checks server-side (§5); tested with
both role-based and cohort-scoped negative cases.
- **Inactive accounts** — rejected at the session-middleware level on every request, not just at
login (a deactivation takes effect immediately, §3).
- **Revoked sessions** — `resolveSession`'s `WHERE revoked_at IS NULL` makes a revoked session
fail identically to an expired one.
- **Role downgrade/upgrade session invalidation** — handled by the live re-fetch design (§3),
not a separate revoke pass.
- **WebSocket authentication** — unchanged from Phase 2A/2B: the Gateway's WS upgrade handler
re-verifies ownership via the same session-resolution path as HTTP.
- **Gateway ownership checks** — unchanged mechanism, tightened cache TTL (5000ms → 2000ms) so a
revoked session stops working at the Gateway within a tighter, tested bound.
- **`trust proxy`** — off by default, opt-in via `TRUST_PROXY=1`, so `req.ip` (used for audit
`source_ip` and rate-limit keys) isn't trivially spoofable via `X-Forwarded-For` unless a real
trusted proxy is actually in front.
## 14. Test results
- `platform/provisioner/test/run.js` (Phase 1, isolated stack) — **not re-run this phase**: zero
source changes to `platform/provisioner/` (confirmed via `git diff`), and its own dev compose
stack collides on port 5433 with the shared `platform/docker-compose.dev.yml` stack used for
everything else in this phase. Provisioner was still exercised heavily and indirectly through
every e2e suite below (provision/reset/destroy, concurrent Prepare Labs, cohort-scoped
operations) — all passing.
- `platform/test/e2e-phase2a.js` — **10/10 passed** (student flow, now under persistent sessions).
- `platform/test/e2e-phase2b.js` — **12/12 passed** (classroom workflow, now under real
instructor accounts instead of a shared password).
- `platform/test/e2e-phase3a.js` — **21/21 passed**: no-enumeration, CSRF positive+negative,
rate limiting, role creation, RBAC denial (instructor→owner-only, instructor→admin-only,
admin→owner-only), cohort scoping (visibility + access), last-owner protection, live
role/status re-evaluation, password change + old-password rejection + other-session
revocation, audit log content (including the no-secrets assertion), student session RBAC,
student logout revocation, and session persistence across a real `docker compose restart api`.
- Real Lab image — rebuilt clean; standalone `--privileged` single-container mode boots and
serves with zero Platform API/Postgres/Provisioner dependency (verified live).
- Frontend production build — clean, no errors.
- Scenario content / Real Ops bundle / Lab `Dockerfile`/`backend/`/`scripts/` — `git diff`
against the Phase 2B milestone SHA is empty for all of them: untouched.
## 15. Known limitations
- **Login rate limiting is in-memory, single-process** (§9) — resets on restart, doesn't share
state across multiple Platform API instances. Fine at pilot scale.
- **Gateway session cache is a 2-second window** — a revoked/expired session can still work at
the Gateway for up to ~2s after revocation (tightened from 5s in Phase 2B). Platform API
itself always reflects revocation immediately; this bound is specific to the Gateway's
whoami-result cache, which exists purely to avoid a Platform API round trip on every single
proxied Lab byte.
- **No distributed session revocation push** — revoking a session updates Postgres immediately;
anything holding a *cached* copy of "this session is valid" (only the Gateway does this, and
only for 2s) catches up on its next cache miss, not instantly.
- **Audit log has no pagination UI** — latest 200 events only. Add pagination if usage ever
exceeds that being useful.
- **Roster name-picking is still not strong student identity** (§7) — explicitly documented,
not silently glossed over.
- **`cohort_instructors` role_in_cohort field exists but isn't consulted for finer-grained
permissions yet** — currently any assignment grants the full `instructor` role's cohort-scoped
actions; a co-teacher vs. lead-teacher distinction within a cohort would consume this field but
isn't built.
- **Exam mode's live bookkeeping is still Lab-local** — unchanged from Phase 2B, still a real gap
for anyone relying on cross-restart exam state.
## 16. Future OIDC path
`auth_identities` (§2) already supports it structurally: a Google/OIDC login would create a
second `auth_identities` row (`provider='google'`, `provider_subject=<their sub>`) pointing at
an existing `instructor_id`, or create a new `instructors` row + `auth_identities` row for a
first-time SSO login. The missing piece is the actual OIDC client flow (discovery, token
exchange, JWT verification) — deliberately **not built this phase**, per the explicit scope
instruction to stop at a documented adapter interface rather than pull in an OIDC client
library and expand scope. `instructors.password_hash` staying nullable (already the case) is
what allows an SSO-only account to exist with no local password at all, whenever that flow is
built.
## 17. Production HTTPS / cookie expectations
Local dev and the current single-VM pilot deployment run over plain HTTP — `COOKIE_SECURE` is
unset (cookies lack `Secure`) and `TRUST_PROXY` is unset by default. Before any real-HTTPS
production deployment: set `COOKIE_SECURE=1` (cookies become HTTPS-only) and, if fronted by a
reverse proxy/load balancer, set `TRUST_PROXY=1` so `req.ip` reflects the real client IP instead
of the proxy's.
## 18. BUILT NOW vs. FUTURE
**Built now:** persistent Postgres-backed sessions (student + instructor), per-instructor
accounts with scrypt password hashing, three-role RBAC enforced server-side, cohort-scoped
instructor access, CSRF double-submit protection, login rate limiting, platform audit log,
owner-safety invariant (never zero active owners), password change with other-session
revocation, `auth_identities` table ready for future OIDC mapping.
**Future (not built):** real OIDC/SSO client flow, multi-instance-safe rate limiting, audit log
pagination/export, cohort_instructors role-in-cohort permission granularity, distributed
(non-Gateway-cache) session revocation push, one-time student enrollment codes /
parent-issued codes, everything already on the Phase 2B "DO NOT BUILD YET" list (capstone
engine, billing, production Proxmox deployment, etc.) — unchanged, still not in scope.
+39
View File
@@ -0,0 +1,39 @@
import { useState } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
export default function AccountView({ instructor }) {
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setNotice(null)
try {
await adminApi.changePassword(oldPassword, newPassword)
setOldPassword(''); setNewPassword('')
setNotice('Password changed. Any other sessions you had open elsewhere were signed out.')
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<div className={styles.card}>
<h2>Account</h2>
<p className={styles.muted}>{instructor.display_name} · {instructor.email} · role: {instructor.role}</p>
<h3 style={{ marginTop: '1.5rem' }}>Change password</h3>
<form onSubmit={submit} className={styles.formRow}>
{error && <div className={styles.errorBox}>{error}</div>}
<div><label className={styles.label}>Current password</label>
<input className={styles.input} type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} autoComplete="current-password" required /></div>
<div><label className={styles.label}>New password (12+ chars)</label>
<input className={styles.input} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="new-password" minLength={12} required /></div>
<div><button className={styles.btn} disabled={busy}>{busy ? 'Changing…' : 'Change password'}</button></div>
</form>
{notice && <p className={styles.muted}>{notice}</p>}
</div>
)
}
+47 -20
View File
@@ -3,46 +3,66 @@ import { adminApi } from './api'
import AdminDashboard from './AdminDashboard'
import CohortsView from './CohortsView'
import StudentsView from './StudentsView'
import InstructorsView from './InstructorsView'
import AuditView from './AuditView'
import AccountView from './AccountView'
import styles from './Admin.module.css'
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
// separate rn_admin session cookie, separate login). A student session cannot reach any of
// these views or their underlying /api/admin/* routes — see
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §10.
// separate rn_instructor session cookie, separate login). A student session cannot reach any
// of these views or their underlying /api/admin/* routes. Per-person accounts replace Phase
// 2B's single shared ADMIN_PASSWORD — see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
const NAV = [
{ key: 'dashboard', label: 'Dashboard' },
{ key: 'cohorts', label: 'Cohorts' },
{ key: 'students', label: 'Students' },
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
]
export default function AdminPortal() {
const [authed, setAuthed] = useState(null) // null = checking, false = need login, true = in
const [instructor, setInstructor] = useState(null) // null = checking, false = need login, object = signed in
const [view, setView] = useState('dashboard')
useEffect(() => {
adminApi.whoami().then(() => setAuthed(true)).catch(() => setAuthed(false))
adminApi.session().then((r) => setInstructor(r.instructor)).catch(() => setInstructor(false))
}, [])
if (authed === null) return null
if (!authed) return <AdminLogin onLogin={() => setAuthed(true)} />
if (instructor === null) return null
if (!instructor) return <InstructorLogin onLogin={setInstructor} />
const visibleNav = NAV.filter((item) => !item.roles || item.roles.includes(instructor.role))
return (
<div className={styles.wrap}>
<header className={styles.header}>
<div className={styles.brand}>⚙ RootNode Admin</div>
<nav className={styles.nav}>
{['dashboard', 'cohorts', 'students'].map((v) => (
<button key={v} className={`${styles.navLink} ${view === v ? styles.navLinkActive : ''}`} onClick={() => setView(v)}>
{v[0].toUpperCase() + v.slice(1)}
{visibleNav.map((item) => (
<button key={item.key} className={`${styles.navLink} ${view === item.key ? styles.navLinkActive : ''}`} onClick={() => setView(item.key)}>
{item.label}
</button>
))}
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setAuthed(false))}>Sign out</button>
<button className={`${styles.navLink} ${view === 'account' ? styles.navLinkActive : ''}`} onClick={() => setView('account')}>
{instructor.display_name}
</button>
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setInstructor(false))}>Sign out</button>
</nav>
</header>
<div className={styles.content}>
{view === 'dashboard' && <AdminDashboard />}
{view === 'cohorts' && <CohortsView />}
{view === 'students' && <StudentsView />}
{view === 'cohorts' && <CohortsView role={instructor.role} />}
{view === 'students' && <StudentsView role={instructor.role} />}
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
{view === 'account' && <AccountView instructor={instructor} />}
</div>
</div>
)
}
function AdminLogin({ onLogin }) {
function InstructorLogin({ onLogin }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
@@ -50,9 +70,14 @@ function AdminLogin({ onLogin }) {
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try { await adminApi.login(password); onLogin() }
catch (e) { setError('Incorrect password.') }
finally { setBusy(false) }
try {
const { instructor } = await adminApi.login(email, password)
onLogin(instructor)
} catch (e) {
setError(e.message || 'Sign-in failed.')
} finally {
setBusy(false)
}
}
return (
@@ -61,9 +86,11 @@ function AdminLogin({ onLogin }) {
<div className={styles.brand}>⚙ RootNode Admin</div>
<p className={styles.subtitle}>Instructor sign-in. Not connected to student accounts.</p>
{error && <div className={styles.errorBox}>{error}</div>}
<label className={styles.label}>Password</label>
<input className={styles.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus />
<button className={styles.btn} style={{ marginTop: '1.25rem', width: '100%' }} disabled={busy || !password}>
<label className={styles.label}>Email</label>
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus autoComplete="username" />
<label className={styles.label} style={{ marginTop: '0.75rem' }}>Password</label>
<input className={styles.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
<button className={styles.btn} style={{ marginTop: '1.25rem', width: '100%' }} disabled={busy || !email || !password}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
+38
View File
@@ -0,0 +1,38 @@
import { useState, useEffect } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// admin/owner only (enforced server-side too). Latest 200 events, no pagination UI — this is a
// pilot-scale audit log, not a SIEM; add pagination if that ever stops being true.
export default function AuditView() {
const [events, setEvents] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
adminApi.auditEvents().then(setEvents).catch((e) => setError(e.message))
}, [])
return (
<div className={styles.card}>
<h2>Audit log</h2>
<p className={styles.muted}>Most recent 200 security/admin events.</p>
{error && <div className={styles.errorBox}>{error}</div>}
{!events ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
<tbody>
{events.map((e) => (
<tr key={e.audit_event_id}>
<td>{new Date(e.created_at).toLocaleString()}</td>
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
<td>{e.action}</td>
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
+23 -16
View File
@@ -2,25 +2,28 @@ import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
export default function CohortsView() {
export default function CohortsView({ role }) {
const [cohorts, setCohorts] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const canManage = role !== 'instructor' // owner/admin only — a plain instructor works within cohorts already assigned to them
const reload = useCallback(() => {
adminApi.cohorts().then(setCohorts).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <CohortDetail cohortId={selected} onBack={() => { setSelected(null); reload() }} />
if (selected) return <CohortDetail cohortId={selected} canManage={canManage} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h2>Create cohort</h2>
<CreateCohortForm onCreated={reload} />
</div>
{canManage && (
<div className={styles.card}>
<h2>Create cohort</h2>
<CreateCohortForm onCreated={reload} />
</div>
)}
<div className={styles.card}>
<h2>Cohorts</h2>
{!cohorts ? <p className={styles.muted}>Loading…</p> : cohorts.length === 0 ? <p className={styles.muted}>None yet.</p> : (
@@ -79,7 +82,7 @@ function CreateCohortForm({ onCreated }) {
)
}
function CohortDetail({ cohortId, onBack }) {
function CohortDetail({ cohortId, canManage, onBack }) {
const [students, setStudents] = useState(null)
const [progress, setProgress] = useState(null)
const [prepResults, setPrepResults] = useState(null)
@@ -136,10 +139,12 @@ function CohortDetail({ cohortId, onBack }) {
<div className={styles.card}>
<h3>Roster ({students?.length ?? '…'})</h3>
<form onSubmit={addStudent} className={styles.formRow}>
<div><input className={styles.input} placeholder="New student name" value={newStudentName} onChange={(e) => setNewStudentName(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy}>Add student</button></div>
</form>
{canManage && (
<form onSubmit={addStudent} className={styles.formRow}>
<div><input className={styles.input} placeholder="New student name" value={newStudentName} onChange={(e) => setNewStudentName(e.target.value)} /></div>
<div><button className={styles.btn} disabled={busy}>Add student</button></div>
</form>
)}
{students && (
<table>
<thead><tr><th>Name</th><th>Status</th><th>Last active</th></tr></thead>
@@ -156,11 +161,13 @@ function CohortDetail({ cohortId, onBack }) {
)}
</div>
<div className={styles.card}>
<h3>Curriculum</h3>
<p className={styles.muted}>RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)</p>
<button className={styles.btnSecondary} onClick={assignCurriculum} disabled={busy}>Assign RootNode Path to cohort</button>
</div>
{canManage && (
<div className={styles.card}>
<h3>Curriculum</h3>
<p className={styles.muted}>RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)</p>
<button className={styles.btnSecondary} onClick={assignCurriculum} disabled={busy}>Assign RootNode Path to cohort</button>
</div>
)}
{progress && (
<div className={styles.card}>
+180
View File
@@ -0,0 +1,180 @@
import { useState, useEffect, useCallback } from 'react'
import { adminApi } from './api'
import styles from './Admin.module.css'
// Owner-only — the server enforces this too (requireOwner on every /api/admin/instructors*
// route); this component is only ever mounted for an owner, but the backend never trusts that.
export default function InstructorsView({ currentInstructorId }) {
const [instructors, setInstructors] = useState(null)
const [selected, setSelected] = useState(null)
const [error, setError] = useState(null)
const reload = useCallback(() => {
adminApi.instructors().then(setInstructors).catch((e) => setError(e.message))
}, [])
useEffect(reload, [reload])
if (selected) return <InstructorDetail instructorId={selected} isSelf={selected === currentInstructorId} onBack={() => { setSelected(null); reload() }} />
return (
<>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.card}>
<h2>Add instructor</h2>
<CreateInstructorForm onCreated={reload} />
</div>
<div className={styles.card}>
<h2>Instructors</h2>
{!instructors ? <p className={styles.muted}>Loading…</p> : (
<table>
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th /></tr></thead>
<tbody>
{instructors.map((i) => (
<tr key={i.instructor_id}>
<td>{i.display_name}</td>
<td>{i.email}</td>
<td><span className={styles.pill}>{i.role}</span></td>
<td><span className={`${styles.pill} ${i.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{i.status}</span></td>
<td>{i.last_login_at ? new Date(i.last_login_at).toLocaleString() : 'never'}</td>
<td><button className={styles.linkBtn} onClick={() => setSelected(i.instructor_id)}>Open</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)
}
function CreateInstructorForm({ onCreated }) {
const [form, setForm] = useState({ email: '', display_name: '', role: 'instructor', password: '' })
const [error, setError] = useState(null)
const [busy, setBusy] = useState(false)
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError(null)
try {
await adminApi.createInstructor(form)
setForm({ email: '', display_name: '', role: 'instructor', password: '' })
onCreated()
} catch (e) { setError(e.message) } finally { setBusy(false) }
}
return (
<form onSubmit={submit}>
{error && <div className={styles.errorBox}>{error}</div>}
<div className={styles.formRow}>
<div><label className={styles.label}>Name</label>
<input className={styles.input} value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} required /></div>
<div><label className={styles.label}>Email</label>
<input className={styles.input} type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /></div>
<div><label className={styles.label}>Role</label>
<select className={styles.select} value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
<option value="instructor">instructor</option>
<option value="admin">admin</option>
<option value="owner">owner</option>
</select></div>
<div><label className={styles.label}>Temporary password (12+ chars)</label>
<input className={styles.input} type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={12} /></div>
<div><button className={styles.btn} disabled={busy}>{busy ? 'Adding…' : 'Add instructor'}</button></div>
</div>
</form>
)
}
function InstructorDetail({ instructorId, isSelf, onBack }) {
const [instructor, setInstructor] = useState(null)
const [error, setError] = useState(null)
const [notice, setNotice] = useState(null)
const [busy, setBusy] = useState(false)
const [newPassword, setNewPassword] = useState('')
const reload = useCallback(() => {
adminApi.instructor(instructorId).then(setInstructor).catch((e) => setError(e.message))
}, [instructorId])
useEffect(reload, [reload])
const updateRole = async (role) => {
setBusy(true); setError(null); setNotice(null)
try { await adminApi.updateInstructor(instructorId, { role }); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
const toggleStatus = async () => {
const status = instructor.status === 'active' ? 'inactive' : 'active'
setBusy(true); setError(null); setNotice(null)
try { await adminApi.updateInstructor(instructorId, { status }); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
const revokeSessions = async () => {
setBusy(true); setError(null)
try { await adminApi.revokeInstructorSessions(instructorId); setNotice('All sessions revoked.'); reload() }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
const resetPassword = async (e) => {
e.preventDefault()
setBusy(true); setError(null); setNotice(null)
try { await adminApi.resetInstructorPassword(instructorId, newPassword); setNewPassword(''); setNotice('Password reset. All of their sessions were revoked.') }
catch (e) { setError(e.message) } finally { setBusy(false) }
}
if (!instructor) return error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>
return (
<>
<button className={styles.linkBtn} onClick={onBack}>&larr; Back to instructors</button>
<h2 style={{ margin: '1rem 0' }}>{instructor.display_name} <span className={styles.muted}>({instructor.email})</span></h2>
{error && <div className={styles.errorBox}>{error}</div>}
{notice && <p className={styles.muted}>{notice}</p>}
<div className={styles.card}>
<h3>Role &amp; status</h3>
<div className={styles.formRow}>
<div><label className={styles.label}>Role</label>
<select className={styles.select} value={instructor.role} onChange={(e) => updateRole(e.target.value)} disabled={busy}>
<option value="instructor">instructor</option>
<option value="admin">admin</option>
<option value="owner">owner</option>
</select></div>
<div><button className={instructor.status === 'active' ? styles.btnDanger : styles.btnSecondary} onClick={toggleStatus} disabled={busy}>
{instructor.status === 'active' ? 'Deactivate' : 'Activate'}
</button></div>
</div>
{isSelf && <p className={styles.muted}>This is your own account — the platform refuses to remove the last active owner, whichever account that is.</p>}
</div>
<div className={styles.card}>
<h3>Sessions</h3>
<p className={styles.muted}>Last login: {instructor.last_login_at ? new Date(instructor.last_login_at).toLocaleString() : 'never'}</p>
{instructor.sessions?.length ? (
<table>
<thead><tr><th>Created</th><th>Last active</th><th>Expires</th><th>Status</th></tr></thead>
<tbody>
{instructor.sessions.map((s) => (
<tr key={s.session_id}>
<td>{new Date(s.created_at).toLocaleString()}</td>
<td>{new Date(s.last_seen_at).toLocaleString()}</td>
<td>{new Date(s.expires_at).toLocaleString()}</td>
<td>{s.revoked_at ? <span className={`${styles.pill} ${styles.pillMuted}`}>revoked</span> : <span className={`${styles.pill} ${styles.pillOk}`}>active</span>}</td>
</tr>
))}
</tbody>
</table>
) : <p className={styles.muted}>No sessions on record.</p>}
<button className={styles.btnDanger} style={{ marginTop: '0.75rem' }} onClick={revokeSessions} disabled={busy}>Revoke all sessions</button>
</div>
<div className={styles.card}>
<h3>Reset password</h3>
<form onSubmit={resetPassword} className={styles.formRow}>
<div><input className={styles.input} type="password" placeholder="New password (12+ chars)" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} minLength={12} required /></div>
<div><button className={styles.btn} disabled={busy}>Set new password</button></div>
</form>
</div>
</>
)
}
+34 -7
View File
@@ -1,7 +1,17 @@
// Admin API client. Uses the rn_admin session cookie exclusively (same-origin, sent
// Admin API client. Uses the rn_instructor session cookie exclusively (same-origin, sent
// automatically) — completely separate credential/cookie from student sessions (see
// platform/api/src/sessions.js). No admin data is ever cached client-side beyond the current
// React state; nothing here reads or writes localStorage.
//
// rn_csrf_i is a deliberately non-HttpOnly cookie (double-submit CSRF pattern, mirrored on the
// student side in frontend/src/platform/api.js) — read here and echoed back as a header on
// every state-changing request.
const CSRF_COOKIE = 'rn_csrf_i'
function getCookie(name) {
return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1]
}
async function json(res) {
if (!res.ok) {
@@ -11,23 +21,30 @@ async function json(res) {
return res.json()
}
function withCsrf(init) {
return { ...init, headers: { ...init.headers, 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' } }
}
function post(path, body) {
return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(json)
return fetch(path, withCsrf({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })).then(json)
}
function patch(path, body) {
return fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(json)
return fetch(path, withCsrf({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).then(json)
}
function del(path) {
return fetch(path, { method: 'DELETE' }).then(json)
return fetch(path, withCsrf({ method: 'DELETE' })).then(json)
}
function get(path) {
return fetch(path).then(json)
}
export const adminApi = {
login: (password) => post('/api/admin/login', { password }),
logout: () => post('/api/admin/logout'),
whoami: () => get('/api/admin/whoami'),
// No CSRF token yet at login (nothing authenticated to forge into).
login: (email, password) => fetch('/api/auth/instructor/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }),
}).then(json),
logout: () => post('/api/auth/logout'),
session: () => get('/api/auth/session'),
changePassword: (old_password, new_password) => post('/api/auth/change-password', { old_password, new_password }),
dashboard: () => get('/api/admin/dashboard'),
@@ -47,10 +64,20 @@ export const adminApi = {
cohortStudents: (id) => get(`/api/admin/cohorts/${id}/students`),
cohortProgress: (id) => get(`/api/admin/cohorts/${id}/progress`),
assignCohortCurriculum: (id, curriculum_path) => post(`/api/admin/cohorts/${id}/curriculum`, { curriculum_path }),
assignCohortInstructor: (id, instructor_id, role_in_cohort) => post(`/api/admin/cohorts/${id}/instructors`, { instructor_id, role_in_cohort }),
prepareLabs: (id) => post(`/api/admin/cohorts/${id}/prepare-labs`),
destroyLabs: (id) => post(`/api/admin/cohorts/${id}/destroy-labs`),
resetEnvironment: (id) => post(`/api/admin/environments/${id}/reset`),
destroyEnvironment: (id) => del(`/api/admin/environments/${id}`),
environmentEvents: (id) => get(`/api/admin/environments/${id}/events`),
instructors: () => get('/api/admin/instructors'),
createInstructor: (fields) => post('/api/admin/instructors', fields),
instructor: (id) => get(`/api/admin/instructors/${id}`),
updateInstructor: (id, fields) => patch(`/api/admin/instructors/${id}`, fields),
revokeInstructorSessions: (id) => post(`/api/admin/instructors/${id}/revoke-sessions`),
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
auditEvents: () => get('/api/admin/audit-events'),
}
+29 -8
View File
@@ -1,14 +1,21 @@
// Platform Shell API client. Phase 2B: identity is a server-controlled session cookie
// (rn_session, HttpOnly — this code can't read it, and doesn't need to; the browser sends it
// automatically on every same-origin request). This replaces Phase 2A's localStorage
// student_id + X-Student-Id header, which was explicitly pilot-only and client-forgeable.
// See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
// Platform Shell API client. Identity is a server-controlled session cookie (rn_session,
// HttpOnly — this code can't read it, and doesn't need to; the browser sends it automatically
// on every same-origin request). Roster name-picking is classroom/pilot identification, not
// production authentication (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5) — what's
// hardened here is the session that results from it: persistent (survives an API restart),
// unforgeable, and checked on every request.
//
// display_name is still cached in sessionStorage purely for UI convenience (showing "Hi,
// Alice" without an extra round trip) — it carries no authority; every actual API call is
// authorized by the cookie alone, not by anything read from here.
//
// rn_csrf_s is a deliberately non-HttpOnly cookie (double-submit CSRF pattern) — this code
// reads it and echoes it back as a header on every state-changing request, proving the request
// came from our own origin's JS (a cross-origin attacker can trigger the cookie but can't read
// its value or attach a matching custom header without CORS, which we don't grant elsewhere).
const DISPLAY_CACHE_KEY = 'rootnode_student_display'
const CSRF_COOKIE = 'rn_csrf_s'
export function getCachedDisplay() {
try { return JSON.parse(sessionStorage.getItem(DISPLAY_CACHE_KEY) || 'null') } catch { return null }
@@ -20,6 +27,10 @@ function clearCachedDisplay() {
sessionStorage.removeItem(DISPLAY_CACHE_KEY)
}
function getCookie(name) {
return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1]
}
async function json(res) {
if (!res.ok) {
const body = await res.json().catch(() => ({}))
@@ -28,10 +39,20 @@ async function json(res) {
return res.json()
}
function mutate(path, body) {
return fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' },
body: body ? JSON.stringify(body) : undefined,
}).then(json)
}
export const api = {
cohorts: () => fetch('/api/roster/cohorts').then(json),
roster: (cohort) => fetch(`/api/roster?cohort=${encodeURIComponent(cohort)}`).then(json),
login: async (student_id) => {
// No CSRF token yet at login (nothing authenticated to forge into) — same reasoning as the
// instructor login route.
const student = await fetch('/api/login', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }),
}).then(json)
@@ -39,7 +60,7 @@ export const api = {
return student
},
logout: async () => {
await fetch('/api/logout', { method: 'POST' }).catch(() => {})
await mutate('/api/logout').catch(() => {})
clearCachedDisplay()
},
whoami: () => fetch('/api/session/whoami').then(json),
@@ -47,6 +68,6 @@ export const api = {
curriculumPath: () => fetch('/api/curriculum/path').then(json),
environmentStatus: () => fetch('/api/environment/status').then(json),
launchEnvironment: () => fetch('/api/environment/launch', { method: 'POST' }).then(json),
resetEnvironment: () => fetch('/api/environment/reset', { method: 'POST' }).then(json),
launchEnvironment: () => mutate('/api/environment/launch'),
resetEnvironment: () => mutate('/api/environment/reset'),
}
+26 -14
View File
@@ -7,13 +7,15 @@ Start here, then go to the component you need:
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
`provisioner/README.md`.
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, progress,
scenario attempts, student/admin sessions, and the instructor/admin control plane. No k3s, no
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
booting an unused k3s cluster just to serve these APIs. Design:
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`. Operational reference: `api/README.md`.
scenario attempts, per-instructor accounts, persistent student/instructor sessions, RBAC,
audit logging, and the instructor/admin control plane. No k3s, no `--privileged` — this
replaced Phase 2A's "identity" instance, which was a full Lab image booting an unused k3s
cluster just to serve these APIs. Design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split +
Postgres migration), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts,
RBAC, audit). Operational reference: `api/README.md`.
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
routing derived from a server-controlled session — not a client-suppliable header/param).
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3 (session ownership).
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (persistent sessions, CSRF).
- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through.
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, Platform API,
Gateway) on one machine. This is the Phase 1/2A/2B **local dev exception** — production is
@@ -24,14 +26,20 @@ Start here, then go to the component you need:
```bash
cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
docker compose -f docker-compose.dev.yml build
docker compose -f docker-compose.dev.yml up -d
# First run only — create the owner account (no ADMIN_PASSWORD anymore, see
# docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md):
cd api
PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus"
```
No k3s boot to wait for at this layer anymore (Platform API starts instantly) — the whole
platform is reachable at `http://localhost:4200` (the Gateway) right away: students at `/`,
instructors at `/admin`.
instructors at `/admin` (sign in with the owner account you just bootstrapped, then create
further instructor accounts from the Instructors tab).
## Tests
@@ -42,16 +50,20 @@ instructors at `/admin`.
- `test/e2e-phase2b.js` — the classroom acceptance workflow: instructor creates a cohort, adds
students, assigns curriculum, Prepare Labs (concurrent), students complete different
scenarios, instructor resets one, instructor destroys all cohort Labs after class — progress
survives every step. Needs `ADMIN_PASSWORD` set (see `platform/.env`).
survives every step.
- `test/e2e-phase3a.js` — persistent sessions (including a real Platform API restart), instructor
auth (no enumeration, rate limiting), RBAC across owner/admin/instructor, cohort-scoped
instructor access, CSRF enforcement, password change, and audit logging.
Both e2e suites are slow (real k3s boots) and need the full stack up. Neither substitutes for
the manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation)
in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
All three e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
gone. They're slow (real k3s boots) and need the full stack up. None of them substitute for the
manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation) in
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
aren't automated here.
## Known limitations
See `provisioner/README.md` (Phase 1), `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 (Phase
2A), and `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 (Phase 2B) for the full, honest lists —
including in-memory sessions, the single shared admin credential, and exam mode's live
bookkeeping staying Lab-local.
2A), and `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (Phase 3A, supersedes Phase 2B's
identity limitations) for the full, honest lists.
+41 -15
View File
@@ -1,7 +1,9 @@
# RootNode Academy — Platform API (Phase 2B)
# RootNode Academy — Platform API (Phase 3A)
Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the design; this is the operational reference.
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the original split/migration design and
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` for persistent sessions, per-instructor
accounts, RBAC, and audit logging — this file is the operational reference.
## Configuration reference
@@ -10,25 +12,46 @@ Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See
| `PLATFORM_DB_URL` | no (defaults to local dev Postgres) | Postgres connection string |
| `PROVISIONER_URL` | no | Provisioner base URL |
| `PROVISIONER_SHARED_SECRET` | **yes** | Internal Provisioner auth — process refuses to start without it |
| `ADMIN_PASSWORD` | **yes** | Instructor login credential — process refuses to start without it. Verified via scrypt+timing-safe compare, never stored or logged in plaintext |
| `PLATFORM_API_PORT` | no | Listen port, default 4300 |
| `FRONTEND_DIST_DIR` | no | Where the built frontend lives — set by the Dockerfile, override for local non-Docker dev |
| `SCENARIOS_DIR` / `BUNDLES_DIR` / `CURRICULUM_PATH_FILE` | no | Override the default `scenarios/` locations, mainly for local dev outside Docker |
| `STUDENT_SESSION_TTL_HOURS` / `STUDENT_SESSION_IDLE_HOURS` | no | Default 8h / 2h |
| `INSTRUCTOR_SESSION_TTL_HOURS` / `INSTRUCTOR_SESSION_IDLE_HOURS` | no | Default 12h / 2h |
| `COOKIE_SECURE` | no | Set to `1` once served over real HTTPS — adds `Secure` to every session/CSRF cookie |
| `TRUST_PROXY` | no | Set to `1` once fronted by a trusted reverse proxy — honors `X-Forwarded-For` for `req.ip` (audit `source_ip`, login rate limiting) |
There is no `ADMIN_PASSWORD` anymore — instructor accounts live in Postgres (`instructors`
table), created via `bootstrap-owner.js` for the first one and the admin portal after that.
## Local dev
```bash
cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
docker compose -f docker-compose.dev.yml build
docker compose -f docker-compose.dev.yml up -d
cd api
PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus"
```
Everything is reachable through the Gateway at `http://localhost:4200` — students at `/`,
instructors at `/admin`. Platform API itself isn't meant to be hit directly by a browser (same
boundary as the Provisioner relative to Platform API), though nothing stops you from curling
it directly at `:4300` for local debugging.
instructors at `/admin` (sign in with the bootstrapped owner). Platform API itself isn't meant
to be hit directly by a browser (same boundary as the Provisioner relative to Platform API),
though nothing stops you from curling it directly at `:4300` for local debugging.
## Owner bootstrap
```bash
node bootstrap-owner.js <email> <display_name> [password]
```
Prefer `BOOTSTRAP_OWNER_PASSWORD` over the positional argument outside a throwaway local test —
CLI args can leak into shell history and `ps`. Refuses to run if an active owner already exists
unless the target email belongs to an existing instructor (the intended recovery path if a
platform is ever locked out with zero owners: point this at a known instructor account to
promote them).
## SQLite migration
@@ -42,17 +65,20 @@ docker run --rm -v <identity-data-volume>:/data \
Always backs up the SQLite file first, is safe to re-run, and writes a JSON report — see
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §2/§3 for the full guarantees and rollback
procedure.
procedure. This step is about academic data (students/cohorts/progress) — it has nothing to do
with instructor accounts, which are Phase 3A-native and start empty until you bootstrap one.
## Tests
`platform/test/e2e-phase2a.js` (student-flow regression, cookie-based) and
`platform/test/e2e-phase2b.js` (the classroom acceptance workflow: cohort → students →
curriculum → Prepare Labs → complete scenarios → reset → destroy-labs → progress survives).
Both need the full stack up and `ADMIN_PASSWORD` set — see `platform/README.md`.
`platform/test/e2e-phase2a.js` (student-flow regression), `platform/test/e2e-phase2b.js` (the
classroom acceptance workflow), and `platform/test/e2e-phase3a.js` (persistent sessions
including a real restart, instructor auth, RBAC, cohort scoping, CSRF, audit log). All three
need the full stack up and `OWNER_EMAIL` / `OWNER_PASSWORD` set to a bootstrapped account — see
`platform/README.md`.
## Known limitations
See `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 — in-memory sessions (a restart logs
everyone out), exam mode's live bookkeeping stays Lab-local, single shared admin credential
rather than per-instructor accounts.
See `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` "Known limitations" — sessions are
Postgres-backed (survive a restart) but there's no distributed revocation channel to the
Gateway's short-lived whoami cache, exam mode's live bookkeeping stays Lab-local, and OIDC/SSO
is a documented adapter interface, not a working integration yet.
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
// One-time owner bootstrap — the only supported way to create the first instructor account,
// so standing up a fresh Platform API never requires editing Postgres by hand.
//
// node bootstrap-owner.js [email protected] "Your Name" [password]
//
// Prefer BOOTSTRAP_OWNER_PASSWORD over the CLI argument in anything other than a throwaway
// local test — CLI args can leak into shell history and `ps`.
//
// Refuses to run if an active owner already exists, UNLESS the target email belongs to an
// existing instructor — in that case it promotes them to owner (the intended recovery path if
// a platform is ever locked out with zero owners: point this at a known instructor account).
const { getPool, runMigrations } = require('./src/db');
const { hashPassword } = require('./src/auth/password');
async function main() {
const [, , email, displayName, cliPassword] = process.argv;
const password = cliPassword || process.env.BOOTSTRAP_OWNER_PASSWORD;
if (!email || !displayName || !password) {
console.error('Usage: node bootstrap-owner.js <email> <display_name> [password]');
console.error(' (or set BOOTSTRAP_OWNER_PASSWORD instead of passing the password)');
process.exit(1);
}
if (password.length < 12) {
console.error('Password must be at least 12 characters.');
process.exit(1);
}
await runMigrations();
const pool = getPool();
const emailLower = email.toLowerCase().trim();
const { rows: existing } = await pool.query(`SELECT instructor_id, role, status FROM instructors WHERE email = $1`, [emailLower]);
const { rows: ownerCount } = await pool.query(`SELECT count(*)::int AS n FROM instructors WHERE role = 'owner' AND status = 'active'`);
if (ownerCount[0].n > 0 && !existing[0]) {
console.error(`Refusing: ${ownerCount[0].n} active owner(s) already exist and ${emailLower} isn't one of your instructors.`);
console.error('Use the admin API (as an existing owner) to add instructors instead.');
process.exit(1);
}
const passwordHash = hashPassword(password);
let instructorId;
const client = await pool.connect();
try {
await client.query('BEGIN');
if (existing[0]) {
await client.query(
`UPDATE instructors SET password_hash = $1, display_name = $2, role = 'owner', status = 'active', updated_at = now() WHERE instructor_id = $3`,
[passwordHash, displayName.trim(), existing[0].instructor_id]
);
instructorId = existing[0].instructor_id;
} else {
const { rows } = await client.query(
`INSERT INTO instructors (email, display_name, password_hash, role, status) VALUES ($1, $2, $3, 'owner', 'active') RETURNING instructor_id`,
[emailLower, displayName.trim(), passwordHash]
);
instructorId = rows[0].instructor_id;
await client.query(
`INSERT INTO auth_identities (instructor_id, provider, provider_subject, provider_email) VALUES ($1, 'local', $2, $3)`,
[instructorId, String(instructorId), emailLower]
);
}
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
console.log(`Owner ready: ${emailLower} (${instructorId})`);
process.exit(0);
}
main().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });
@@ -0,0 +1,20 @@
-- instructors — replaces the single shared ADMIN_PASSWORD (Phase 2B) with real per-person
-- accounts. password_hash is "<salt-hex>:<hash-hex>" from scrypt (see src/auth/password.js),
-- NULL only if an instructor is ever external-auth-only (future OIDC, see auth_identities).
--
-- Roles are a plain CHECK, not a lookup table — three roles, not expected to churn often, and
-- a CHECK is enough to keep bad data out without a join on every permission check.
CREATE TABLE IF NOT EXISTS instructors (
instructor_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE, -- always stored lowercased by the application
display_name TEXT NOT NULL,
password_hash TEXT,
role TEXT NOT NULL DEFAULT 'instructor' CHECK (role IN ('owner', 'admin', 'instructor')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_instructors_role_status ON instructors(role, status);
@@ -0,0 +1,18 @@
-- auth_identities — lets a local-password identity and a future OIDC identity map to the same
-- instructor record without a schema change when OIDC lands. Every instructor gets a 'local'
-- row today (provider_subject = their own instructor_id as text); a Google/OIDC login later
-- just adds a second row for the same instructor_id. See
-- docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for the adapter interface this backs.
CREATE TABLE IF NOT EXISTS auth_identities (
auth_identity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instructor_id UUID NOT NULL REFERENCES instructors(instructor_id) ON DELETE CASCADE,
provider TEXT NOT NULL, -- 'local' | 'google' | 'oidc:<name>'
provider_subject TEXT NOT NULL,
provider_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ,
UNIQUE (provider, provider_subject)
);
CREATE INDEX IF NOT EXISTS idx_auth_identities_instructor ON auth_identities(instructor_id);
+35
View File
@@ -0,0 +1,35 @@
-- sessions — replaces Phase 2B's in-memory studentSessions/adminSessions Maps. A Platform API
-- restart no longer logs everyone out, and sessions are individually revocable/auditable.
--
-- Only a SHA-256 hash of the opaque bearer token is stored (token_hash) — never the raw token
-- itself, so a database read (backup, dump, compromised replica) can't be turned into working
-- session cookies.
--
-- expires_at is the absolute ceiling (set once, at creation). idle_expires_at slides forward on
-- every authenticated request (see src/sessions.js resolve*Session), capped at expires_at —
-- whichever of the two is hit first ends the session. session_type distinguishes student vs.
-- instructor sessions; the CHECK below keeps a session row from ever being ambiguous about
-- which kind it is, so a bug elsewhere can't accidentally treat one as the other.
CREATE TABLE IF NOT EXISTS sessions (
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_type TEXT NOT NULL CHECK (session_type IN ('student', 'instructor')),
student_id TEXT REFERENCES students(student_id),
instructor_id UUID REFERENCES instructors(instructor_id),
token_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
idle_expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
metadata JSONB,
CHECK (
(session_type = 'student' AND student_id IS NOT NULL AND instructor_id IS NULL) OR
(session_type = 'instructor' AND instructor_id IS NOT NULL AND student_id IS NULL)
)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_sessions_instructor ON sessions(instructor_id) WHERE instructor_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_student ON sessions(student_id) WHERE student_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expires_at);
@@ -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);
@@ -0,0 +1,20 @@
-- audit_events — platform-level security/admin audit log, distinct from the Provisioner's
-- environment_events (which is Lab-lifecycle-only). Never stores plaintext passwords, raw
-- session tokens, Provisioner secrets, or raw routing tokens — see src/audit.js callers.
CREATE TABLE IF NOT EXISTS audit_events (
audit_event_id BIGSERIAL PRIMARY KEY,
actor_type TEXT NOT NULL, -- 'instructor' | 'student' | 'system'
actor_id TEXT, -- instructor_id or student_id as text; NULL for e.g. a failed login with no matching account
action TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
result TEXT NOT NULL, -- 'success' | 'failure'
source_ip TEXT,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_events(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor_type, actor_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
+2 -1
View File
@@ -6,7 +6,8 @@
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"migrate-sqlite": "node migrate-sqlite.js"
"migrate-sqlite": "node migrate-sqlite.js",
"bootstrap-owner": "node bootstrap-owner.js"
},
"dependencies": {
"express": "^4.19.2",
-29
View File
@@ -1,29 +0,0 @@
// Admin password verification. Phase 2B scope, explicitly not production SSO/OIDC (that's
// Phase 3+, documented in docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md).
//
// ADMIN_PASSWORD is read from the environment (the same secret-injection channel every other
// component here uses — PROVISIONER_SHARED_SECRET, etc. — not a new pattern). It is never
// stored in a database or logged. Comparison is done via scrypt + a timing-safe compare rather
// than a plain string comparison, so a login attempt never directly compares raw password
// bytes (defense in depth, cheap to do correctly with only Node's stdlib — no new dependency).
const crypto = require('crypto');
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
const SALT = 'rootnode-admin-fixed-salt-v1'; // fixed salt is fine here: single credential, not
// a multi-user password table needing per-row salts
if (!ADMIN_PASSWORD) {
console.error('FATAL: ADMIN_PASSWORD is not set. Refusing to start with no way to authenticate an instructor.');
process.exit(1);
}
const expectedHash = crypto.scryptSync(ADMIN_PASSWORD, SALT, 64);
function verifyAdminPassword(submitted) {
if (!submitted || typeof submitted !== 'string') return false;
const submittedHash = crypto.scryptSync(submitted, SALT, 64);
return crypto.timingSafeEqual(expectedHash, submittedHash);
}
module.exports = { verifyAdminPassword };
+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);
});
};
+20
View File
@@ -0,0 +1,20 @@
// Platform-level security/admin audit log — distinct from the Provisioner's environment_events
// (Lab lifecycle only). Never pass plaintext passwords, raw session tokens, Provisioner
// secrets, or raw routing tokens into `metadata` — callers are responsible for that, this
// module doesn't scrub input.
async function recordAudit(pool, { actorType, actorId, action, resourceType, resourceId, result, sourceIp, metadata }) {
try {
await pool.query(
`INSERT INTO audit_events (actor_type, actor_id, action, resource_type, resource_id, result, source_ip, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[actorType, actorId ? String(actorId) : null, action, resourceType || null, resourceId ? String(resourceId) : null,
result, sourceIp || null, metadata ? JSON.stringify(metadata) : null]
);
} catch (e) {
// Audit logging must never take down the request it's describing.
console.error('[audit] failed to record event:', action, e.message);
}
}
module.exports = { recordAudit };
+27
View File
@@ -0,0 +1,27 @@
// Password hashing for instructor accounts — scrypt (Node stdlib, no new dependency) with a
// fresh random salt per instructor, stored alongside the hash as "<salt-hex>:<hash-hex>".
// Phase 2B's admin-auth.js used one FIXED salt because there was exactly one shared password;
// now that there are real per-person accounts, each one gets its own salt.
const crypto = require('crypto');
const KEY_LEN = 64;
function hashPassword(plain) {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(plain, salt, KEY_LEN);
return `${salt.toString('hex')}:${hash.toString('hex')}`;
}
function verifyPassword(plain, stored) {
if (!plain || !stored) return false;
const [saltHex, hashHex] = stored.split(':');
if (!saltHex || !hashHex) return false;
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(hashHex, 'hex');
const actual = crypto.scryptSync(plain, salt, expected.length || KEY_LEN);
if (actual.length !== expected.length) return false;
return crypto.timingSafeEqual(actual, expected);
}
module.exports = { hashPassword, verifyPassword };
+21
View File
@@ -0,0 +1,21 @@
// Cookie construction, centralized so the Secure attribute is set consistently everywhere
// instead of hand-built per call site. Secure is opt-in via COOKIE_SECURE=1 — off by default
// because local dev and the current single-VM pilot deployment run over plain HTTP; anything
// served over real HTTPS should set COOKIE_SECURE=1 (see docs/PHASE3A... "production HTTPS
// expectations").
const SECURE = process.env.COOKIE_SECURE === '1';
function buildCookie(name, value, { maxAgeSec, httpOnly = true } = {}) {
const parts = [`${name}=${value}`, 'Path=/', 'SameSite=Lax'];
if (httpOnly) parts.push('HttpOnly');
if (SECURE) parts.push('Secure');
parts.push(`Max-Age=${maxAgeSec != null ? maxAgeSec : 0}`);
return parts.join('; ');
}
function clearCookie(name) {
return buildCookie(name, '', { maxAgeSec: 0 });
}
module.exports = { buildCookie, clearCookie };
+102
View File
@@ -0,0 +1,102 @@
// POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session,
// POST /api/auth/change-password — replaces Phase 2B's single shared ADMIN_PASSWORD
// (/api/admin/login) with real per-instructor accounts. See
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
const { verifyPassword, hashPassword } = require('./auth/password');
const { getInstructorByEmail, publicInstructor } = require('./instructors');
const { recordAudit } = require('./audit');
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
const { buildCookie, clearCookie } = require('./cookies');
const INSTRUCTOR_COOKIE = 'rn_instructor';
const CSRF_COOKIE = 'rn_csrf_i';
const LOGIN_MAX_ATTEMPTS = 8;
const LOGIN_WINDOW_MS = 5 * 60 * 1000; // 5 min
module.exports = function registerInstructorAuthRoutes(app, { pool, sessions, instructorSession }) {
app.post('/api/auth/instructor/login', async (req, res) => {
const { email, password } = req.body || {};
const rateKey = `${req.ip}:${String(email || '').toLowerCase()}`;
if (tooManyAttempts(rateKey, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS)) {
return res.status(429).json({ error: 'too_many_attempts', message: 'Too many login attempts. Try again in a few minutes.' });
}
if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
const instructor = await getInstructorByEmail(pool, email);
// Same generic response whether the email doesn't exist or the password is wrong — no
// account enumeration. Only once the password is ALSO correct do we distinguish
// "deactivated," which doesn't help an attacker who doesn't already know the password.
const passwordOk = instructor && verifyPassword(password, instructor.password_hash);
if (!passwordOk) {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor?.instructor_id, action: 'login',
result: 'failure', sourceIp: req.ip, metadata: { email: String(email).toLowerCase() },
});
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password.' });
}
if (instructor.status !== 'active') {
await recordAudit(pool, {
actorType: 'instructor', actorId: instructor.instructor_id, action: 'login',
result: 'failure', sourceIp: req.ip, metadata: { reason: 'inactive' },
});
return res.status(403).json({ error: 'account_inactive', message: 'This account has been deactivated.' });
}
const token = await sessions.createInstructorSession(pool, instructor.instructor_id);
const csrf = makeCsrfToken();
await pool.query(`UPDATE instructors SET last_login_at = now() WHERE instructor_id = $1`, [instructor.instructor_id]);
await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'login', result: 'success', sourceIp: req.ip });
res.append('Set-Cookie', buildCookie(INSTRUCTOR_COOKIE, token, { maxAgeSec: 12 * 3600 }));
res.append('Set-Cookie', buildCookie(CSRF_COOKIE, csrf, { maxAgeSec: 12 * 3600, httpOnly: false }));
res.json({ instructor: publicInstructor({ ...instructor, last_login_at: new Date() }) });
});
app.post('/api/auth/logout', async (req, res) => {
const token = parseCookies(req)[INSTRUCTOR_COOKIE];
// CSRF is still checked here even though logout is low-severity to forge (the worst case is
// an annoying forced sign-out, not data exposure) — consistent enforcement is simpler to
// reason about than a documented exception per route.
if (!checkCsrf(req, CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
// Resolve directly rather than requiring the strict instructorSession middleware — logout
// must still succeed (and clear cookies) even against an already-expired/invalid session.
const instructorId = await sessions.resolveInstructorSession(pool, token);
if (instructorId) {
await recordAudit(pool, { actorType: 'instructor', actorId: instructorId, action: 'logout', result: 'success', sourceIp: req.ip });
}
await sessions.destroyInstructorSession(pool, token);
res.append('Set-Cookie', clearCookie(INSTRUCTOR_COOKIE));
res.append('Set-Cookie', clearCookie(CSRF_COOKIE));
res.json({ ok: true });
});
// GET /api/auth/session — "whoami" for the instructor portal.
app.get('/api/auth/session', instructorSession, (req, res) => {
res.json({ instructor: publicInstructor(req.instructor) });
});
app.post('/api/auth/change-password', instructorSession, async (req, res) => {
// CSRF already checked by instructorSession (server.js) for every non-GET method.
const { old_password, new_password } = req.body || {};
if (!old_password || !new_password || new_password.length < 12) {
return res.status(400).json({ error: 'new_password must be at least 12 characters' });
}
const instructor = req.instructor; // full row (studentSession-style middleware already fetched it)
if (!verifyPassword(old_password, instructor.password_hash)) {
await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'password_change', result: 'failure', sourceIp: req.ip });
return res.status(401).json({ error: 'invalid_credentials', message: 'Current password is incorrect.' });
}
const newHash = hashPassword(new_password);
await pool.query(`UPDATE instructors SET password_hash = $1, updated_at = now() WHERE instructor_id = $2`, [newHash, instructor.instructor_id]);
// Kill any other lingering sessions for this account — a password change should invalidate
// access anywhere else that was logged in, but not the session that just made the change.
const currentToken = parseCookies(req)[INSTRUCTOR_COOKIE];
await sessions.revokeAllInstructorSessions(pool, instructor.instructor_id, currentToken);
await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'password_change', result: 'success', sourceIp: req.ip });
res.json({ ok: true });
});
};
module.exports.INSTRUCTOR_COOKIE = INSTRUCTOR_COOKIE;
module.exports.CSRF_COOKIE = CSRF_COOKIE;
+77
View File
@@ -0,0 +1,77 @@
// Instructor account CRUD + the small set of invariants that protect the platform from
// locking itself out (never zero active owners). Shared by the auth routes (login/session) and
// the admin instructor-management routes.
const { hashPassword } = require('./auth/password');
function publicInstructor(row) {
if (!row) return null;
const { password_hash, ...rest } = row;
return rest;
}
async function getActiveInstructor(pool, instructorId) {
const { rows } = await pool.query(
`SELECT * FROM instructors WHERE instructor_id = $1 AND status = 'active'`, [instructorId]
);
return rows[0] || null;
}
// Returns the row regardless of status — the login flow needs to tell "wrong password" apart
// from "correct password, but account is deactivated" (see instructor-auth-routes.js), which
// requires seeing inactive accounts too.
async function getInstructorByEmail(pool, email) {
const { rows } = await pool.query(`SELECT * FROM instructors WHERE email = $1`, [String(email).toLowerCase().trim()]);
return rows[0] || null;
}
async function listInstructors(pool) {
const { rows } = await pool.query(`SELECT * FROM instructors ORDER BY created_at`);
return rows.map(publicInstructor);
}
async function countActiveOwners(pool, excludingId) {
const { rows } = await pool.query(
`SELECT count(*)::int AS n FROM instructors WHERE role = 'owner' AND status = 'active' AND instructor_id != $1`,
[excludingId || '00000000-0000-0000-0000-000000000000']
);
return rows[0].n;
}
async function createInstructor(pool, { email, displayName, role, password }) {
const emailLower = String(email).toLowerCase().trim();
const passwordHash = hashPassword(password);
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`INSERT INTO instructors (email, display_name, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING *`,
[emailLower, displayName.trim(), passwordHash, role || 'instructor']
);
const instructor = rows[0];
await client.query(
`INSERT INTO auth_identities (instructor_id, provider, provider_subject, provider_email) VALUES ($1, 'local', $2, $3)`,
[instructor.instructor_id, String(instructor.instructor_id), emailLower]
);
await client.query('COMMIT');
return instructor;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
// Guards the one invariant that matters: never let the platform end up with zero active
// owners. Called before any update that would deactivate an owner or demote their role.
async function assertNotLastOwner(pool, instructorId) {
const remaining = await countActiveOwners(pool, instructorId);
if (remaining === 0) {
const err = new Error('Cannot remove the last active owner.');
err.code = 'LAST_OWNER';
throw err;
}
}
module.exports = { publicInstructor, getActiveInstructor, getInstructorByEmail, listInstructors, countActiveOwners, createInstructor, assertNotLastOwner };
+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 };
};
+57
View File
@@ -0,0 +1,57 @@
// CSRF (double-submit cookie) and login rate limiting. No new dependency — both are small
// enough to hand-roll, and SameSite=Lax cookies already do most of the CSRF work; this is
// defense in depth on top of that, not a replacement for it.
//
// CSRF: on login, a non-HttpOnly token is set as a cookie (rn_csrf_s for students, rn_csrf_i
// for instructors) — non-HttpOnly *on purpose*, so our own frontend JS can read it via
// document.cookie and echo it back as a header. A cross-origin attacker's page can trigger a
// cookie-carrying request but cannot read the cookie's value (browser same-origin policy) or
// add a custom header to it without CORS, which we don't grant to other origins — so it can
// never produce a request where the header matches the cookie.
const crypto = require('crypto');
function makeCsrfToken() {
return crypto.randomBytes(24).toString('base64url');
}
function parseCookies(req) {
const header = req.headers.cookie;
if (!header) return {};
return Object.fromEntries(
header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)])
);
}
// Verifies X-CSRF-Token header against the given cookie for any state-changing method.
// GET/HEAD/OPTIONS are read-only and exempt (nothing to protect).
function checkCsrf(req, cookieName) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return true;
const cookieValue = parseCookies(req)[cookieName];
const header = req.headers['x-csrf-token'];
return !!cookieValue && !!header && cookieValue === header;
}
// ── Login rate limiting ─────────────────────────────────────────────────────────────────
// In-memory sliding window, keyed by "ip:identifier" (e.g. ip:email). Fine for a single-process
// Platform API — a shared store (Postgres/Redis) would be needed for multi-instance, not a
// concern at this scale (see docs/PHASE3A... known limitations).
const attempts = new Map();
function tooManyAttempts(key, max, windowMs) {
const now = Date.now();
const arr = (attempts.get(key) || []).filter((t) => now - t < windowMs);
arr.push(now);
attempts.set(key, arr);
return arr.length > max;
}
setInterval(() => {
const cutoff = Date.now() - 60 * 60 * 1000;
for (const [key, arr] of attempts) {
const kept = arr.filter((t) => t > cutoff);
if (kept.length) attempts.set(key, kept); else attempts.delete(key);
}
}, 15 * 60 * 1000).unref();
module.exports = { makeCsrfToken, parseCookies, checkCsrf, tooManyAttempts };
+89 -50
View File
@@ -1,60 +1,77 @@
// RootNode Academy — Platform API (Phase 2B).
// RootNode Academy — Platform API (Phase 3A revision).
//
// Lightweight service: no k3s, no --privileged, no Lab terminal runtime. Owns students,
// cohorts, enrollments, curriculum assignments, progress, scenario attempts, student/admin
// session identity, and all Provisioner (environment orchestration) calls — both the
// student-facing launch/reset path and the instructor/admin control plane. See
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md.
// cohorts, enrollments, curriculum assignments, progress, scenario attempts, instructor
// accounts, persistent student/instructor sessions, RBAC, audit logging, and all Provisioner
// (environment orchestration) calls. See docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
//
// Reached only through the Gateway (platform/gateway) — never directly by browser JS, the
// same trust boundary the Provisioner already has relative to this service.
// Reached only through the Gateway (platform/gateway) — never directly by browser JS.
const express = require('express');
const path = require('path');
const { getPool, runMigrations } = require('./db');
const sessions = require('./sessions');
const { verifyAdminPassword } = require('./admin-auth');
const { getActiveInstructor } = require('./instructors');
const { provisioner } = require('./provisioner-client');
const curriculum = require('./curriculum');
const { loadProgress, saveOneProgress, touchStudent } = require('./progress');
const { recordAudit } = require('./audit');
const { parseCookies, checkCsrf, makeCsrfToken } = require('./security');
const { buildCookie, clearCookie } = require('./cookies');
const createRbac = require('./rbac');
const { INSTRUCTOR_COOKIE, CSRF_COOKIE: INSTRUCTOR_CSRF_COOKIE } = require('./instructor-auth-routes');
const PORT = Number(process.env.PLATFORM_API_PORT) || 4300;
const STUDENT_COOKIE = 'rn_session';
const ADMIN_COOKIE = 'rn_admin';
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
// Deliberate, not automatic: honoring X-Forwarded-For for req.ip (audit source_ip, login rate
// limiting) is only correct when there's a trusted reverse proxy in front of this process. Off
// by default (direct connections / dev); set TRUST_PROXY=1 once fronted by one in production.
const app = express();
if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1);
app.use(express.json());
const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist');
app.use(express.static(FRONTEND_DIST));
function parseCookies(req) {
const header = req.headers.cookie;
if (!header) return {};
return Object.fromEntries(
header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)])
);
}
// ── Student session middleware ──────────────────────────────────────────────────────────
// This is the actual fix for the Phase 2A pilot ?sid= mechanism: student_id is derived ONLY
// from an opaque, server-issued session cookie the browser cannot fabricate — never from a
// header or query parameter a client controls. See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
function studentSession(req, res, next) {
// student_id is derived ONLY from an opaque, server-issued session token (now Postgres-backed,
// not the in-memory Map from Phase 2B) — never from a header or query parameter a client
// controls. CSRF is checked here too, for every state-changing student request.
async function studentSession(req, res, next) {
const token = parseCookies(req)[STUDENT_COOKIE];
const studentId = sessions.resolveStudentSession(token);
const studentId = await sessions.resolveStudentSession(getPool(), token);
if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
req.studentId = studentId;
req.sessionToken = token;
next();
}
function adminSession(req, res, next) {
const token = parseCookies(req)[ADMIN_COOKIE];
if (!sessions.resolveAdminSession(token)) {
return res.status(401).json({ error: 'no_admin_session', message: 'Instructor login required.' });
// ── Instructor session middleware ───────────────────────────────────────────────────────
// Re-fetches the instructor row from Postgres on every request rather than trusting anything
// cached in the session — a role change or deactivation takes effect on the very next request,
// with no separate "revoke sessions on role change" mechanism needed.
async function instructorSession(req, res, next) {
const token = parseCookies(req)[INSTRUCTOR_COOKIE];
const instructorId = await sessions.resolveInstructorSession(getPool(), token);
if (!instructorId) return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' });
const instructor = await getActiveInstructor(getPool(), instructorId);
if (!instructor) {
await sessions.destroyInstructorSession(getPool(), token); // deactivated since the session was issued
return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' });
}
if (!checkCsrf(req, INSTRUCTOR_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
req.instructor = instructor;
req.sessionToken = token;
next();
}
function wrap(fn) {
return (req, res, next) => fn(req, res, next).catch(next);
}
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: roster / login / session
// ═══════════════════════════════════════════════════════════════════════════════════════
@@ -81,27 +98,38 @@ app.get('/api/roster', async (req, res) => {
res.json(rows);
});
// Roster name-picking is CLASSROOM/PILOT IDENTIFICATION, not production authentication — there
// is no password or possession factor here, only "does this student_id exist." What Phase 3A
// hardens is everything AFTER that pick: the resulting session is server-controlled, persistent,
// can't be forged/switched by editing client state, and is checked on every Lab request. See
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5 for the future path (one-time codes,
// institutional SSO) that upgrades identification without touching this session system.
app.post('/api/login', async (req, res) => {
const { student_id } = req.body || {};
const { rows } = await getPool().query(`SELECT student_id, display_name, status FROM students WHERE student_id = $1`, [student_id]);
const student = rows[0];
if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' });
await touchStudent(getPool(), student.student_id);
const token = sessions.createStudentSession(student.student_id);
res.append('Set-Cookie', `${STUDENT_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`);
const token = await sessions.createStudentSession(getPool(), student.student_id);
const csrf = makeCsrfToken();
res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, token, { maxAgeSec: 8 * 3600 }));
res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: 8 * 3600, httpOnly: false }));
res.json({ student_id: student.student_id, display_name: student.display_name });
});
app.post('/api/logout', (req, res) => {
app.post('/api/logout', async (req, res) => {
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
const token = parseCookies(req)[STUDENT_COOKIE];
sessions.destroyStudentSession(token);
res.append('Set-Cookie', `${STUDENT_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
await sessions.destroyStudentSession(getPool(), token);
res.append('Set-Cookie', clearCookie(STUDENT_COOKIE));
res.append('Set-Cookie', clearCookie(STUDENT_CSRF_COOKIE));
res.json({ ok: true });
});
// GET /api/session/whoami — used by the Gateway to resolve "who is this browser" for Lab
// ownership checks (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3). Also usable by the frontend.
app.get('/api/session/whoami', studentSession, (req, res) => {
// ownership checks. Read-only, so it isn't behind the CSRF check (studentSession only enforces
// CSRF on non-GET methods — see checkCsrf).
app.get('/api/session/whoami', wrap(studentSession), (req, res) => {
res.json({ student_id: req.studentId });
});
@@ -109,7 +137,7 @@ app.get('/api/session/whoami', studentSession, (req, res) => {
// STUDENT-FACING: curriculum / progress
// ═══════════════════════════════════════════════════════════════════════════════════════
app.get('/api/curriculum/path', studentSession, async (req, res) => {
app.get('/api/curriculum/path', wrap(studentSession), async (req, res) => {
const path = curriculum.loadCurriculumPath();
if (!path) return res.status(404).json({ error: 'Curriculum path not found' });
const scenarios = curriculum.loadScenarios();
@@ -130,7 +158,7 @@ app.get('/api/curriculum/path', studentSession, async (req, res) => {
res.json({ id: path.id, name: path.name, tagline: path.tagline, stages, next_stage: firstIncompleteStage });
});
app.get('/api/progress', studentSession, async (req, res) => {
app.get('/api/progress', wrap(studentSession), async (req, res) => {
const scenarios = curriculum.loadScenarios();
const progress = await loadProgress(getPool(), req.studentId);
const total = scenarios.length;
@@ -138,12 +166,7 @@ app.get('/api/progress', studentSession, async (req, res) => {
res.json({ total, completed, details: progress });
});
// POST /api/progress/record — Phase 2A's progress relay, now Postgres-backed and also logging
// a scenario_attempts row (new in Phase 2B — real per-attempt history, not just aggregate
// status). Called by the Gateway after a real validate/answer succeeded against the student's
// own Lab — never re-runs validation itself. See relayScenarioOutcome in
// platform/gateway/src/server.js.
app.post('/api/progress/record', studentSession, async (req, res) => {
app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
const { scenario_id, status, environment_id, validation_count } = req.body || {};
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
@@ -166,8 +189,7 @@ app.post('/api/progress/record', studentSession, async (req, res) => {
});
// ═══════════════════════════════════════════════════════════════════════════════════════
// STUDENT-FACING: environment lifecycle (moved here from the Gateway in Phase 2A — Phase 2B
// consolidates ALL Provisioner access into this one service)
// STUDENT-FACING: environment lifecycle
// ═══════════════════════════════════════════════════════════════════════════════════════
async function publicEnvView(env) {
@@ -185,14 +207,14 @@ async function publicEnvView(env) {
return view;
}
app.get('/api/environment/status', studentSession, async (req, res) => {
app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
const { status, body } = await provisioner.listForStudent(req.studentId);
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
const active = body.find((e) => !['destroyed', 'error'].includes(e.status)) || body.find((e) => e.status === 'error');
res.json(await publicEnvView(active));
});
app.post('/api/environment/launch', studentSession, async (req, res) => {
app.post('/api/environment/launch', wrap(studentSession), async (req, res) => {
const existing = await provisioner.listForStudent(req.studentId);
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
if (active) return res.json(await publicEnvView(active));
@@ -206,7 +228,7 @@ app.post('/api/environment/launch', studentSession, async (req, res) => {
res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }));
});
app.post('/api/environment/reset', studentSession, async (req, res) => {
app.post('/api/environment/reset', wrap(studentSession), async (req, res) => {
const existing = await provisioner.listForStudent(req.studentId);
const env = existing.body?.find((e) => e.status !== 'destroyed');
if (!env) return res.status(404).json({ error: 'no_environment' });
@@ -215,24 +237,41 @@ app.post('/api/environment/reset', studentSession, async (req, res) => {
res.json(await publicEnvView(body));
});
// Internal — the Gateway calls this to resolve a routing token to its owning environment
// (student_id, internal_ip, status) for Lab proxy ownership checks. Same trust boundary as
// the Provisioner's own by-token endpoint (internal callers only, never the browser).
// Internal — the Gateway calls this to resolve a routing token to its owning environment.
app.get('/api/environment/by-token/:token', async (req, res) => {
const { status, body } = await provisioner.getEnvironmentByToken(req.params.token);
res.status(status).json(body);
});
require('./admin-routes')(app, { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress, saveOneProgress });
// ═══════════════════════════════════════════════════════════════════════════════════════
// INSTRUCTOR-FACING: auth + admin API
// ═══════════════════════════════════════════════════════════════════════════════════════
require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) });
require('./admin-routes')(app, {
instructorSession: wrap(instructorSession), rbac: createRbac(getPool()), provisioner, curriculum,
getPool, loadProgress, saveOneProgress, recordAudit,
});
// Fallback to the platform-shell SPA for any unmatched non-API route
app.get('*', (req, res) => {
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
});
// Last-resort error handler — an unhandled DB error (or anything else) in any async route
// reaches here via next(err) instead of crashing the process or leaking a stack trace to the
// client. Every new async route/middleware added in Phase 3A funnels through this.
app.use((err, req, res, next) => {
console.error('[unhandled route error]', err);
if (res.headersSent) return next(err);
res.status(500).json({ error: 'internal_error' });
});
async function main() {
const applied = await runMigrations();
if (applied.length) console.log('Applied migrations:', applied.join(', '));
sessions.startSessionSweeper(getPool());
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
}
+104 -52
View File
@@ -1,67 +1,119 @@
// Server-controlled session store — replaces Phase 2A's pilot ?sid= mechanism (client-supplied,
// trivially forgeable) with opaque, server-issued tokens the browser can't fabricate or guess.
// In-memory, single-process: fine for a pilot admin/classroom tool (see
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Known limitations" — a Platform API restart logs
// everyone out, which is an acceptable tradeoff at this scale, not something silently ignored).
// Persistent session store (Phase 3A) — replaces Phase 2B's in-memory studentSessions/
// adminSessions Maps. Sessions live in Postgres (the `sessions` table), so a Platform API
// restart no longer logs everyone out, and individual sessions can be revoked/audited.
//
// Two separate, non-overlapping session kinds (student / admin) so a student session can never
// be mistaken for an admin one regardless of any bug elsewhere — they use different cookie
// names AND different token namespaces.
// The browser only ever holds an opaque, unguessable bearer token (32 random bytes,
// base64url). The server stores only a SHA-256 hash of it (token_hash) — a database dump
// can't be replayed as working session cookies, same principle as password hashing.
//
// Two independent lifecycles, matching Phase 2B's separation: student sessions and instructor
// sessions never share a token namespace or a cookie name, so a bug can't confuse one for the
// other. Each has an absolute expiry (expires_at, fixed at creation) and a sliding idle expiry
// (idle_expires_at, pushed forward on every authenticated use, never past expires_at) —
// whichever is hit first ends the session.
const crypto = require('crypto');
const STUDENT_TTL_MS = 8 * 60 * 60 * 1000; // 8h — a work/class session
const ADMIN_TTL_MS = 4 * 60 * 60 * 1000; // 4h — shorter, this is a privileged session
const studentSessions = new Map(); // token -> { student_id, expiresAt }
const adminSessions = new Map(); // token -> { expiresAt }
const STUDENT_TTL_MS = Number(process.env.STUDENT_SESSION_TTL_HOURS || 8) * 60 * 60 * 1000;
const STUDENT_IDLE_TTL_MS = Number(process.env.STUDENT_SESSION_IDLE_HOURS || 2) * 60 * 60 * 1000;
const INSTRUCTOR_TTL_MS = Number(process.env.INSTRUCTOR_SESSION_TTL_HOURS || 12) * 60 * 60 * 1000;
const INSTRUCTOR_IDLE_TTL_MS = Number(process.env.INSTRUCTOR_SESSION_IDLE_HOURS || 2) * 60 * 60 * 1000;
function makeToken() {
return crypto.randomBytes(32).toString('base64url');
}
function createStudentSession(studentId) {
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
async function createSession(pool, { type, studentId, instructorId, ttlMs, idleTtlMs, metadata }) {
const token = makeToken();
studentSessions.set(token, { student_id: studentId, expiresAt: Date.now() + STUDENT_TTL_MS });
return token;
}
function resolveStudentSession(token) {
if (!token) return null;
const s = studentSessions.get(token);
if (!s || s.expiresAt < Date.now()) { studentSessions.delete(token); return null; }
return s.student_id;
}
function destroyStudentSession(token) {
studentSessions.delete(token);
}
function createAdminSession() {
const token = makeToken();
adminSessions.set(token, { expiresAt: Date.now() + ADMIN_TTL_MS });
return token;
}
function resolveAdminSession(token) {
if (!token) return false;
const s = adminSessions.get(token);
if (!s || s.expiresAt < Date.now()) { adminSessions.delete(token); return false; }
return true;
}
function destroyAdminSession(token) {
adminSessions.delete(token);
}
// Periodic sweep so long-idle sessions don't accumulate forever in memory.
setInterval(() => {
const now = Date.now();
for (const [token, s] of studentSessions) if (s.expiresAt < now) studentSessions.delete(token);
for (const [token, s] of adminSessions) if (s.expiresAt < now) adminSessions.delete(token);
}, 10 * 60 * 1000).unref();
const expiresAt = new Date(now + ttlMs);
const idleExpiresAt = new Date(Math.min(now + idleTtlMs, expiresAt.getTime()));
await pool.query(
`INSERT INTO sessions (session_type, student_id, instructor_id, token_hash, expires_at, idle_expires_at, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[type, studentId || null, instructorId || null, hashToken(token), expiresAt, idleExpiresAt, metadata ? JSON.stringify(metadata) : null]
);
return token;
}
// Validates + slides the idle window forward in one round trip. Returns null on any failure
// (missing, expired, idle-timed-out, or revoked) without distinguishing why — the caller
// doesn't need to know, and not distinguishing avoids leaking session state to a client.
async function resolveSession(pool, token, type, idleTtlMs) {
if (!token) return null;
const { rows } = await pool.query(
`UPDATE sessions
SET last_seen_at = now(), idle_expires_at = LEAST(now() + $3::interval, expires_at)
WHERE token_hash = $1 AND session_type = $2 AND revoked_at IS NULL
AND expires_at > now() AND idle_expires_at > now()
RETURNING student_id, instructor_id`,
[hashToken(token), type, `${idleTtlMs} milliseconds`]
);
return rows[0] || null;
}
async function revokeSession(pool, token) {
if (!token) return;
await pool.query(`UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, [hashToken(token)]);
}
// ── Student sessions ─────────────────────────────────────────────────────────────────────
function createStudentSession(pool, studentId) {
return createSession(pool, { type: 'student', studentId, ttlMs: STUDENT_TTL_MS, idleTtlMs: STUDENT_IDLE_TTL_MS });
}
async function resolveStudentSession(pool, token) {
const row = await resolveSession(pool, token, 'student', STUDENT_IDLE_TTL_MS);
return row ? row.student_id : null;
}
const destroyStudentSession = revokeSession;
// ── Instructor sessions ──────────────────────────────────────────────────────────────────
function createInstructorSession(pool, instructorId) {
return createSession(pool, { type: 'instructor', instructorId, ttlMs: INSTRUCTOR_TTL_MS, idleTtlMs: INSTRUCTOR_IDLE_TTL_MS });
}
async function resolveInstructorSession(pool, token) {
const row = await resolveSession(pool, token, 'instructor', INSTRUCTOR_IDLE_TTL_MS);
return row ? row.instructor_id : null;
}
const destroyInstructorSession = revokeSession;
// Revokes every live session for an instructor — used for the owner-facing "revoke all
// sessions" control, and automatically after a password change (except the session that just
// changed the password, so the user isn't immediately logged out of their own action).
async function revokeAllInstructorSessions(pool, instructorId, exceptToken) {
const exceptHash = exceptToken ? hashToken(exceptToken) : null;
await pool.query(
`UPDATE sessions SET revoked_at = now()
WHERE instructor_id = $1 AND revoked_at IS NULL AND ($2::text IS NULL OR token_hash != $2)`,
[instructorId, exceptHash]
);
}
async function listInstructorSessions(pool, instructorId) {
const { rows } = await pool.query(
`SELECT session_id, created_at, last_seen_at, expires_at, revoked_at
FROM sessions WHERE instructor_id = $1 ORDER BY created_at DESC LIMIT 20`,
[instructorId]
);
return rows;
}
// Periodic cleanup of long-dead rows so the table doesn't grow forever — cosmetic/storage only,
// not a security control (expired/revoked rows already fail resolveSession's WHERE clause).
function startSessionSweeper(pool) {
setInterval(() => {
pool.query(`DELETE FROM sessions WHERE expires_at < now() - interval '7 days'`).catch((e) => {
console.error('[session sweep] failed:', e.message);
});
}, 60 * 60 * 1000).unref();
}
module.exports = {
createStudentSession, resolveStudentSession, destroyStudentSession,
createAdminSession, resolveAdminSession, destroyAdminSession,
createInstructorSession, resolveInstructorSession, destroyInstructorSession,
revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper,
};
+4 -1
View File
@@ -68,8 +68,11 @@ services:
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform
PROVISIONER_URL: "http://localhost:4100"
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set an instructor password, do not use a default}
PLATFORM_API_PORT: "4300"
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
# `npm run bootstrap-owner` for the first owner and the admin portal after that.
# COOKIE_SECURE: "1" # set once served over real HTTPS
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
depends_on:
postgres:
condition: service_healthy
+18 -4
View File
@@ -23,6 +23,7 @@ const envCache = require('./env-cache');
const PORT = Number(process.env.GATEWAY_PORT) || 4200;
const PLATFORM_API_URL = process.env.PLATFORM_API_URL || 'http://localhost:4300';
const STUDENT_COOKIE = 'rn_session';
const STUDENT_CSRF_COOKIE = 'rn_csrf_s'; // Phase 3A — see relayScenarioOutcome for why the Gateway needs this too
// Routing hint only — NOT a security check by itself (ownership is always re-verified via the
// session, see checkOwnership/resolveOwnedRunningEnvironment). Its only job is disambiguating
// "this unprefixed request came from inside the Lab UI" from "this came from the dashboard,"
@@ -30,7 +31,9 @@ const STUDENT_COOKIE = 'rn_session';
// dashboard too, and naively using it hijacked EVERY unmatched dashboard request (including
// GET /api/session/whoami) to the student's Lab container once one existed. Found by testing.
const LAB_ACTIVE_COOKIE = 'rn_lab_active';
const SESSION_CACHE_TTL_MS = 5000;
// Phase 3A: shortened from 5000ms so a revoked/expired session stops working here within a
// tighter, testable bound (see platform/test/e2e-phase3a.js "revoked mid-use" case).
const SESSION_CACHE_TTL_MS = 2000;
const app = express();
// Deliberately no express.json() globally — see the Phase 2A note this carries forward:
@@ -120,15 +123,26 @@ async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcom
const passed = extractOutcome(body);
if (passed !== null) {
const validationCount = Array.isArray(body.checks) ? body.checks.length : undefined;
const token = parseCookies(req)[STUDENT_COOKIE];
const cookies = parseCookies(req);
const token = cookies[STUDENT_COOKIE];
const csrf = cookies[STUDENT_CSRF_COOKIE] || '';
// Phase 3A: /api/progress/record is CSRF-protected like every other student mutation. The
// double-submit check needs BOTH the rn_csrf_s cookie AND a matching X-CSRF-Token header on
// the request Platform API actually sees — so both are forwarded here, not just the header
// (an earlier version of this fix sent only the header, which still 403'd).
fetch(`${PLATFORM_API_URL}/api/progress/record`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Cookie: `${STUDENT_COOKIE}=${token}` },
headers: {
'Content-Type': 'application/json',
Cookie: `${STUDENT_COOKIE}=${token}; ${STUDENT_CSRF_COOKIE}=${csrf}`,
'X-CSRF-Token': csrf,
},
body: JSON.stringify({
scenario_id: req.params.scenarioId, status: passed ? 'completed' : 'in_progress',
environment_id: env.environment_id, validation_count: validationCount,
}),
}).catch((e) => console.error('[progress relay] failed:', e.message));
}).then((r) => { if (!r.ok) console.error('[progress relay] Platform API rejected the update:', r.status); })
.catch((e) => console.error('[progress relay] failed:', e.message));
}
}
+20 -31
View File
@@ -1,38 +1,26 @@
#!/usr/bin/env node
// Phase 2A student-flow regression, updated for Phase 2B's session-cookie architecture.
// Originally used X-Student-Id headers and a ?sid= query param — both mechanisms are gone as
// of Phase 2B (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3); this version exercises the exact
// same flow through the real rn_session cookie instead, since that's how it actually works now.
// Phase 2A student-flow regression, updated for Phase 3A's persistent session + per-instructor
// auth model. Originally used X-Student-Id headers and a ?sid= query param — both mechanisms
// are long gone; this version exercises the same flow through the real rn_session cookie, with
// the admin roster-add step now going through a real instructor account instead of a shared
// ADMIN_PASSWORD.
//
// Runs against a live platform/docker-compose.dev.yml stack. Slow (real k3s boots). No
// framework, matching project convention.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d && node test/e2e-phase2a.js
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase2a.js
const assert = require('assert');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const COHORT = `e2e-test-${Date.now()}`;
// Minimal manual cookie jar — Node's fetch doesn't persist cookies across calls the way a
// browser does, so each "session" here explicitly carries its own Set-Cookie forward.
function makeSession() {
let cookie = '';
return {
async request(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
if (cookie) headers.Cookie = cookie;
const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const setCookie = res.headers.get('set-cookie');
if (setCookie) cookie = setCookie.split(';')[0];
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, body: json };
},
rawFetch(path) {
return fetch(`${GW}${path}`, { headers: cookie ? { Cookie: cookie } : {} });
},
};
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
let passed = 0, failed = 0;
@@ -42,10 +30,11 @@ async function test(name, fn) {
}
async function main() {
console.log('Phase 2A student-flow regression (Phase 2B session cookies) — slow, real k3s boots\n');
console.log('Phase 2A student-flow regression (Phase 3A sessions/auth) — slow, real k3s boots\n');
const admin = makeSession();
await admin.request('POST', '/api/admin/login', { password: process.env.ADMIN_PASSWORD });
const admin = makeSession(GW);
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.equal(loginStatus, 200, 'owner login must succeed — did you bootstrap one?');
let alice, bob;
await test('roster: add two students via admin', async () => {
@@ -55,8 +44,8 @@ async function main() {
assert.notEqual(alice, bob);
});
const aliceSession = makeSession();
const bobSession = makeSession();
const aliceSession = makeSession(GW);
const bobSession = makeSession(GW);
await test('login: student session cookie is issued and required for curriculum', async () => {
const { status } = await aliceSession.request('POST', '/api/login', { student_id: alice });
+22 -35
View File
@@ -1,38 +1,28 @@
#!/usr/bin/env node
// Phase 2B classroom workflow — the actual acceptance test from
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md: instructor creates a cohort, adds three students,
// assigns curriculum, prepares Labs for the whole cohort, two students complete different
// scenarios, instructor resets one student's Lab, instructor destroys all cohort Labs after
// class — progress survives every step.
// Phase 2B classroom workflow — the acceptance test from
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md, updated for Phase 3A's per-instructor accounts:
// instructor creates a cohort, adds three students, assigns curriculum, prepares Labs for the
// whole cohort, two students complete different scenarios, instructor resets one student's
// Lab, instructor destroys all cohort Labs after class — progress survives every step.
//
// Runs against a live platform/docker-compose.dev.yml stack. Slow (three real k3s boots,
// concurrently). No framework, matching project convention.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// ADMIN_PASSWORD=<value from platform/.env> node test/e2e-phase2b.js
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase2b.js
const assert = require('assert');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
if (!ADMIN_PASSWORD) { console.error('Set ADMIN_PASSWORD (see platform/.env)'); process.exit(1); }
const COHORT_ID = `e2e-cohort-${Date.now()}`;
function makeSession() {
let cookie = '';
return {
async request(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
if (cookie) headers.Cookie = cookie;
const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
const setCookie = res.headers.get('set-cookie');
if (setCookie) cookie = setCookie.split(';')[0];
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, body: json };
},
};
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const COHORT_ID = `e2e-cohort-${Date.now()}`;
let passed = 0, failed = 0;
async function test(name, fn) {
@@ -41,15 +31,15 @@ async function test(name, fn) {
}
async function main() {
console.log('Phase 2B classroom workflow acceptance test — slow, real k3s boots\n');
console.log('Phase 2B classroom workflow acceptance test (Phase 3A auth) — slow, real k3s boots\n');
const admin = makeSession();
const other = makeSession();
const admin = makeSession(GW);
const other = makeSession(GW);
await test('admin auth: wrong password rejected, correct password issues a session', async () => {
const bad = await admin.request('POST', '/api/admin/login', { password: 'definitely-wrong' });
await test('instructor auth: wrong password rejected, correct password issues a session', async () => {
const bad = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: 'definitely-wrong' });
assert.equal(bad.status, 401);
const good = await admin.request('POST', '/api/admin/login', { password: ADMIN_PASSWORD });
const good = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.equal(good.status, 200);
});
@@ -89,8 +79,8 @@ async function main() {
});
const [aliceId, bobId] = students;
const alice = makeSession();
const bob = makeSession();
const alice = makeSession(GW);
const bob = makeSession(GW);
await test('students: Alice and Bob log in and launch their ALREADY-PREPARED labs instantly', async () => {
await alice.request('POST', '/api/login', { student_id: aliceId });
@@ -140,9 +130,6 @@ async function main() {
});
await test('regression: Phase 1 boundary still holds — no Lab container publishes a host port', async () => {
// Sanity check via the admin environment list — this doesn't re-run the Provisioner's own
// iptables tests (see platform/provisioner/test/run.js for those), just confirms nothing
// in the Phase 2B layer introduced a published port.
const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/environment`);
assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80));
});
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env node
// Phase 3A — identity and instructor access. Covers session persistence (including a real
// Platform API restart), per-instructor auth (no shared ADMIN_PASSWORD), RBAC across
// owner/admin/instructor, cohort-scoped instructor access, CSRF enforcement, login rate
// limiting, audit logging, and a repeat of the Phase 2B classroom workflow with a real
// instructor account instead of a shared password.
//
// Runs against a live platform/docker-compose.dev.yml stack, and shells out to `docker compose
// restart api` for the restart-persistence test — so it must run from a checkout where that
// compose file's `api` service is actually running (see Usage). No framework, matching project
// convention.
//
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase3a.js
const assert = require('assert');
const { execSync } = require('child_process');
const { makeSession } = require('./session-helper');
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300'; // direct — a few checks (restart) need to bypass the Gateway's proxy caching
const OWNER_EMAIL = process.env.OWNER_EMAIL;
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
process.exit(1);
}
const RUN_ID = Date.now();
let passed = 0, failed = 0;
async function test(name, fn) {
try { await fn(); console.log(` ok — ${name}`); passed++; }
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
}
async function main() {
console.log('Phase 3A identity/RBAC acceptance test — slow, real k3s boots + a real API restart\n');
const owner = makeSession(GW);
await test('instructor auth: no account enumeration (unknown email and wrong password look identical)', async () => {
const unknown = await owner.request('POST', '/api/auth/instructor/login', { email: `nobody-${RUN_ID}@rootnode.test`, password: 'whatever12345' });
const wrongPw = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: 'definitely-wrong-password' });
assert.equal(unknown.status, 401);
assert.equal(wrongPw.status, 401);
assert.deepEqual(unknown.body, wrongPw.body);
});
await test('instructor auth: valid login issues a session + CSRF cookie', async () => {
const { status } = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
assert.equal(status, 200);
assert.ok(owner.hasCookie('rn_instructor'));
assert.ok(owner.hasCookie('rn_csrf_i'));
});
await test('CSRF: mutating request without the header is rejected', async () => {
const res = await fetch(`${GW}/api/admin/cohorts`, {
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` },
body: JSON.stringify({ cohort_id: `csrf-should-fail-${RUN_ID}`, name: 'nope' }),
});
assert.equal(res.status, 403);
});
await test('CSRF: mutating request WITH the header succeeds', async () => {
const { status } = await owner.request('POST', '/api/admin/cohorts', { cohort_id: `csrf-ok-${RUN_ID}`, name: 'CSRF OK' });
assert.equal(status, 201);
});
await test('rate limiting: repeated bad logins for one identity get throttled', async () => {
const probe = makeSession(GW);
let sawThrottle = false;
for (let i = 0; i < 9; i++) {
const { status } = await probe.request('POST', '/api/auth/instructor/login', { email: `throttle-${RUN_ID}@rootnode.test`, password: 'wrong' });
if (status === 429) sawThrottle = true;
}
assert.ok(sawThrottle, 'expected a 429 within 9 rapid attempts');
});
let adminId, instructorId;
await test('owner: create an admin-role and an instructor-role account', async () => {
const a = await owner.request('POST', '/api/admin/instructors', { email: `admin-${RUN_ID}@rootnode.test`, display_name: 'E2E Admin', role: 'admin', password: 'AdminPassword123' });
assert.equal(a.status, 201);
adminId = a.body.instructor_id;
const i = await owner.request('POST', '/api/admin/instructors', { email: `teach-${RUN_ID}@rootnode.test`, display_name: 'E2E Teacher', role: 'instructor', password: 'TeacherPassword123' });
assert.equal(i.status, 201);
instructorId = i.body.instructor_id;
});
const teacher = makeSession(GW);
await test('RBAC: instructor role rejected from owner-only routes', async () => {
await teacher.request('POST', '/api/auth/instructor/login', { email: `teach-${RUN_ID}@rootnode.test`, password: 'TeacherPassword123' });
const { status } = await teacher.request('GET', '/api/admin/instructors');
assert.equal(status, 403);
});
await test('RBAC: instructor role rejected from admin/owner cohort-creation route', async () => {
const { status } = await teacher.request('POST', '/api/admin/cohorts', { cohort_id: `teacher-should-fail-${RUN_ID}`, name: 'nope' });
assert.equal(status, 403);
});
const admin = makeSession(GW);
await test('RBAC: admin role can create cohorts (below owner, above instructor)', async () => {
await admin.request('POST', '/api/auth/instructor/login', { email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' });
const { status } = await admin.request('POST', '/api/admin/cohorts', { cohort_id: `admin-ok-${RUN_ID}`, name: 'Admin OK' });
assert.equal(status, 201);
});
await test('RBAC: admin role rejected from owner-only instructor management', async () => {
const { status } = await admin.request('GET', '/api/admin/instructors');
assert.equal(status, 403);
});
const scopedCohort = `scoped-${RUN_ID}`;
const unscopedCohort = `unscoped-${RUN_ID}`;
await test('cohort scoping: owner creates two cohorts, assigns the instructor to only one', async () => {
assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: scopedCohort, name: 'Scoped' })).status, 201);
assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: unscopedCohort, name: 'Unscoped' })).status, 201);
assert.equal((await owner.request('POST', `/api/admin/cohorts/${scopedCohort}/instructors`, { instructor_id: instructorId })).status, 200);
});
await test('cohort scoping: instructor sees the assigned cohort, not the other', async () => {
const { body } = await teacher.request('GET', '/api/admin/cohorts');
const ids = body.map((c) => c.cohort_id);
assert.ok(ids.includes(scopedCohort));
assert.ok(!ids.includes(unscopedCohort));
});
await test('cohort scoping: instructor allowed on the assigned cohort, 403 on the other', async () => {
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${scopedCohort}/students`)).status, 200);
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${unscopedCohort}/students`)).status, 403);
});
await test('owner-safety: cannot deactivate the last active owner', async () => {
const { body: whoami } = await owner.request('GET', '/api/auth/session');
const { status, body } = await owner.request('PATCH', `/api/admin/instructors/${whoami.instructor.instructor_id}`, { status: 'inactive' });
assert.equal(status, 400);
assert.equal(body.error, 'last_owner');
});
await test('role/status changes take effect on the very next request, no separate revoke needed', async () => {
assert.equal((await teacher.request('GET', '/api/admin/dashboard')).status, 200); // works before deactivation
await owner.request('PATCH', `/api/admin/instructors/${instructorId}`, { status: 'inactive' });
const { status } = await teacher.request('GET', '/api/admin/dashboard');
assert.equal(status, 401);
});
await test('password change: old password rejected afterward, other sessions revoked', async () => {
const second = makeSession(GW);
await second.request('POST', '/api/auth/instructor/login', { email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' });
await admin.request('POST', '/api/auth/change-password', { old_password: 'AdminPassword123', new_password: 'NewAdminPassword456' });
const stillOld = await fetch(`${GW}/api/auth/instructor/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' }) });
assert.equal(stillOld.status, 401);
const newWorks = await fetch(`${GW}/api/auth/instructor/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: `admin-${RUN_ID}@rootnode.test`, password: 'NewAdminPassword456' }) });
assert.equal(newWorks.status, 200);
// The OTHER session (logged in before the change) should now be revoked.
const { status } = await second.request('GET', '/api/auth/session');
assert.equal(status, 401);
});
await test('audit log: security events were actually recorded', async () => {
const { body } = await owner.request('GET', '/api/admin/audit-events');
const actions = body.map((e) => e.action);
assert.ok(actions.includes('login'));
assert.ok(actions.includes('authz_denied'));
assert.ok(actions.includes('instructor_update'));
assert.ok(body.every((e) => !JSON.stringify(e.metadata || {}).match(/password/i)), 'audit metadata must never contain password material');
});
// ── Student session hardening ──────────────────────────────────────────────────────────
let studentId;
await test('roster: create a student for session tests', async () => {
const { status, body } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-Session-${RUN_ID}` });
assert.equal(status, 201);
studentId = body.student_id;
});
const student = makeSession(GW);
await test('student session: cannot call admin routes', async () => {
await student.request('POST', '/api/login', { student_id: studentId });
const { status } = await student.request('GET', '/api/admin/dashboard');
assert.equal(status, 401);
});
await test('student session: logout revokes, curriculum then rejected', async () => {
assert.equal((await student.request('GET', '/api/curriculum/path')).status, 200);
await student.request('POST', '/api/logout');
assert.equal((await student.request('GET', '/api/curriculum/path')).status, 401);
});
// ── Session persistence across a real restart ──────────────────────────────────────────
if (!SKIP_RESTART_TEST) {
await test('session persistence: instructor AND student sessions survive a Platform API restart', async () => {
const persistOwner = makeSession(GW);
await persistOwner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
const persistStudent = makeSession(GW);
await persistStudent.request('POST', '/api/login', { student_id: studentId });
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: __dirname + '/..', stdio: 'pipe' });
// Give the container a moment to re-apply migrations and start listening again.
let up = false;
for (let i = 0; i < 30 && !up; i++) {
await new Promise((r) => setTimeout(r, 1000));
try { up = (await fetch(`${API}/api/roster/cohorts`)).ok; } catch { /* still restarting */ }
}
assert.ok(up, 'Platform API did not come back up in time');
assert.equal((await persistOwner.request('GET', '/api/auth/session')).status, 200);
assert.equal((await persistStudent.request('GET', '/api/curriculum/path')).status, 200);
});
} else {
console.log(' (skipped) session persistence: restart test — SKIP_RESTART_TEST=1');
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });
+53
View File
@@ -0,0 +1,53 @@
// Shared cookie-jar test helper for the e2e suites (Phase 2A/2B/3A). Node's fetch doesn't
// persist cookies across calls the way a browser does, and Phase 3A's CSRF double-submit
// pattern means every state-changing request needs an X-CSRF-Token header echoing whichever
// rn_csrf_* cookie the jar is currently holding — this centralizes both instead of repeating
// slightly-different versions in every test file.
function makeSession(baseUrl) {
const jar = new Map(); // cookie name -> value
function cookieHeader() {
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
}
function csrfToken() {
return jar.get('rn_csrf_s') || jar.get('rn_csrf_i') || '';
}
function absorbSetCookie(res) {
const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
for (const c of cookies) {
const [pair] = c.split(';');
const eq = pair.indexOf('=');
if (eq === -1) continue;
const name = pair.slice(0, eq).trim();
const value = pair.slice(eq + 1).trim();
if (value === '') jar.delete(name); else jar.set(name, value);
}
}
return {
async request(method, path, body) {
const headers = { 'Content-Type': 'application/json' };
const cookies = cookieHeader();
if (cookies) headers.Cookie = cookies;
if (!['GET', 'HEAD'].includes(method)) headers['X-CSRF-Token'] = csrfToken();
const res = await fetch(`${baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
absorbSetCookie(res);
const text = await res.text();
let json; try { json = JSON.parse(text); } catch { json = text; }
return { status: res.status, body: json };
},
rawFetch(path) {
const cookies = cookieHeader();
return fetch(`${baseUrl}${path}`, { headers: cookies ? { Cookie: cookies } : {} });
},
hasCookie(name) { return jar.has(name); },
getCookie(name) { return jar.get(name); },
// Lets a test simulate "this browser was never issued a CSRF cookie" or "attach the wrong
// one" for a negative CSRF test, without hand-rolling a second fetch call.
setCookie(name, value) { jar.set(name, value); },
clearCookie(name) { jar.delete(name); },
};
}
module.exports = { makeSession };