docs(phase3a): document production identity architecture

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

Updates ENGINEERING_SOP.md (new §9/§10, closing a stale '(§9)' forward-reference that dated
back to Phase 2B), LAB_ARCHITECTURE.md status line, README.md, platform/README.md, and
platform/api/README.md (bootstrap-owner usage, new config vars, no more ADMIN_PASSWORD).
This commit is contained in:
2026-08-18 13:36:45 -05:00
parent face0ae132
commit 40692926ee
6 changed files with 496 additions and 62 deletions
+18 -12
View File
@@ -135,18 +135,19 @@ layers rather than changing the image itself:
- `platform/provisioner/` — Postgres-backed environment registry, per-student Docker
networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`).
- `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress,
scenario attempts, student/admin sessions, instructor/admin control plane. No k3s, no
`--privileged` (`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`).
scenario attempts, persistent student/instructor sessions, per-instructor accounts (RBAC:
owner/admin/instructor), audit log, instructor/admin control plane. No k3s, no `--privileged`
(`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`).
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity from a
server-controlled session cookie (not a client-suppliable header/param), proxies
server-controlled, persistent session cookie (not a client-suppliable header/param), proxies
HTTP/WebSocket into that one student's container, never a raw container IP/port
(`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3).
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3).
- The student flow (roster login → curriculum dashboard → Launch Lab → Lab session →
Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal
(cohorts, students, classroom "Prepare Labs," environment controls) lives in
`frontend/src/admin/` at the `/admin` route — both small additions alongside the existing,
unmodified Lab UI, not a rewrite of it.
Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal (per-instructor
login, cohorts, students, instructor accounts, audit log, classroom "Prepare Labs,"
environment controls) lives in `frontend/src/admin/` at the `/admin` route — both small
additions alongside the existing, unmodified Lab UI, not a rewrite of it.
**Never publish the raw privileged container** — front it with isolation (that's what the
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
@@ -163,16 +164,21 @@ Key properties of the platform (not this image):
loses their academic record. Verified with a full class-sized (3-student) cohort workflow:
provision, complete different scenarios, reset one student's Lab, destroy all Labs after
class — progress survives every step.
- Instructors manage cohorts, students, curriculum assignment, and classroom Lab prep
(provision an entire cohort before class, verify readiness, destroy Labs after) from
`/admin` — no manual database, Docker, or Kubernetes access needed.
- Instructors sign in with their own account (no shared password) and manage cohorts, students,
curriculum assignment, and classroom Lab prep (provision an entire cohort before class, verify
readiness, destroy Labs after) from `/admin` — no manual database, Docker, or Kubernetes
access needed. Owners manage instructor accounts and roles; every instructor/admin action is
recorded in an audit log.
- Sessions (student and instructor) are Postgres-backed and survive a Platform API restart —
verified by actually restarting it mid-session and confirming access still works.
- CPU/memory/PID/storage limits are enforced per container, config-driven.
- Egress is restricted to an allowlisted proxy at the firewall layer, not left open.
- Idle/abandoned environments are automatically cleaned up, and a student can launch a fresh
one and keep going without losing progress.
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`.
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`.
---
+59 -15
View File
@@ -139,17 +139,22 @@ Order agreed 2026-08-17/18. Status as of this doc:
(`⎈ RootNode Academy Lab — node: k8s-lab`). Upstream KubeKosh attribution left untouched in
footer/README/LICENSE. Broader "make Real Ops first-class" UI work not started.
2. RootNode curriculum mode — **backend done** (§4), frontend not started.
3. Student accounts / persistent progress — **backend done** (§5), frontend not started,
no instructor auth yet.
4. Instructor dashboard — not started.
5. Per-student isolation — **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow)
done**, see §8/§9. Ephemeral per-student Lab environments, Gateway-mediated routing, and a
working entry→dashboard→launch→lab→progress flow are implemented and end-to-end tested on a
single dev machine, including two simultaneous students with verified cross-student
isolation. **Still not cleared for minors** — pilot-grade identity only (no
password/SSO), no instructor auth layer, and the production two-VM Proxmox split this
depends on doesn't exist yet. See `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` for exactly
what's verified vs. not.
3. Student accounts / persistent progress — **done**, Postgres-authoritative as of Phase 2B
(§9), sessions persistent as of Phase 3A (§10). Roster name-picking is still classroom/pilot
identification, not strong student auth — documented, not silently glossed over
(docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §7).
4. Instructor dashboard — **done** (§9/§10). Full admin portal at `/admin`: dashboard, cohort
and student management, classroom Prepare/Destroy Labs, per-instructor accounts with
owner/admin/instructor roles, audit log.
5. Per-student isolation — **Phase 1 + Phase 2A + Phase 2B + Phase 3A done**, see §8/§9/§10.
Ephemeral per-student Lab environments, Gateway-mediated routing with persistent
server-controlled sessions, a full instructor/admin control plane with real per-person
accounts and RBAC, and a working entry→dashboard→launch→lab→progress flow are implemented
and end-to-end tested on a single dev machine, including a 3-student cohort classroom
workflow and a real Platform API restart mid-session. **Still not cleared for minors** — the
production two-VM Proxmox split this depends on doesn't exist yet, and OIDC/SSO is a
documented adapter, not a working integration (docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md
§16).
6. Scenarios → teaching modules (incident context/investigation/prevention) — not started.
7. Capstone/exam engine — not started (placeholder stage only, see §4).
8. Progressive hint levels — not started.
@@ -157,9 +162,9 @@ Order agreed 2026-08-17/18. Status as of this doc:
10. CI scenario testing — not started.
11. Container/build optimization — explicitly deprioritized by design (platform works, not urgent).
Next slice: Phase 2B — instructor dashboard, production auth/SSO, and the identity/progress
Postgres migration are the biggest remaining items. See
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 for the full Phase 2B list.
Next slice: production OIDC/SSO, the production two-VM Proxmox deployment, and teaching-module
scenario content are the biggest remaining items. See
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §18 for the full BUILT NOW vs. FUTURE split.
## 8. Platform Provisioner (Phase 1)
@@ -177,7 +182,8 @@ section is just the map.
auth) or `platform/provisioner/test/run.js` for isolated Provisioner testing.
- **Where identity lives:** as of Phase 2B, Postgres is authoritative —
`students`/`cohorts`/`progress`/`scenario_attempts` live in the same `rootnode_platform`
database as `environments`, owned by the Platform API's own migrations. See §9.
database as `environments`, owned by the Platform API's own migrations. See §9. As of Phase
3A, `instructors`/`sessions`/`cohort_instructors`/`audit_events` live there too. See §10.
`environments.student_id` is still a plain opaque reference, not a real FK — that's a
deliberate, documented choice (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §2), not an oversight.
- **Local dev:** `platform/provisioner/docker-compose.dev.yml` runs Postgres + the egress
@@ -189,3 +195,41 @@ section is just the map.
events). Some properties (idle reaper timing, remote TLS lab-host, reconciliation-on-crash,
real-image k3s behavior) were validated manually during implementation rather than
automated — see the Phase 1 final report for what and why.
## 9. Platform API (Phase 2B)
Full design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`. Operational reference:
`platform/api/README.md`.
- **What it is:** `platform/api/` — a lightweight Node/Express service (no k3s, no
`--privileged`, no Lab terminal runtime) owning students, cohorts, enrollments, curriculum
assignments, progress, scenario attempts, and — as of Phase 3A — instructor accounts,
persistent sessions, and the audit log (§10). The sole caller of the Provisioner for both the
student launch/reset path and the instructor/admin control plane.
- **What it is not:** reachable directly by a browser — always through the Gateway
(`platform/gateway`), same trust boundary the Provisioner has relative to this service.
- **Local dev:** part of the same `platform/docker-compose.dev.yml` stack as the Provisioner —
no separate compose file.
- **Test procedure:** `platform/test/e2e-phase2a.js` (student flow) and
`platform/test/e2e-phase2b.js` (classroom acceptance workflow) — both need a bootstrapped
instructor account as of Phase 3A, see §10.
## 10. Identity and instructor access (Phase 3A)
Full design: `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`. Operational reference:
`platform/api/README.md` (bootstrap, config).
- **What it is:** persistent Postgres-backed sessions (student + instructor, survive a Platform
API restart), real per-instructor accounts (`owner`/`admin`/`instructor` roles, scrypt
password hashing, per-instructor salt), server-enforced RBAC on every `/api/admin/*` route,
cohort-scoped access for the plain `instructor` role, CSRF double-submit protection, login
rate limiting, and a platform-level `audit_events` log.
- **What it replaced:** Phase 2B's in-memory session `Map`s (gone on every restart) and the
single shared `ADMIN_PASSWORD` env var (no accountability, no roles, no revocation).
- **Bootstrap:** `node platform/api/bootstrap-owner.js <email> <name> <password>` — the only
supported way to create the first instructor account; no manual database editing.
- **Test procedure:** `platform/test/e2e-phase3a.js` — 21 automated tests covering session
persistence (including a real `docker compose restart api`), instructor auth, RBAC, cohort
scoping, CSRF, rate limiting, password change, and audit logging.
- **Known limitations, future OIDC path, production HTTPS expectations:** see
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §15–17.
+8 -6
View File
@@ -1,12 +1,14 @@
# RootNode Academy — Ephemeral Lab Architecture
Status: **Phase 1 (platform foundation) + Phase 2A (Gateway + student flow) + Phase 2B
(Platform API split, Postgres academic data, server sessions, admin portal) IMPLEMENTED,
2026-08-18** — see `docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
and `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for concrete schema/API/test results, and
`platform/README.md` for how to run the stack. This document remains the architecture record
for the full ephemeral-lab direction; production OIDC/SSO, per-instructor accounts, and
production Proxmox deployment are still future work (Phase 3+ — see the Phase 2B doc's §13).
(Platform API split, Postgres academic data, server sessions, admin portal) + Phase 3A
(persistent sessions, per-instructor accounts, RBAC, audit log) IMPLEMENTED, 2026-08-18** — see
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, and `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`
for concrete schema/API/test results, and `platform/README.md` for how to run the stack. This
document remains the architecture record for the full ephemeral-lab direction; production
OIDC/SSO (an adapter interface exists, not a working integration — see the Phase 3A doc §16)
and production Proxmox deployment are still future work (Phase 3B+ — see the Phase 3A doc §18).
Supersedes the namespace-isolation
(`shared_namespace`/`ephemeral_cluster`) approach that was in flight before this redirect — see
`scenarios/SCHEMA.md`'s `isolation_type` note for the pointer back here.
@@ -0,0 +1,344 @@
# Phase 3A — Identity and Instructor Access
Productionizes identity for RootNode Academy: persistent sessions, real per-instructor accounts
with roles, server-enforced RBAC, CSRF/rate-limiting hardening, and a platform-level audit log.
Builds on `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (Platform API split, Postgres migration,
first-generation session cookies, admin portal) — read that first if you haven't.
## 1. What changed, in one paragraph
Phase 2B's sessions lived in an in-memory `Map` (gone on every restart) and instructor access
was one shared `ADMIN_PASSWORD` env var (no accountability, no roles, no revocation). Phase 3A
replaces both: sessions are now rows in Postgres (`sessions` table, survive a restart, are
individually revocable), and instructors are real accounts (`instructors` table) with a role —
`owner`, `admin`, or `instructor` — enforced server-side on every `/api/admin/*` route, with a
plain `instructor` further scoped to the cohorts they're assigned to. Every security-relevant
action is now recorded in a platform-level audit log (`audit_events`).
## 2. Schema added
```
instructors instructor_id, email (unique), display_name, password_hash, role, status,
created_at, updated_at, last_login_at
auth_identities auth_identity_id, instructor_id, provider ('local' today), provider_subject,
provider_email, created_at, last_login_at — one 'local' row per instructor;
a future OIDC login adds a second row for the SAME instructor_id, no schema
change needed then.
sessions session_id, session_type ('student'|'instructor'), student_id, instructor_id,
token_hash (SHA-256 of the bearer token — the raw token is NEVER stored),
created_at, last_seen_at, expires_at (absolute), idle_expires_at (sliding,
capped at expires_at), revoked_at, metadata
cohort_instructors cohort_id, instructor_id, role_in_cohort — scopes the plain 'instructor'
role; owner/admin never consult this table (global visibility by role alone)
audit_events audit_event_id, actor_type, actor_id, action, resource_type, resource_id,
result, source_ip, metadata, created_at
```
All five are new migrations (`platform/api/migrations/007`–`011`), idempotent (the existing
`schema_migrations` tracking table already guarantees that), and applied automatically on
Platform API startup — no manual migration step. None of Phase 2B's tables
(`students`/`cohorts`/`progress`/`scenario_attempts`/etc.) were touched; all Phase 2B data is
preserved exactly as-is. `sessions.instructor_id` and `cohort_instructors.instructor_id` are
real foreign keys to `instructors` (both tables live in the same Platform API-owned migration
set) — unlike `environments.student_id` in the Provisioner's schema, there's no
cross-service-database reason to keep this one an opaque reference.
## 3. Persistent session design
Browser holds one opaque, CSPRNG bearer token (32 random bytes, base64url) per session, in an
`HttpOnly`, `SameSite=Lax` cookie (`Secure` too, once `COOKIE_SECURE=1`). The server stores only
`SHA-256(token)` — a Postgres dump or backup can't be replayed as a working session, same
principle as password hashing.
Two independent lifecycles (`sessions.session_type`), separate cookie names
(`rn_session`/`rn_instructor`), separate token namespaces — a bug can't confuse a student
session for an instructor one. Each session has:
- **Absolute expiry** (`expires_at`, fixed at creation) — 8h student / 12h instructor by default,
configurable via `STUDENT_SESSION_TTL_HOURS` / `INSTRUCTOR_SESSION_TTL_HOURS`.
- **Idle expiry** (`idle_expires_at`, slides forward on every authenticated request, never past
`expires_at`) — 2h default for both, configurable via `*_SESSION_IDLE_HOURS`.
Resolving a session (`sessions.js` `resolveSession`) is one atomic `UPDATE ... RETURNING` that
checks `revoked_at IS NULL AND expires_at > now() AND idle_expires_at > now()` and slides the
idle window forward in the same round trip — no separate read-then-write race.
**Restart behavior:** a Platform API restart no longer logs anyone out — verified in
`platform/test/e2e-phase3a.js` by actually running `docker compose restart api` mid-test and
confirming both an instructor session and a student session still resolve afterward.
**Role/status changes take effect immediately, no revocation needed:** the instructor session
middleware re-fetches the instructor row from Postgres on *every* request rather than trusting
anything cached in the session token — so demoting a role or deactivating an account is live on
the very next request that account makes, with no separate "walk every session and revoke"
step required. Explicit revocation (owner-triggered "revoke all sessions," and automatic on
password change) exists too, for the case where you want to kill an active session immediately
rather than wait for its next request.
## 4. Instructor accounts
`instructors.password_hash` is `scrypt` with a **per-instructor random salt** (Phase 2B's
`admin-auth.js` used one fixed salt because there was exactly one shared password — that's gone,
this is real per-row salting now), stored as `"<salt-hex>:<hash-hex>"`, verified with
`crypto.timingSafeEqual`. No plaintext password ever touches a log line, a database column, or
an API response body (`publicInstructor()` strips `password_hash` before any JSON response).
Bootstrap: `node platform/api/bootstrap-owner.js <email> <name> [password]` — the only supported
way to create the first account, so a fresh deployment never requires editing Postgres by hand.
Refuses to run if an active owner already exists unless the target email is an existing
instructor (the intended zero-owners recovery path).
## 5. Role model (RBAC matrix)
| Action | owner | admin | instructor |
|---|---|---|---|
| Manage instructor accounts (create/edit/deactivate/reset password/revoke sessions) | ✅ | ❌ | ❌ |
| View audit log | ✅ | ✅ | ❌ |
| Create/edit cohorts, create/edit students, assign curriculum | ✅ | ✅ | ❌ |
| Assign an instructor to a cohort | ✅ | ✅ | ❌ |
| View dashboard, cohort list, cohort/student detail, progress | ✅ (global) | ✅ (global) | ✅ (**assigned cohorts only**) |
| Prepare Labs / Destroy Cohort Labs, provision a student's Lab | ✅ (any cohort) | ✅ (any cohort) | ✅ (**assigned cohorts only**) |
| Reset/destroy/view-events by raw environment id | ✅ | ✅ | ❌ — see §6 |
Enforced by `src/rbac.js`, applied per-route in `src/admin-routes.js` — never in the frontend
alone (`frontend/src/admin/*.jsx` hides controls a role can't use, but that's UX; every route
re-checks server-side regardless of what the browser sends or hides). A denied request gets a
flat `403 {"error":"forbidden"}` — no distinction in the response between "wrong role" and
"right role, wrong cohort," and no distinction between "resource doesn't exist" and "you can't
see it," so a 403 never confirms anything about what exists to someone probing.
An unauthenticated request gets `401`, not `403` — the two are kept distinct throughout.
## 6. Cohort-scoped instructor access
`cohort_instructors` maps `instructor_id → cohort_id` (assigned by an owner/admin, never
self-service). A plain `instructor` role's requests to cohort- or student-keyed routes go
through `requireCohortAccess`/`requireStudentCohortAccess`, which check this table;
owner/admin bypass it entirely (global visibility by role alone).
**Deliberate simplification:** the raw `/api/admin/environments/:id/{reset,DELETE,events}`
routes (operate directly on a Provisioner environment id, no cohort context in the URL) stay
owner/admin-only rather than resolving cohort scope from a bare environment id — that resolution
would need an extra Provisioner round trip on every single call. A plain instructor still has
full Lab control, just via the cohort/student-scoped routes (`/cohorts/:id/prepare-labs`,
`/students/:id/environment/provision`, etc.), which already carry the scope check. Upgrade path
if this ever needs to change: add a `student_id`/`cohort_id` lookup to the Provisioner's
`GET /environments/:id` response and extend `requireCohortAccess` to resolve through it.
## 7. Student session hardening
The pick-your-name roster flow is unchanged UX — **this is classroom/pilot identification, not
production authentication.** There is still no password or possession factor at the identity
step; anyone who knows a display name (or can see the roster dropdown) can "become" that
student. What Phase 3A hardens is everything *after* that pick:
- The resulting session is server-controlled and now **persistent** (survives a Platform API
restart — verified, see §3).
- A student cannot switch identity by editing client state — there's nothing client-side to
edit; the cookie is `HttpOnly` and opaque.
- Logout revokes the session server-side (`sessions.destroyStudentSession`), not just a
client-side cookie clear.
- Every Gateway-proxied Lab request (HTTP and WebSocket) re-verifies ownership against the
session on every request, not just the initial navigation — unchanged from Phase 2B, still
true here.
- A student session can never call `/api/admin/*` — those routes require an `rn_instructor`
session in a completely separate cookie/token namespace.
- CSRF (see §8) now also protects student mutating routes (`/api/environment/launch`, `/reset`,
`/api/progress/record`, `/api/logout`) the same way it protects instructor ones.
**Future upgrade path that doesn't require touching the session system:** one-time student
enrollment codes, parent-issued codes, or institutional SSO would all still terminate in the
exact same `sessions` table and `rn_session` cookie — only the *identity verification step*
before `createStudentSession()` is called would change. This is why the session store was worth
building as a shared, session-type-agnostic module rather than something bespoke to the current
roster-picker.
## 8. CSRF protection
Double-submit cookie pattern, on top of (not instead of) `SameSite=Lax`. On login, a
**non-HttpOnly** cookie is set — `rn_csrf_s` (student) / `rn_csrf_i` (instructor) — deliberately
readable by the frontend's own JS via `document.cookie`. Every state-changing request
(`POST`/`PATCH`/`DELETE`) must echo that value back as an `X-CSRF-Token` header; the server
checks the header matches the cookie it's holding for that same request. A cross-origin
attacker's page can trigger a cookie-carrying request but can't read the cookie's value (browser
same-origin policy) or attach a matching custom header without CORS, which isn't granted to
other origins — so it can never produce a request where the two match.
`GET`/`HEAD`/`OPTIONS` are exempt (nothing to protect). Applied uniformly, including to
`/api/logout` and `/api/auth/logout` — a forged logout is low-severity, but consistent
enforcement was simpler to reason about than a per-route exception.
**One real bug found while testing this:** the Gateway's own progress-relay call
(`platform/gateway/src/server.js` `relayScenarioOutcome`, triggered when a student solves a
scenario inside their Lab) is a server-to-server call to `POST /api/progress/record`, not a
browser one — but it's relaying the browser's own session, so it needs to forward the CSRF
token too. The first fix attempt forwarded only the `X-CSRF-Token` header and still 403'd,
because the double-submit check needs BOTH the cookie *and* a matching header on the request
Platform API actually receives — the fix forwards both.
## 9. Login rate limiting
In-memory sliding window (`src/security.js`), keyed by `ip:email`, max 8 attempts per 5 minutes,
`429` past that. No new dependency — small enough to hand-roll. **Known limitation:** in-memory
means it resets on a Platform API restart and doesn't share state across multiple instances; a
Postgres- or Redis-backed limiter would be the upgrade if this ever needs to survive a restart
or scale beyond one process. Not needed at pilot scale.
## 10. Audit log
`audit_events`, distinct from the Provisioner's `environment_events` (Lab-lifecycle-only).
Records: instructor login success/failure, logout, password change, instructor
created/updated/deactivated, role changes, session revocation, cohort create/update, roster
changes, curriculum assignment, Prepare Labs, Lab reset/destroy, and every `403` authorization
denial (recorded centrally by the RBAC middleware itself, not sprinkled per-route). Never
records plaintext passwords, raw session tokens, Provisioner secrets, or raw routing tokens —
verified by an explicit test assertion (`e2e-phase3a.js` "audit log" case scans recorded
metadata for anything password-shaped). Viewable at `/admin` → Audit (owner/admin only), latest
200 events, no pagination UI yet — pilot-scale, not a SIEM.
## 11. API inventory (new/changed)
```
POST /api/auth/instructor/login email+password login, rate-limited, no enumeration
POST /api/auth/logout revokes the current instructor session
GET /api/auth/session current instructor identity (role, etc.)
POST /api/auth/change-password requires current password; revokes other sessions
POST /api/login UNCHANGED route, now backed by persistent sessions
POST /api/logout UNCHANGED route, now CSRF-checked + persistent revoke
GET /api/session/whoami UNCHANGED — Gateway's ownership-check dependency
GET /api/admin/instructors owner only
POST /api/admin/instructors owner only
GET /api/admin/instructors/:id owner only — includes session list
PATCH /api/admin/instructors/:id owner only — role/status/name; blocks removing the last active owner
POST /api/admin/instructors/:id/revoke-sessions owner only
POST /api/admin/instructors/:id/reset-password owner only — also revokes that instructor's sessions
GET /api/admin/audit-events admin/owner
POST /api/admin/cohorts/:id/instructors owner/admin — assigns an instructor to a cohort
```
Every pre-existing `/api/admin/*` route from Phase 2B still exists at the same path — behavior
unchanged for owner/admin, newly scoped for the `instructor` role (see §5/§6). `/api/admin/login`,
`/api/admin/logout`, `/api/admin/whoami` (the old shared-password routes) are gone — replaced by
the `/api/auth/instructor/*` routes above.
## 12. Frontend/admin changes
- `AdminPortal.jsx` — email+password login form (was a bare password field); nav now
conditionally shows Instructors (owner) and Audit (owner/admin) tabs; adds an Account tab.
- `InstructorsView.jsx` (new) — create/list/edit instructors, role/status controls, session list
+ revoke-all, password reset.
- `AuditView.jsx` (new) — read-only audit event table.
- `AccountView.jsx` (new) — change-password form for the signed-in instructor.
- `CohortsView.jsx` — create-cohort form and curriculum-assignment button hidden for the
`instructor` role (server already enforces this; this just avoids showing controls that would
403).
- `frontend/src/admin/api.js` / `frontend/src/platform/api.js` — both now read the relevant
`rn_csrf_*` cookie and attach `X-CSRF-Token` on every mutating call.
## 13. Security hardening review (what was checked)
- **Cookie settings** — `HttpOnly` on session cookies, `SameSite=Lax`, `Secure` gated behind
`COOKIE_SECURE=1` (off by default for local HTTP dev).
- **CSRF** — §8.
- **Login brute force** — §9.
- **Session fixation** — every login issues a brand-new session row (never reuses/upgrades an
existing token), so there's nothing to "fix" a pre-auth session into.
- **Password handling** — §4.
- **Account enumeration** — identical response for unknown-email and wrong-password (tested).
- **Authorization bypass** — every `/api/admin/*` route re-checks server-side (§5); tested with
both role-based and cohort-scoped negative cases.
- **Inactive accounts** — rejected at the session-middleware level on every request, not just at
login (a deactivation takes effect immediately, §3).
- **Revoked sessions** — `resolveSession`'s `WHERE revoked_at IS NULL` makes a revoked session
fail identically to an expired one.
- **Role downgrade/upgrade session invalidation** — handled by the live re-fetch design (§3),
not a separate revoke pass.
- **WebSocket authentication** — unchanged from Phase 2A/2B: the Gateway's WS upgrade handler
re-verifies ownership via the same session-resolution path as HTTP.
- **Gateway ownership checks** — unchanged mechanism, tightened cache TTL (5000ms → 2000ms) so a
revoked session stops working at the Gateway within a tighter, tested bound.
- **`trust proxy`** — off by default, opt-in via `TRUST_PROXY=1`, so `req.ip` (used for audit
`source_ip` and rate-limit keys) isn't trivially spoofable via `X-Forwarded-For` unless a real
trusted proxy is actually in front.
## 14. Test results
- `platform/provisioner/test/run.js` (Phase 1, isolated stack) — **not re-run this phase**: zero
source changes to `platform/provisioner/` (confirmed via `git diff`), and its own dev compose
stack collides on port 5433 with the shared `platform/docker-compose.dev.yml` stack used for
everything else in this phase. Provisioner was still exercised heavily and indirectly through
every e2e suite below (provision/reset/destroy, concurrent Prepare Labs, cohort-scoped
operations) — all passing.
- `platform/test/e2e-phase2a.js` — **10/10 passed** (student flow, now under persistent sessions).
- `platform/test/e2e-phase2b.js` — **12/12 passed** (classroom workflow, now under real
instructor accounts instead of a shared password).
- `platform/test/e2e-phase3a.js` — **21/21 passed**: no-enumeration, CSRF positive+negative,
rate limiting, role creation, RBAC denial (instructor→owner-only, instructor→admin-only,
admin→owner-only), cohort scoping (visibility + access), last-owner protection, live
role/status re-evaluation, password change + old-password rejection + other-session
revocation, audit log content (including the no-secrets assertion), student session RBAC,
student logout revocation, and session persistence across a real `docker compose restart api`.
- Real Lab image — rebuilt clean; standalone `--privileged` single-container mode boots and
serves with zero Platform API/Postgres/Provisioner dependency (verified live).
- Frontend production build — clean, no errors.
- Scenario content / Real Ops bundle / Lab `Dockerfile`/`backend/`/`scripts/` — `git diff`
against the Phase 2B milestone SHA is empty for all of them: untouched.
## 15. Known limitations
- **Login rate limiting is in-memory, single-process** (§9) — resets on restart, doesn't share
state across multiple Platform API instances. Fine at pilot scale.
- **Gateway session cache is a 2-second window** — a revoked/expired session can still work at
the Gateway for up to ~2s after revocation (tightened from 5s in Phase 2B). Platform API
itself always reflects revocation immediately; this bound is specific to the Gateway's
whoami-result cache, which exists purely to avoid a Platform API round trip on every single
proxied Lab byte.
- **No distributed session revocation push** — revoking a session updates Postgres immediately;
anything holding a *cached* copy of "this session is valid" (only the Gateway does this, and
only for 2s) catches up on its next cache miss, not instantly.
- **Audit log has no pagination UI** — latest 200 events only. Add pagination if usage ever
exceeds that being useful.
- **Roster name-picking is still not strong student identity** (§7) — explicitly documented,
not silently glossed over.
- **`cohort_instructors` role_in_cohort field exists but isn't consulted for finer-grained
permissions yet** — currently any assignment grants the full `instructor` role's cohort-scoped
actions; a co-teacher vs. lead-teacher distinction within a cohort would consume this field but
isn't built.
- **Exam mode's live bookkeeping is still Lab-local** — unchanged from Phase 2B, still a real gap
for anyone relying on cross-restart exam state.
## 16. Future OIDC path
`auth_identities` (§2) already supports it structurally: a Google/OIDC login would create a
second `auth_identities` row (`provider='google'`, `provider_subject=<their sub>`) pointing at
an existing `instructor_id`, or create a new `instructors` row + `auth_identities` row for a
first-time SSO login. The missing piece is the actual OIDC client flow (discovery, token
exchange, JWT verification) — deliberately **not built this phase**, per the explicit scope
instruction to stop at a documented adapter interface rather than pull in an OIDC client
library and expand scope. `instructors.password_hash` staying nullable (already the case) is
what allows an SSO-only account to exist with no local password at all, whenever that flow is
built.
## 17. Production HTTPS / cookie expectations
Local dev and the current single-VM pilot deployment run over plain HTTP — `COOKIE_SECURE` is
unset (cookies lack `Secure`) and `TRUST_PROXY` is unset by default. Before any real-HTTPS
production deployment: set `COOKIE_SECURE=1` (cookies become HTTPS-only) and, if fronted by a
reverse proxy/load balancer, set `TRUST_PROXY=1` so `req.ip` reflects the real client IP instead
of the proxy's.
## 18. BUILT NOW vs. FUTURE
**Built now:** persistent Postgres-backed sessions (student + instructor), per-instructor
accounts with scrypt password hashing, three-role RBAC enforced server-side, cohort-scoped
instructor access, CSRF double-submit protection, login rate limiting, platform audit log,
owner-safety invariant (never zero active owners), password change with other-session
revocation, `auth_identities` table ready for future OIDC mapping.
**Future (not built):** real OIDC/SSO client flow, multi-instance-safe rate limiting, audit log
pagination/export, cohort_instructors role-in-cohort permission granularity, distributed
(non-Gateway-cache) session revocation push, one-time student enrollment codes /
parent-issued codes, everything already on the Phase 2B "DO NOT BUILD YET" list (capstone
engine, billing, production Proxmox deployment, etc.) — unchanged, still not in scope.
+26 -14
View File
@@ -7,13 +7,15 @@ Start here, then go to the component you need:
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
`provisioner/README.md`.
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, progress,
scenario attempts, student/admin sessions, and the instructor/admin control plane. No k3s, no
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
booting an unused k3s cluster just to serve these APIs. Design:
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`. Operational reference: `api/README.md`.
scenario attempts, per-instructor accounts, persistent student/instructor sessions, RBAC,
audit logging, and the instructor/admin control plane. No k3s, no `--privileged` — this
replaced Phase 2A's "identity" instance, which was a full Lab image booting an unused k3s
cluster just to serve these APIs. Design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split +
Postgres migration), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts,
RBAC, audit). Operational reference: `api/README.md`.
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
routing derived from a server-controlled session — not a client-suppliable header/param).
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3 (session ownership).
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (persistent sessions, CSRF).
- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through.
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, Platform API,
Gateway) on one machine. This is the Phase 1/2A/2B **local dev exception** — production is
@@ -24,14 +26,20 @@ Start here, then go to the component you need:
```bash
cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
docker compose -f docker-compose.dev.yml build
docker compose -f docker-compose.dev.yml up -d
# First run only — create the owner account (no ADMIN_PASSWORD anymore, see
# docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md):
cd api
PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus"
```
No k3s boot to wait for at this layer anymore (Platform API starts instantly) — the whole
platform is reachable at `http://localhost:4200` (the Gateway) right away: students at `/`,
instructors at `/admin`.
instructors at `/admin` (sign in with the owner account you just bootstrapped, then create
further instructor accounts from the Instructors tab).
## Tests
@@ -42,16 +50,20 @@ instructors at `/admin`.
- `test/e2e-phase2b.js` — the classroom acceptance workflow: instructor creates a cohort, adds
students, assigns curriculum, Prepare Labs (concurrent), students complete different
scenarios, instructor resets one, instructor destroys all cohort Labs after class — progress
survives every step. Needs `ADMIN_PASSWORD` set (see `platform/.env`).
survives every step.
- `test/e2e-phase3a.js` — persistent sessions (including a real Platform API restart), instructor
auth (no enumeration, rate limiting), RBAC across owner/admin/instructor, cohort-scoped
instructor access, CSRF enforcement, password change, and audit logging.
Both e2e suites are slow (real k3s boots) and need the full stack up. Neither substitutes for
the manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation)
in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
All three e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
gone. They're slow (real k3s boots) and need the full stack up. None of them substitute for the
manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation) in
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
aren't automated here.
## Known limitations
See `provisioner/README.md` (Phase 1), `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 (Phase
2A), and `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 (Phase 2B) for the full, honest lists —
including in-memory sessions, the single shared admin credential, and exam mode's live
bookkeeping staying Lab-local.
2A), and `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (Phase 3A, supersedes Phase 2B's
identity limitations) for the full, honest lists.
+41 -15
View File
@@ -1,7 +1,9 @@
# RootNode Academy — Platform API (Phase 2B)
# RootNode Academy — Platform API (Phase 3A)
Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the design; this is the operational reference.
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the original split/migration design and
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` for persistent sessions, per-instructor
accounts, RBAC, and audit logging — this file is the operational reference.
## Configuration reference
@@ -10,25 +12,46 @@ Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See
| `PLATFORM_DB_URL` | no (defaults to local dev Postgres) | Postgres connection string |
| `PROVISIONER_URL` | no | Provisioner base URL |
| `PROVISIONER_SHARED_SECRET` | **yes** | Internal Provisioner auth — process refuses to start without it |
| `ADMIN_PASSWORD` | **yes** | Instructor login credential — process refuses to start without it. Verified via scrypt+timing-safe compare, never stored or logged in plaintext |
| `PLATFORM_API_PORT` | no | Listen port, default 4300 |
| `FRONTEND_DIST_DIR` | no | Where the built frontend lives — set by the Dockerfile, override for local non-Docker dev |
| `SCENARIOS_DIR` / `BUNDLES_DIR` / `CURRICULUM_PATH_FILE` | no | Override the default `scenarios/` locations, mainly for local dev outside Docker |
| `STUDENT_SESSION_TTL_HOURS` / `STUDENT_SESSION_IDLE_HOURS` | no | Default 8h / 2h |
| `INSTRUCTOR_SESSION_TTL_HOURS` / `INSTRUCTOR_SESSION_IDLE_HOURS` | no | Default 12h / 2h |
| `COOKIE_SECURE` | no | Set to `1` once served over real HTTPS — adds `Secure` to every session/CSRF cookie |
| `TRUST_PROXY` | no | Set to `1` once fronted by a trusted reverse proxy — honors `X-Forwarded-For` for `req.ip` (audit `source_ip`, login rate limiting) |
There is no `ADMIN_PASSWORD` anymore — instructor accounts live in Postgres (`instructors`
table), created via `bootstrap-owner.js` for the first one and the admin portal after that.
## Local dev
```bash
cd platform
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
docker compose -f docker-compose.dev.yml build
docker compose -f docker-compose.dev.yml up -d
cd api
PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus"
```
Everything is reachable through the Gateway at `http://localhost:4200` — students at `/`,
instructors at `/admin`. Platform API itself isn't meant to be hit directly by a browser (same
boundary as the Provisioner relative to Platform API), though nothing stops you from curling
it directly at `:4300` for local debugging.
instructors at `/admin` (sign in with the bootstrapped owner). Platform API itself isn't meant
to be hit directly by a browser (same boundary as the Provisioner relative to Platform API),
though nothing stops you from curling it directly at `:4300` for local debugging.
## Owner bootstrap
```bash
node bootstrap-owner.js <email> <display_name> [password]
```
Prefer `BOOTSTRAP_OWNER_PASSWORD` over the positional argument outside a throwaway local test —
CLI args can leak into shell history and `ps`. Refuses to run if an active owner already exists
unless the target email belongs to an existing instructor (the intended recovery path if a
platform is ever locked out with zero owners: point this at a known instructor account to
promote them).
## SQLite migration
@@ -42,17 +65,20 @@ docker run --rm -v <identity-data-volume>:/data \
Always backs up the SQLite file first, is safe to re-run, and writes a JSON report — see
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §2/§3 for the full guarantees and rollback
procedure.
procedure. This step is about academic data (students/cohorts/progress) — it has nothing to do
with instructor accounts, which are Phase 3A-native and start empty until you bootstrap one.
## Tests
`platform/test/e2e-phase2a.js` (student-flow regression, cookie-based) and
`platform/test/e2e-phase2b.js` (the classroom acceptance workflow: cohort → students →
curriculum → Prepare Labs → complete scenarios → reset → destroy-labs → progress survives).
Both need the full stack up and `ADMIN_PASSWORD` set — see `platform/README.md`.
`platform/test/e2e-phase2a.js` (student-flow regression), `platform/test/e2e-phase2b.js` (the
classroom acceptance workflow), and `platform/test/e2e-phase3a.js` (persistent sessions
including a real restart, instructor auth, RBAC, cohort scoping, CSRF, audit log). All three
need the full stack up and `OWNER_EMAIL` / `OWNER_PASSWORD` set to a bootstrapped account — see
`platform/README.md`.
## Known limitations
See `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 — in-memory sessions (a restart logs
everyone out), exam mode's live bookkeeping stays Lab-local, single shared admin credential
rather than per-instructor accounts.
See `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` "Known limitations" — sessions are
Postgres-backed (survive a restart) but there's no distributed revocation channel to the
Gateway's short-lived whoami cache, exam mode's live bookkeeping stays Lab-local, and OIDC/SSO
is a documented adapter interface, not a working integration yet.