diff --git a/README.md b/README.md index 5eda1d9..22bb033 100644 --- a/README.md +++ b/README.md @@ -134,11 +134,18 @@ 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/gateway/` — the only student-facing path to a Lab: resolves identity → environment - → routing token, proxies HTTP/WebSocket into that one student's container, never a raw - container IP/port (`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.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`). +- `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 + 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). - The student flow (roster login → curriculum dashboard → Launch Lab → Lab session → - Reset/Recreate) lives in `frontend/src/platform/` — a small addition alongside the existing, + 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. **Never publish the raw privileged container** — front it with isolation (that's what the @@ -151,9 +158,14 @@ Key properties of the platform (not this image): - One container **per student**, its own Docker network, its own firewall chain — never a shared cluster between learners. Verified with two simultaneous students: separate clusters, separate progress, no cross-access. -- Progress/identity live outside the disposable container (a persistent identity instance - running this same image, currently SQLite-backed, moving to the Platform's PostgreSQL in a - later phase) — destroying or recreating a student's Lab never loses their academic record. +- Progress/identity live outside the disposable container, in the Platform API's own + PostgreSQL (authoritative as of Phase 2B) — destroying or recreating a student's Lab never + 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. - 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 diff --git a/docs/ENGINEERING_SOP.md b/docs/ENGINEERING_SOP.md index 2854447..cf4fcd5 100644 --- a/docs/ENGINEERING_SOP.md +++ b/docs/ENGINEERING_SOP.md @@ -171,13 +171,15 @@ section is just the map. environment per student — its own Docker network, its own container running the existing, unmodified Lab image, its own firewall-enforced egress. Postgres-backed registry (`environments`, `environment_events`, `lab_hosts`), idle reaper, startup reconciliation. -- **What it is not:** directly reachable by a student, even now — the Gateway (§9) sits in - front of it and is the only caller. Still exercised directly via its internal API - (shared-secret auth) or `platform/provisioner/test/run.js` for isolated Provisioner testing. -- **Where identity lives:** unchanged from §5 — `students`/`progress` are still on the Lab - image's own SQLite. `environments.student_id` is deliberately opaque (no FK) until a later, - separate migration puts roster/progress on the same Postgres instance as the environment - registry. +- **What it is not:** directly reachable by a student or the Gateway anymore — as of Phase 2B, + the Platform API (§9) is the *only* caller (student launch/reset AND admin prepare/destroy/ + reset both go through it). Still exercised directly via its internal API (shared-secret + 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. + `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 proxy + the Provisioner on one machine — the documented Phase 1 dev exception. Production is a two-VM Proxmox split (Platform VM / Lab Host VM); the dev compose file is explicitly diff --git a/docs/LAB_ARCHITECTURE.md b/docs/LAB_ARCHITECTURE.md index 54ae499..3e58ead 100644 --- a/docs/LAB_ARCHITECTURE.md +++ b/docs/LAB_ARCHITECTURE.md @@ -1,12 +1,13 @@ # RootNode Academy — Ephemeral Lab Architecture -Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) IMPLEMENTED, -2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md` and -`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` for the concrete schema/API/test results, and -`platform/provisioner/README.md` for how to run the stack. This document remains the -architecture record for the full ephemeral-lab direction; instructor dashboard, production -auth/SSO, and production Proxmox deployment are still future work (Phase 2B — see the Phase 2A -doc's §9). Supersedes the namespace-isolation +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). +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. diff --git a/docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md b/docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md new file mode 100644 index 0000000..7a183f6 --- /dev/null +++ b/docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md @@ -0,0 +1,213 @@ +# RootNode Academy — Phase 2B: Admin Portal & Platform Data + +Status: **IMPLEMENTED and end-to-end tested, 2026-08-18.** Builds on +`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (Phase 2A milestone +`42dfb1c31b599d5e86b64915beb4b871b2e91055`). This doc covers: the platform/lab split, the +Postgres academic schema and SQLite migration, server-controlled sessions, and the +instructor/admin control plane. + +--- + +## 1. Split: Platform API vs. Lab image + +Phase 2A's "identity" instance was a full, `--privileged` Lab image booting an unused k3s +cluster just to serve roster/curriculum/progress APIs. Phase 2B replaces it with +`platform/api/` — a plain Node/Express service: no k3s, no `--privileged`, no terminal +runtime, no nginx (serves the built frontend directly via `express.static`). Startup is +instant instead of a 1-2 minute k3s cold start — directly observable in testing (see §8). + +The Lab image (`Dockerfile`, `backend/server.js`, `scripts/entrypoint.sh`) is **completely +unchanged in this phase** — it's still the disposable-per-student, nested-k3s, terminal + +scenario-execution engine, exactly as Phase 1/2A built it. Platform API never runs +setup/validate/teardown commands itself; it only relays outcomes the Lab already produced +(see §7, "Progress relay," carried over from Phase 2A and extended). + +Platform API owns: students, cohorts, enrollments, curriculum assignments, progress, scenario +attempts, student/admin session identity, and — new in Phase 2B — **all** Provisioner calls, +both student-initiated (launch/reset) and admin-initiated (prepare-labs, destroy-labs, reset, +destroy). The Gateway no longer calls the Provisioner directly at all; it proxies +`/api/environment/*` and `/api/admin/*` to Platform API, which is now the single place that +holds the Provisioner's shared secret. + +--- + +## 2. PostgreSQL academic schema + +New tables in the existing `rootnode_platform` database (same instance the Provisioner's +`lab_hosts`/`environments`/`environment_events` already live in — different migration +directory, same Postgres): + +- `students` — `student_id` stays the same opaque TEXT identifier used everywhere else + (no breaking change to `environments.student_id`, which remains a plain reference with no + FK, exactly as Phase 1 designed it). +- `cohorts` / `cohort_enrollments` — replaces the old single `students.cohort` TEXT column + with a real many-to-many model (a student can move between cohorts over time; the old + model couldn't represent that at all). +- `curriculum_assignments` — records which curriculum path a cohort or student is on. Only + one path exists today (`rootnode-path`) — this table exists so assigning it is a real, + auditable instructor action, not because multiple paths exist yet. +- `progress` — same shape as the SQLite table it replaces. +- `scenario_attempts` — **new capability**, not a migration of anything that existed before. + One row per validate/answer outcome (result, validation count, timestamp, which + environment), giving real per-attempt history instead of just an aggregate status + + counter. `hint_count` is present but always NULL — the existing Lab engine's hints are + static UI content only (`frontend/src/components/ScenarioPanel.jsx`), nothing server-side + ever records a hint view. Documented here rather than inventing a number; the column is + ready if hint-view tracking is ever added to the Lab engine. +- `exam_sessions` — historical import of the old SQLite `sessions` table. **Scope note:** + exam mode's *live* session bookkeeping (start/shuffle/submit/abandon) stays Lab-image-local + for Phase 2B — productionizing it would need the same relay pattern progress got in Phase + 2A, and it isn't exercised by the Phase 2B acceptance test. This table makes historical exam + records survive the migration and visible to instructors; it isn't written to by new exam + sessions yet. Flagged as a real gap, not silently ignored — see §13. + +## 3. SQLite → Postgres migration + +`platform/api/migrate-sqlite.js`. Run once as the cutover: + +```bash +SQLITE_PATH=/path/to/progress.db PLATFORM_DB_URL=postgres://... node migrate-sqlite.js +``` + +- **Backs up first, unconditionally** — copies the SQLite file to + `.backup-` before reading anything. The backup is never deleted or + modified by the script. +- **Idempotent** — every write uses `ON CONFLICT`; re-running re-syncs from the SQLite source + rather than duplicating or erroring. +- **Never silently drops data** — prints and writes a JSON report + (`migration-report-.json`) with exact counts (students, progress rows, + exam_sessions, cohorts created, enrollments created) and anything that couldn't be read + (e.g. a missing table), with the reason. +- **Preserves the old `students.cohort` column** as a real cohort + enrollment row instead of + discarding it. +- **Rollback:** Postgres only becomes authoritative once this has run and the Platform API is + pointed at it — nothing here modifies or deletes the SQLite source. To roll back: stop + Platform API, point student/instructor traffic back at a SQLite-backed service (the old + Phase 2A "identity" image is unmodified and still buildable/runnable — see + `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`), and restore from the backup file if the live + SQLite file was somehow touched (it shouldn't have been — the migration is read-only against + it). + +See the final report for actual migrated record counts from this implementation's test run. + +--- + +## 3. Student session — productionized ownership + +Phase 2A's `?sid=` was explicitly pilot-only: a client-supplied query parameter, trivially +changeable. Phase 2B replaces it with a server-controlled session: + +- `POST /api/login {student_id}` (unchanged trigger — pick-your-name, no password, still + pilot-grade identity, stated as such) creates a session server-side + (`platform/api/src/sessions.js`, in-memory, 8h TTL) and returns an opaque, CSPRNG token as + an `HttpOnly`, `SameSite=Lax` cookie (`rn_session`). The browser cannot read or fabricate + this value. +- Platform API derives `student_id` **only** from this cookie + (`studentSession` middleware) — never from a header or query parameter a client controls. +- The Gateway resolves "who is this browser" by calling Platform API's + `GET /api/session/whoami` (forwarding the cookie), then compares that student_id against + the environment's actual owning `student_id` (from the Provisioner, via Platform API) — + **on every proxied Lab request, HTTP and WebSocket, not just the initial navigation.** A + short-TTL cache (`SESSION_CACHE_TTL_MS`, 5s) keeps the extra round trip cheap. +- Changing the token, URL, query parameter, or environment ID in the browser has **no effect** + on who the Gateway thinks is asking — that's determined entirely by the unforgeable cookie. + Verified by testing: a valid session for Student A requesting Student B's `/lab//` + gets **403**, not just an unset-parameter fallback. + +**Explicitly not production SSO/OIDC.** Sessions are in-memory (a Platform API restart logs +everyone out — acceptable for a pilot/classroom tool, not silently ignored, see §13), and +login is still pick-your-name with no password. Production OIDC/Google Workspace SSO remains +Phase 3+ (§13/§14). + +### Routing-hint cookie — a real bug found while building this + +The Gateway's unprefixed fallback route (needed so the *unmodified* Lab UI's root-relative +`/api/scenarios`, `/shell-ws`, etc. resolve correctly once a browser is inside `/lab//` +— see Phase 2A's cookie design) initially used "does this session currently own a running +environment" as the signal for "should I try routing this to the Lab." That's wrong: it's +true on the *dashboard* too, not just inside the lab page, and it hijacked +`GET /api/session/whoami` (and would have hijacked `/api/environment/*`, `/api/admin/*`, +`/api/roster*`) to the student's Lab container — which doesn't have those routes at all — +whenever a student had ever launched a lab. Confirmed by testing (whoami returned the SPA's +`index.html` instead of JSON). + +Fixed with an explicit allowlist (`LAB_OWNED_PATHS` in `platform/gateway/src/server.js`) of +the specific unprefixed paths the Lab UI actually calls +(`/api/scenarios*`, `/api/bundles`, `/api/sessions*`, `/api/cache*`, `/api/health`, +`/shell-ws`, `/assets/*`) — everything else always goes to Platform API regardless of whether +the student has a running lab. `LAB_ACTIVE_COOKIE` (set only on an actual `/lab//` +visit) is a secondary gate on top of that, not the sole signal. + +--- + +## 4-9. Admin/Instructor portal + +New `/admin` route in the same frontend bundle (`frontend/src/admin/`) — a separate React tree +from the student Platform Shell, gated by its own login (`rn_admin` session cookie, completely +separate cookie/token namespace from `rn_session` — a student session can never call an admin +route and vice versa). + +**Dashboard** (`AdminDashboard.jsx`) — active cohorts, total students, Labs by state +(running/provisioning/error/idle), recent completions, recent environment failures. No +Prometheus/Grafana — this is "is class ready / is anything on fire," not an observability +platform (explicit non-goal). + +**Cohorts** (`CohortsView.jsx`) — create/edit, roster (add/view students), curriculum +assignment, per-stage progress bars aggregated across the cohort, and the classroom +preparation workflow: + +- **Prepare Labs** — finds enrolled active students, provisions anyone without a usable + environment. Runs concurrently (`Promise.allSettled`) — sequential would make a real cohort + prep take the better part of an hour, since each provision is a genuine k3s cold start. + Returns a per-student readiness report. +- **Destroy Cohort Labs** — destroys every enrolled student's environment. Academic progress + lives entirely in Postgres, untouched by this — by construction, not by a separate + safeguard, since the destroy path never touches `students`/`progress`/`scenario_attempts`. + +**Students** (`StudentsView.jsx`) — per-student curriculum progress, recent attempts +(scenario, result, validation count, timestamp), current environment status, and controls: +provision / reset-recreate / destroy / view event history. + +**Admin auth** (`platform/api/src/admin-auth.js`) — a single instructor credential +(`ADMIN_PASSWORD` env var, the same secret-injection channel every other component here +uses), verified via `scrypt` + `timingSafeEqual` rather than a plain string comparison — no +plaintext password ever sits in a database or gets logged. Explicitly not OIDC; documented as +Phase 3+ future work. + +--- + +## 10-12. API routes, security boundary + +See the route table in the final report. Boundary preserved throughout: + +``` +Student browser → Gateway → Platform API → Provisioner → Lab Host +Admin browser → Gateway → Platform API → Provisioner → Lab Host +``` + +Browser JavaScript never reaches the Provisioner directly in either path — Platform API is +now the *only* thing holding `PROVISIONER_SHARED_SECRET`. + +--- + +## 13. Known limitations + +- **Sessions are in-memory, single-process.** A Platform API restart invalidates every student + and admin session. Acceptable at pilot/classroom scale; would need a shared store (Postgres + or Redis) before this runs across multiple Platform API instances. +- **Exam mode is not fully productionized** — live exam sessions stay Lab-local; only + historical records migrate to Postgres. See §2. +- **`identity`'s Phase 2A `network_mode: bridge` requirement no longer applies** — Platform + API replaces it entirely and doesn't run k3s, so that whole class of problem is gone, not + just documented around. +- **Admin credential is a single shared password**, not per-instructor accounts — fine for a + single-instructor pilot, not a multi-instructor deployment model. +- **`?sid=` is gone entirely** (Phase 2A's mechanism) — nothing reads it anymore; a stale + bookmarked URL containing it is harmless, the parameter is simply ignored. + +## Future (Phase 3+) + +Production OIDC/SSO, per-instructor accounts, full students/progress historical fidelity for +exam mode, shared session store for horizontal scaling, everything already listed as +out-of-scope in Phase 2A/2B (parent portal, billing, capstone engine, production Proxmox, +full observability, multi-host scheduling, public enrollment). diff --git a/platform/README.md b/platform/README.md index 86e4e3a..be755bd 100644 --- a/platform/README.md +++ b/platform/README.md @@ -6,42 +6,52 @@ Start here, then go to the component you need: - `provisioner/` — Postgres-backed environment registry and lifecycle. Design: `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`. - `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware - routing, student identity glue). Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`. + 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). - `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through. -- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, a persistent - "identity" instance, Gateway) on one machine. This is the Phase 1/2A **local dev exception** - — production is the two-VM Proxmox split described in `docs/LAB_ARCHITECTURE.md` §1. +- `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 + the two-VM Proxmox split described in `docs/LAB_ARCHITECTURE.md` §1. ## Running the full stack locally ```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 ``` -Wait for `identity` to boot (it runs a real k3s cluster, same as any Lab — check with -`curl http://localhost:7554/api/health`), then the whole platform is reachable at -`http://localhost:4200` (the Gateway) — that's the one origin a student's browser would use. +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`. ## Tests - `provisioner/test/run.js` — fast, isolated Provisioner tests (network/container/firewall - mechanics, no Gateway/identity involved). Run against `provisioner/docker-compose.dev.yml`. -- `test/e2e-phase2a.js` — slow, full-stack tests against the compose file above: roster → - login → curriculum → launch → Gateway routing → cross-student rejection → progress relay → - reset. Real k3s boots, budget several minutes. Run whenever the Gateway, Provisioner API - surface, or the identity backend's progress/roster/curriculum routes change. + mechanics, no Gateway/Platform API involved). Run against `provisioner/docker-compose.dev.yml`. +- `test/e2e-phase2a.js` — student-flow regression through real session cookies: roster → login + → curriculum → launch → Gateway routing → cross-student rejection → progress relay → reset. +- `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`). -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 aren't automated here. +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 +aren't automated here. ## Known limitations -See `provisioner/README.md` (Phase 1) and `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 -(Phase 2A) for the full, honest lists — including the `identity` service's `network_mode: -bridge` requirement and its unused-k3s resource cost, both flagged as real, not swept under -the rug. +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.