From 40692926ee1944e7b84a5953f578fd118110affd Mon Sep 17 00:00:00 2001 From: ced4568 Date: Tue, 18 Aug 2026 13:36:45 -0500 Subject: [PATCH] docs(phase3a): document production identity architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- README.md | 30 +- docs/ENGINEERING_SOP.md | 74 +++- docs/LAB_ARCHITECTURE.md | 14 +- .../PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md | 344 ++++++++++++++++++ platform/README.md | 40 +- platform/api/README.md | 56 ++- 6 files changed, 496 insertions(+), 62 deletions(-) create mode 100644 docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md diff --git a/README.md b/README.md index 22bb033..84089bb 100644 --- a/README.md +++ b/README.md @@ -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`. --- diff --git a/docs/ENGINEERING_SOP.md b/docs/ENGINEERING_SOP.md index cf4fcd5..e2a9d49 100644 --- a/docs/ENGINEERING_SOP.md +++ b/docs/ENGINEERING_SOP.md @@ -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 ` — 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. diff --git a/docs/LAB_ARCHITECTURE.md b/docs/LAB_ARCHITECTURE.md index 3e58ead..43962be 100644 --- a/docs/LAB_ARCHITECTURE.md +++ b/docs/LAB_ARCHITECTURE.md @@ -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. diff --git a/docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md b/docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md new file mode 100644 index 0000000..27f17a9 --- /dev/null +++ b/docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md @@ -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 `":"`, 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 [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=`) 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. diff --git a/platform/README.md b/platform/README.md index be755bd..4936f9b 100644 --- a/platform/README.md +++ b/platform/README.md @@ -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 you@example.com "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. diff --git a/platform/api/README.md b/platform/api/README.md index fa976e7..8a210dd 100644 --- a/platform/api/README.md +++ b/platform/api/README.md @@ -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 you@example.com "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 [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 :/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.