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

345 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.