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:
+59
-15
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user