docs(phase2b): document admin and platform-data architecture

New docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md covers the Platform API
split, Postgres schema and migration guarantees/rollback, the session
cookie architecture (including the whoami-routing-bug fix), the admin
portal, the API/security boundary, known limitations, and Phase 3+
future work.

Updates ENGINEERING_SOP.md §8, LAB_ARCHITECTURE.md, README.md, and
platform/README.md to reflect Platform API as the sole Provisioner
caller and Postgres as authoritative for identity/progress as of
Phase 2B.
This commit is contained in:
2026-08-18 12:23:17 -05:00
parent 6f1d202242
commit 08ff5315de
5 changed files with 278 additions and 40 deletions
+19 -7
View File
@@ -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
+9 -7
View File
@@ -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
+8 -7
View File
@@ -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.
+213
View File
@@ -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
`<path>.backup-<timestamp>` 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-<timestamp>.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/<token>/`
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/<token>/`
— 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/<token>/`
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).
+29 -19
View File
@@ -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.