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).
22 KiB
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 viaSTUDENT_SESSION_TTL_HOURS/INSTRUCTOR_SESSION_TTL_HOURS. - Idle expiry (
idle_expires_at, slides forward on every authenticated request, never pastexpires_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
HttpOnlyand 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 anrn_instructorsession 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 theinstructorrole (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 relevantrn_csrf_*cookie and attachX-CSRF-Tokenon every mutating call.
13. Security hardening review (what was checked)
- Cookie settings —
HttpOnlyon session cookies,SameSite=Lax,Securegated behindCOOKIE_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'sWHERE revoked_at IS NULLmakes 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 viaTRUST_PROXY=1, soreq.ip(used for auditsource_ipand rate-limit keys) isn't trivially spoofable viaX-Forwarded-Forunless 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 toplatform/provisioner/(confirmed viagit diff), and its own dev compose stack collides on port 5433 with the sharedplatform/docker-compose.dev.ymlstack 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 realdocker compose restart api.- Real Lab image — rebuilt clean; standalone
--privilegedsingle-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 diffagainst 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_instructorsrole_in_cohort field exists but isn't consulted for finer-grained permissions yet — currently any assignment grants the fullinstructorrole'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.