# 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.