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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user