Files
rootnode-academy/docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md
ced4568 40692926ee 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).
2026-08-18 13:36:45 -05:00

22 KiB
Raw Permalink Blame History

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.

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.