Compare commits
9
Commits
08ff5315de
...
40692926ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40692926ee | ||
|
|
face0ae132 | ||
|
|
1ca5dd3995 | ||
|
|
777a1ef880 | ||
|
|
7ce435ad32 | ||
|
|
62726455d5 | ||
|
|
37230d4ef1 | ||
|
|
85fa920f96 | ||
|
|
52c17c1907 |
@@ -135,18 +135,19 @@ layers rather than changing the image itself:
|
||||
- `platform/provisioner/` — Postgres-backed environment registry, per-student Docker
|
||||
networking, firewall-enforced egress, idle reaper (`docs/PHASE1_PLATFORM_FOUNDATION.md`).
|
||||
- `platform/api/` — lightweight Platform API: students, cohorts, curriculum, progress,
|
||||
scenario attempts, student/admin sessions, instructor/admin control plane. No k3s, no
|
||||
`--privileged` (`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`).
|
||||
scenario attempts, persistent student/instructor sessions, per-instructor accounts (RBAC:
|
||||
owner/admin/instructor), audit log, instructor/admin control plane. No k3s, no `--privileged`
|
||||
(`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`, `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`).
|
||||
- `platform/gateway/` — the only student-facing path to a Lab: resolves identity from a
|
||||
server-controlled session cookie (not a client-suppliable header/param), proxies
|
||||
server-controlled, persistent session cookie (not a client-suppliable header/param), proxies
|
||||
HTTP/WebSocket into that one student's container, never a raw container IP/port
|
||||
(`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`, session model in
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3).
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` §3).
|
||||
- The student flow (roster login → curriculum dashboard → Launch Lab → Lab session →
|
||||
Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal
|
||||
(cohorts, students, classroom "Prepare Labs," environment controls) lives in
|
||||
`frontend/src/admin/` at the `/admin` route — both small additions alongside the existing,
|
||||
unmodified Lab UI, not a rewrite of it.
|
||||
Reset/Recreate) lives in `frontend/src/platform/`; the instructor/admin portal (per-instructor
|
||||
login, cohorts, students, instructor accounts, audit log, classroom "Prepare Labs,"
|
||||
environment controls) lives in `frontend/src/admin/` at the `/admin` route — both small
|
||||
additions alongside the existing, unmodified Lab UI, not a rewrite of it.
|
||||
|
||||
**Never publish the raw privileged container** — front it with isolation (that's what the
|
||||
Gateway is). The one-container `docker run --privileged -p 7554:80 ...` command above stays
|
||||
@@ -163,16 +164,21 @@ Key properties of the platform (not this image):
|
||||
loses their academic record. Verified with a full class-sized (3-student) cohort workflow:
|
||||
provision, complete different scenarios, reset one student's Lab, destroy all Labs after
|
||||
class — progress survives every step.
|
||||
- Instructors manage cohorts, students, curriculum assignment, and classroom Lab prep
|
||||
(provision an entire cohort before class, verify readiness, destroy Labs after) from
|
||||
`/admin` — no manual database, Docker, or Kubernetes access needed.
|
||||
- Instructors sign in with their own account (no shared password) and manage cohorts, students,
|
||||
curriculum assignment, and classroom Lab prep (provision an entire cohort before class, verify
|
||||
readiness, destroy Labs after) from `/admin` — no manual database, Docker, or Kubernetes
|
||||
access needed. Owners manage instructor accounts and roles; every instructor/admin action is
|
||||
recorded in an audit log.
|
||||
- Sessions (student and instructor) are Postgres-backed and survive a Platform API restart —
|
||||
verified by actually restarting it mid-session and confirming access still works.
|
||||
- CPU/memory/PID/storage limits are enforced per container, config-driven.
|
||||
- Egress is restricted to an allowlisted proxy at the firewall layer, not left open.
|
||||
- Idle/abandoned environments are automatically cleaned up, and a student can launch a fresh
|
||||
one and keep going without losing progress.
|
||||
|
||||
Production topology and what's built vs. not yet: `docs/LAB_ARCHITECTURE.md`,
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`.
|
||||
`docs/PHASE1_PLATFORM_FOUNDATION.md`, `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md`,
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+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.
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
export default function AccountView({ instructor }) {
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try {
|
||||
await adminApi.changePassword(oldPassword, newPassword)
|
||||
setOldPassword(''); setNewPassword('')
|
||||
setNotice('Password changed. Any other sessions you had open elsewhere were signed out.')
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Account</h2>
|
||||
<p className={styles.muted}>{instructor.display_name} · {instructor.email} · role: {instructor.role}</p>
|
||||
|
||||
<h3 style={{ marginTop: '1.5rem' }}>Change password</h3>
|
||||
<form onSubmit={submit} className={styles.formRow}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div><label className={styles.label}>Current password</label>
|
||||
<input className={styles.input} type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} autoComplete="current-password" required /></div>
|
||||
<div><label className={styles.label}>New password (12+ chars)</label>
|
||||
<input className={styles.input} type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="new-password" minLength={12} required /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>{busy ? 'Changing…' : 'Change password'}</button></div>
|
||||
</form>
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,46 +3,66 @@ import { adminApi } from './api'
|
||||
import AdminDashboard from './AdminDashboard'
|
||||
import CohortsView from './CohortsView'
|
||||
import StudentsView from './StudentsView'
|
||||
import InstructorsView from './InstructorsView'
|
||||
import AuditView from './AuditView'
|
||||
import AccountView from './AccountView'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Instructor/Admin portal — clearly separate from the student experience (/admin route,
|
||||
// separate rn_admin session cookie, separate login). A student session cannot reach any of
|
||||
// these views or their underlying /api/admin/* routes — see
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §10.
|
||||
// separate rn_instructor session cookie, separate login). A student session cannot reach any
|
||||
// of these views or their underlying /api/admin/* routes. Per-person accounts replace Phase
|
||||
// 2B's single shared ADMIN_PASSWORD — see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
|
||||
const NAV = [
|
||||
{ key: 'dashboard', label: 'Dashboard' },
|
||||
{ key: 'cohorts', label: 'Cohorts' },
|
||||
{ key: 'students', label: 'Students' },
|
||||
{ key: 'instructors', label: 'Instructors', roles: ['owner'] },
|
||||
{ key: 'audit', label: 'Audit', roles: ['owner', 'admin'] },
|
||||
]
|
||||
|
||||
export default function AdminPortal() {
|
||||
const [authed, setAuthed] = useState(null) // null = checking, false = need login, true = in
|
||||
const [instructor, setInstructor] = useState(null) // null = checking, false = need login, object = signed in
|
||||
const [view, setView] = useState('dashboard')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.whoami().then(() => setAuthed(true)).catch(() => setAuthed(false))
|
||||
adminApi.session().then((r) => setInstructor(r.instructor)).catch(() => setInstructor(false))
|
||||
}, [])
|
||||
|
||||
if (authed === null) return null
|
||||
if (!authed) return <AdminLogin onLogin={() => setAuthed(true)} />
|
||||
if (instructor === null) return null
|
||||
if (!instructor) return <InstructorLogin onLogin={setInstructor} />
|
||||
|
||||
const visibleNav = NAV.filter((item) => !item.roles || item.roles.includes(instructor.role))
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.brand}>⚙ RootNode Admin</div>
|
||||
<nav className={styles.nav}>
|
||||
{['dashboard', 'cohorts', 'students'].map((v) => (
|
||||
<button key={v} className={`${styles.navLink} ${view === v ? styles.navLinkActive : ''}`} onClick={() => setView(v)}>
|
||||
{v[0].toUpperCase() + v.slice(1)}
|
||||
{visibleNav.map((item) => (
|
||||
<button key={item.key} className={`${styles.navLink} ${view === item.key ? styles.navLinkActive : ''}`} onClick={() => setView(item.key)}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setAuthed(false))}>Sign out</button>
|
||||
<button className={`${styles.navLink} ${view === 'account' ? styles.navLinkActive : ''}`} onClick={() => setView('account')}>
|
||||
{instructor.display_name}
|
||||
</button>
|
||||
<button className={styles.navLink} onClick={() => adminApi.logout().then(() => setInstructor(false))}>Sign out</button>
|
||||
</nav>
|
||||
</header>
|
||||
<div className={styles.content}>
|
||||
{view === 'dashboard' && <AdminDashboard />}
|
||||
{view === 'cohorts' && <CohortsView />}
|
||||
{view === 'students' && <StudentsView />}
|
||||
{view === 'cohorts' && <CohortsView role={instructor.role} />}
|
||||
{view === 'students' && <StudentsView role={instructor.role} />}
|
||||
{view === 'instructors' && instructor.role === 'owner' && <InstructorsView currentInstructorId={instructor.instructor_id} />}
|
||||
{view === 'audit' && ['owner', 'admin'].includes(instructor.role) && <AuditView />}
|
||||
{view === 'account' && <AccountView instructor={instructor} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminLogin({ onLogin }) {
|
||||
function InstructorLogin({ onLogin }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -50,9 +70,14 @@ function AdminLogin({ onLogin }) {
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try { await adminApi.login(password); onLogin() }
|
||||
catch (e) { setError('Incorrect password.') }
|
||||
finally { setBusy(false) }
|
||||
try {
|
||||
const { instructor } = await adminApi.login(email, password)
|
||||
onLogin(instructor)
|
||||
} catch (e) {
|
||||
setError(e.message || 'Sign-in failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -61,9 +86,11 @@ function AdminLogin({ onLogin }) {
|
||||
<div className={styles.brand}>⚙ RootNode Admin</div>
|
||||
<p className={styles.subtitle}>Instructor sign-in. Not connected to student accounts.</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<label className={styles.label}>Password</label>
|
||||
<input className={styles.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoFocus />
|
||||
<button className={styles.btn} style={{ marginTop: '1.25rem', width: '100%' }} disabled={busy || !password}>
|
||||
<label className={styles.label}>Email</label>
|
||||
<input className={styles.input} type="email" value={email} onChange={(e) => setEmail(e.target.value)} autoFocus autoComplete="username" />
|
||||
<label className={styles.label} style={{ marginTop: '0.75rem' }}>Password</label>
|
||||
<input className={styles.input} type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
|
||||
<button className={styles.btn} style={{ marginTop: '1.25rem', width: '100%' }} disabled={busy || !email || !password}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// admin/owner only (enforced server-side too). Latest 200 events, no pagination UI — this is a
|
||||
// pilot-scale audit log, not a SIEM; add pagination if that ever stops being true.
|
||||
export default function AuditView() {
|
||||
const [events, setEvents] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.auditEvents().then(setEvents).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<h2>Audit log</h2>
|
||||
<p className={styles.muted}>Most recent 200 security/admin events.</p>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{!events ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>When</th><th>Actor</th><th>Action</th><th>Resource</th><th>Result</th></tr></thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.audit_event_id}>
|
||||
<td>{new Date(e.created_at).toLocaleString()}</td>
|
||||
<td>{e.actor_type}{e.actor_id ? ` · ${e.actor_id.slice(0, 8)}` : ''}</td>
|
||||
<td>{e.action}</td>
|
||||
<td>{e.resource_type ? `${e.resource_type}${e.resource_id ? ` (${e.resource_id})` : ''}` : '—'}</td>
|
||||
<td><span className={`${styles.pill} ${e.result === 'success' ? styles.pillOk : styles.pillErr}`}>{e.result}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,25 +2,28 @@ import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
export default function CohortsView() {
|
||||
export default function CohortsView({ role }) {
|
||||
const [cohorts, setCohorts] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const canManage = role !== 'instructor' // owner/admin only — a plain instructor works within cohorts already assigned to them
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.cohorts().then(setCohorts).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <CohortDetail cohortId={selected} onBack={() => { setSelected(null); reload() }} />
|
||||
if (selected) return <CohortDetail cohortId={selected} canManage={canManage} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.card}>
|
||||
<h2>Create cohort</h2>
|
||||
<CreateCohortForm onCreated={reload} />
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h2>Create cohort</h2>
|
||||
<CreateCohortForm onCreated={reload} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.card}>
|
||||
<h2>Cohorts</h2>
|
||||
{!cohorts ? <p className={styles.muted}>Loading…</p> : cohorts.length === 0 ? <p className={styles.muted}>None yet.</p> : (
|
||||
@@ -79,7 +82,7 @@ function CreateCohortForm({ onCreated }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CohortDetail({ cohortId, onBack }) {
|
||||
function CohortDetail({ cohortId, canManage, onBack }) {
|
||||
const [students, setStudents] = useState(null)
|
||||
const [progress, setProgress] = useState(null)
|
||||
const [prepResults, setPrepResults] = useState(null)
|
||||
@@ -136,10 +139,12 @@ function CohortDetail({ cohortId, onBack }) {
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Roster ({students?.length ?? '…'})</h3>
|
||||
<form onSubmit={addStudent} className={styles.formRow}>
|
||||
<div><input className={styles.input} placeholder="New student name" value={newStudentName} onChange={(e) => setNewStudentName(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>Add student</button></div>
|
||||
</form>
|
||||
{canManage && (
|
||||
<form onSubmit={addStudent} className={styles.formRow}>
|
||||
<div><input className={styles.input} placeholder="New student name" value={newStudentName} onChange={(e) => setNewStudentName(e.target.value)} /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>Add student</button></div>
|
||||
</form>
|
||||
)}
|
||||
{students && (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Status</th><th>Last active</th></tr></thead>
|
||||
@@ -156,11 +161,13 @@ function CohortDetail({ cohortId, onBack }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Curriculum</h3>
|
||||
<p className={styles.muted}>RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)</p>
|
||||
<button className={styles.btnSecondary} onClick={assignCurriculum} disabled={busy}>Assign RootNode Path to cohort</button>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className={styles.card}>
|
||||
<h3>Curriculum</h3>
|
||||
<p className={styles.muted}>RootNode Learning Path (Foundation → Workloads → Networking → Configuration → Troubleshooting → Real Ops → Capstone)</p>
|
||||
<button className={styles.btnSecondary} onClick={assignCurriculum} disabled={busy}>Assign RootNode Path to cohort</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && (
|
||||
<div className={styles.card}>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi } from './api'
|
||||
import styles from './Admin.module.css'
|
||||
|
||||
// Owner-only — the server enforces this too (requireOwner on every /api/admin/instructors*
|
||||
// route); this component is only ever mounted for an owner, but the backend never trusts that.
|
||||
export default function InstructorsView({ currentInstructorId }) {
|
||||
const [instructors, setInstructors] = useState(null)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.instructors().then(setInstructors).catch((e) => setError(e.message))
|
||||
}, [])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
if (selected) return <InstructorDetail instructorId={selected} isSelf={selected === currentInstructorId} onBack={() => { setSelected(null); reload() }} />
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.card}>
|
||||
<h2>Add instructor</h2>
|
||||
<CreateInstructorForm onCreated={reload} />
|
||||
</div>
|
||||
<div className={styles.card}>
|
||||
<h2>Instructors</h2>
|
||||
{!instructors ? <p className={styles.muted}>Loading…</p> : (
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Last login</th><th /></tr></thead>
|
||||
<tbody>
|
||||
{instructors.map((i) => (
|
||||
<tr key={i.instructor_id}>
|
||||
<td>{i.display_name}</td>
|
||||
<td>{i.email}</td>
|
||||
<td><span className={styles.pill}>{i.role}</span></td>
|
||||
<td><span className={`${styles.pill} ${i.status === 'active' ? styles.pillOk : styles.pillMuted}`}>{i.status}</span></td>
|
||||
<td>{i.last_login_at ? new Date(i.last_login_at).toLocaleString() : 'never'}</td>
|
||||
<td><button className={styles.linkBtn} onClick={() => setSelected(i.instructor_id)}>Open</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateInstructorForm({ onCreated }) {
|
||||
const [form, setForm] = useState({ email: '', display_name: '', role: 'instructor', password: '' })
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await adminApi.createInstructor(form)
|
||||
setForm({ email: '', display_name: '', role: 'instructor', password: '' })
|
||||
onCreated()
|
||||
} catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Name</label>
|
||||
<input className={styles.input} value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Email</label>
|
||||
<input className={styles.input} type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /></div>
|
||||
<div><label className={styles.label}>Role</label>
|
||||
<select className={styles.select} value={form.role} onChange={(e) => setForm({ ...form, role: e.target.value })}>
|
||||
<option value="instructor">instructor</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="owner">owner</option>
|
||||
</select></div>
|
||||
<div><label className={styles.label}>Temporary password (12+ chars)</label>
|
||||
<input className={styles.input} type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={12} /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>{busy ? 'Adding…' : 'Add instructor'}</button></div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function InstructorDetail({ instructorId, isSelf, onBack }) {
|
||||
const [instructor, setInstructor] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
|
||||
const reload = useCallback(() => {
|
||||
adminApi.instructor(instructorId).then(setInstructor).catch((e) => setError(e.message))
|
||||
}, [instructorId])
|
||||
useEffect(reload, [reload])
|
||||
|
||||
const updateRole = async (role) => {
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await adminApi.updateInstructor(instructorId, { role }); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const toggleStatus = async () => {
|
||||
const status = instructor.status === 'active' ? 'inactive' : 'active'
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await adminApi.updateInstructor(instructorId, { status }); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const revokeSessions = async () => {
|
||||
setBusy(true); setError(null)
|
||||
try { await adminApi.revokeInstructorSessions(instructorId); setNotice('All sessions revoked.'); reload() }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
const resetPassword = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null); setNotice(null)
|
||||
try { await adminApi.resetInstructorPassword(instructorId, newPassword); setNewPassword(''); setNotice('Password reset. All of their sessions were revoked.') }
|
||||
catch (e) { setError(e.message) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!instructor) return error ? <div className={styles.errorBox}>{error}</div> : <p className={styles.muted}>Loading…</p>
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={styles.linkBtn} onClick={onBack}>← Back to instructors</button>
|
||||
<h2 style={{ margin: '1rem 0' }}>{instructor.display_name} <span className={styles.muted}>({instructor.email})</span></h2>
|
||||
{error && <div className={styles.errorBox}>{error}</div>}
|
||||
{notice && <p className={styles.muted}>{notice}</p>}
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Role & status</h3>
|
||||
<div className={styles.formRow}>
|
||||
<div><label className={styles.label}>Role</label>
|
||||
<select className={styles.select} value={instructor.role} onChange={(e) => updateRole(e.target.value)} disabled={busy}>
|
||||
<option value="instructor">instructor</option>
|
||||
<option value="admin">admin</option>
|
||||
<option value="owner">owner</option>
|
||||
</select></div>
|
||||
<div><button className={instructor.status === 'active' ? styles.btnDanger : styles.btnSecondary} onClick={toggleStatus} disabled={busy}>
|
||||
{instructor.status === 'active' ? 'Deactivate' : 'Activate'}
|
||||
</button></div>
|
||||
</div>
|
||||
{isSelf && <p className={styles.muted}>This is your own account — the platform refuses to remove the last active owner, whichever account that is.</p>}
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Sessions</h3>
|
||||
<p className={styles.muted}>Last login: {instructor.last_login_at ? new Date(instructor.last_login_at).toLocaleString() : 'never'}</p>
|
||||
{instructor.sessions?.length ? (
|
||||
<table>
|
||||
<thead><tr><th>Created</th><th>Last active</th><th>Expires</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{instructor.sessions.map((s) => (
|
||||
<tr key={s.session_id}>
|
||||
<td>{new Date(s.created_at).toLocaleString()}</td>
|
||||
<td>{new Date(s.last_seen_at).toLocaleString()}</td>
|
||||
<td>{new Date(s.expires_at).toLocaleString()}</td>
|
||||
<td>{s.revoked_at ? <span className={`${styles.pill} ${styles.pillMuted}`}>revoked</span> : <span className={`${styles.pill} ${styles.pillOk}`}>active</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <p className={styles.muted}>No sessions on record.</p>}
|
||||
<button className={styles.btnDanger} style={{ marginTop: '0.75rem' }} onClick={revokeSessions} disabled={busy}>Revoke all sessions</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.card}>
|
||||
<h3>Reset password</h3>
|
||||
<form onSubmit={resetPassword} className={styles.formRow}>
|
||||
<div><input className={styles.input} type="password" placeholder="New password (12+ chars)" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} minLength={12} required /></div>
|
||||
<div><button className={styles.btn} disabled={busy}>Set new password</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,17 @@
|
||||
// Admin API client. Uses the rn_admin session cookie exclusively (same-origin, sent
|
||||
// Admin API client. Uses the rn_instructor session cookie exclusively (same-origin, sent
|
||||
// automatically) — completely separate credential/cookie from student sessions (see
|
||||
// platform/api/src/sessions.js). No admin data is ever cached client-side beyond the current
|
||||
// React state; nothing here reads or writes localStorage.
|
||||
//
|
||||
// rn_csrf_i is a deliberately non-HttpOnly cookie (double-submit CSRF pattern, mirrored on the
|
||||
// student side in frontend/src/platform/api.js) — read here and echoed back as a header on
|
||||
// every state-changing request.
|
||||
|
||||
const CSRF_COOKIE = 'rn_csrf_i'
|
||||
|
||||
function getCookie(name) {
|
||||
return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1]
|
||||
}
|
||||
|
||||
async function json(res) {
|
||||
if (!res.ok) {
|
||||
@@ -11,23 +21,30 @@ async function json(res) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function withCsrf(init) {
|
||||
return { ...init, headers: { ...init.headers, 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' } }
|
||||
}
|
||||
function post(path, body) {
|
||||
return fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(json)
|
||||
return fetch(path, withCsrf({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })).then(json)
|
||||
}
|
||||
function patch(path, body) {
|
||||
return fetch(path, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(json)
|
||||
return fetch(path, withCsrf({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })).then(json)
|
||||
}
|
||||
function del(path) {
|
||||
return fetch(path, { method: 'DELETE' }).then(json)
|
||||
return fetch(path, withCsrf({ method: 'DELETE' })).then(json)
|
||||
}
|
||||
function get(path) {
|
||||
return fetch(path).then(json)
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
login: (password) => post('/api/admin/login', { password }),
|
||||
logout: () => post('/api/admin/logout'),
|
||||
whoami: () => get('/api/admin/whoami'),
|
||||
// No CSRF token yet at login (nothing authenticated to forge into).
|
||||
login: (email, password) => fetch('/api/auth/instructor/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }),
|
||||
}).then(json),
|
||||
logout: () => post('/api/auth/logout'),
|
||||
session: () => get('/api/auth/session'),
|
||||
changePassword: (old_password, new_password) => post('/api/auth/change-password', { old_password, new_password }),
|
||||
|
||||
dashboard: () => get('/api/admin/dashboard'),
|
||||
|
||||
@@ -47,10 +64,20 @@ export const adminApi = {
|
||||
cohortStudents: (id) => get(`/api/admin/cohorts/${id}/students`),
|
||||
cohortProgress: (id) => get(`/api/admin/cohorts/${id}/progress`),
|
||||
assignCohortCurriculum: (id, curriculum_path) => post(`/api/admin/cohorts/${id}/curriculum`, { curriculum_path }),
|
||||
assignCohortInstructor: (id, instructor_id, role_in_cohort) => post(`/api/admin/cohorts/${id}/instructors`, { instructor_id, role_in_cohort }),
|
||||
prepareLabs: (id) => post(`/api/admin/cohorts/${id}/prepare-labs`),
|
||||
destroyLabs: (id) => post(`/api/admin/cohorts/${id}/destroy-labs`),
|
||||
|
||||
resetEnvironment: (id) => post(`/api/admin/environments/${id}/reset`),
|
||||
destroyEnvironment: (id) => del(`/api/admin/environments/${id}`),
|
||||
environmentEvents: (id) => get(`/api/admin/environments/${id}/events`),
|
||||
|
||||
instructors: () => get('/api/admin/instructors'),
|
||||
createInstructor: (fields) => post('/api/admin/instructors', fields),
|
||||
instructor: (id) => get(`/api/admin/instructors/${id}`),
|
||||
updateInstructor: (id, fields) => patch(`/api/admin/instructors/${id}`, fields),
|
||||
revokeInstructorSessions: (id) => post(`/api/admin/instructors/${id}/revoke-sessions`),
|
||||
resetInstructorPassword: (id, new_password) => post(`/api/admin/instructors/${id}/reset-password`, { new_password }),
|
||||
|
||||
auditEvents: () => get('/api/admin/audit-events'),
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
// Platform Shell API client. Phase 2B: identity is a server-controlled session cookie
|
||||
// (rn_session, HttpOnly — this code can't read it, and doesn't need to; the browser sends it
|
||||
// automatically on every same-origin request). This replaces Phase 2A's localStorage
|
||||
// student_id + X-Student-Id header, which was explicitly pilot-only and client-forgeable.
|
||||
// See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
|
||||
// Platform Shell API client. Identity is a server-controlled session cookie (rn_session,
|
||||
// HttpOnly — this code can't read it, and doesn't need to; the browser sends it automatically
|
||||
// on every same-origin request). Roster name-picking is classroom/pilot identification, not
|
||||
// production authentication (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5) — what's
|
||||
// hardened here is the session that results from it: persistent (survives an API restart),
|
||||
// unforgeable, and checked on every request.
|
||||
//
|
||||
// display_name is still cached in sessionStorage purely for UI convenience (showing "Hi,
|
||||
// Alice" without an extra round trip) — it carries no authority; every actual API call is
|
||||
// authorized by the cookie alone, not by anything read from here.
|
||||
//
|
||||
// rn_csrf_s is a deliberately non-HttpOnly cookie (double-submit CSRF pattern) — this code
|
||||
// reads it and echoes it back as a header on every state-changing request, proving the request
|
||||
// came from our own origin's JS (a cross-origin attacker can trigger the cookie but can't read
|
||||
// its value or attach a matching custom header without CORS, which we don't grant elsewhere).
|
||||
|
||||
const DISPLAY_CACHE_KEY = 'rootnode_student_display'
|
||||
const CSRF_COOKIE = 'rn_csrf_s'
|
||||
|
||||
export function getCachedDisplay() {
|
||||
try { return JSON.parse(sessionStorage.getItem(DISPLAY_CACHE_KEY) || 'null') } catch { return null }
|
||||
@@ -20,6 +27,10 @@ function clearCachedDisplay() {
|
||||
sessionStorage.removeItem(DISPLAY_CACHE_KEY)
|
||||
}
|
||||
|
||||
function getCookie(name) {
|
||||
return document.cookie.split('; ').find((c) => c.startsWith(`${name}=`))?.split('=')[1]
|
||||
}
|
||||
|
||||
async function json(res) {
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
@@ -28,10 +39,20 @@ async function json(res) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function mutate(path, body) {
|
||||
return fetch(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie(CSRF_COOKIE) || '' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
}).then(json)
|
||||
}
|
||||
|
||||
export const api = {
|
||||
cohorts: () => fetch('/api/roster/cohorts').then(json),
|
||||
roster: (cohort) => fetch(`/api/roster?cohort=${encodeURIComponent(cohort)}`).then(json),
|
||||
login: async (student_id) => {
|
||||
// No CSRF token yet at login (nothing authenticated to forge into) — same reasoning as the
|
||||
// instructor login route.
|
||||
const student = await fetch('/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ student_id }),
|
||||
}).then(json)
|
||||
@@ -39,7 +60,7 @@ export const api = {
|
||||
return student
|
||||
},
|
||||
logout: async () => {
|
||||
await fetch('/api/logout', { method: 'POST' }).catch(() => {})
|
||||
await mutate('/api/logout').catch(() => {})
|
||||
clearCachedDisplay()
|
||||
},
|
||||
whoami: () => fetch('/api/session/whoami').then(json),
|
||||
@@ -47,6 +68,6 @@ export const api = {
|
||||
curriculumPath: () => fetch('/api/curriculum/path').then(json),
|
||||
|
||||
environmentStatus: () => fetch('/api/environment/status').then(json),
|
||||
launchEnvironment: () => fetch('/api/environment/launch', { method: 'POST' }).then(json),
|
||||
resetEnvironment: () => fetch('/api/environment/reset', { method: 'POST' }).then(json),
|
||||
launchEnvironment: () => mutate('/api/environment/launch'),
|
||||
resetEnvironment: () => mutate('/api/environment/reset'),
|
||||
}
|
||||
|
||||
+26
-14
@@ -7,13 +7,15 @@ Start here, then go to the component you need:
|
||||
`docs/LAB_ARCHITECTURE.md`, `docs/PHASE1_PLATFORM_FOUNDATION.md`. Operational reference:
|
||||
`provisioner/README.md`.
|
||||
- `api/` — the lightweight Platform API: students, cohorts, enrollments, curriculum, progress,
|
||||
scenario attempts, student/admin sessions, and the instructor/admin control plane. No k3s, no
|
||||
`--privileged` — this replaced Phase 2A's "identity" instance, which was a full Lab image
|
||||
booting an unused k3s cluster just to serve these APIs. Design:
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md`. Operational reference: `api/README.md`.
|
||||
scenario attempts, per-instructor accounts, persistent student/instructor sessions, RBAC,
|
||||
audit logging, and the instructor/admin control plane. No k3s, no `--privileged` — this
|
||||
replaced Phase 2A's "identity" instance, which was a full Lab image booting an unused k3s
|
||||
cluster just to serve these APIs. Design: `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` (split +
|
||||
Postgres migration), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (sessions, accounts,
|
||||
RBAC, audit). Operational reference: `api/README.md`.
|
||||
- `gateway/` — the only student-facing path to a Lab (HTTP + WebSocket proxy, environment-aware
|
||||
routing derived from a server-controlled session — not a client-suppliable header/param).
|
||||
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §3 (session ownership).
|
||||
Design: `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` (routing), `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (persistent sessions, CSRF).
|
||||
- `egress-proxy/` — shared allowlisting proxy every Lab's outbound traffic passes through.
|
||||
- `docker-compose.dev.yml` — the full stack (Postgres, egress proxy, Provisioner, Platform API,
|
||||
Gateway) on one machine. This is the Phase 1/2A/2B **local dev exception** — production is
|
||||
@@ -24,14 +26,20 @@ Start here, then go to the component you need:
|
||||
```bash
|
||||
cd platform
|
||||
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
|
||||
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
|
||||
docker compose -f docker-compose.dev.yml build
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# First run only — create the owner account (no ADMIN_PASSWORD anymore, see
|
||||
# docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md):
|
||||
cd api
|
||||
PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus"
|
||||
```
|
||||
|
||||
No k3s boot to wait for at this layer anymore (Platform API starts instantly) — the whole
|
||||
platform is reachable at `http://localhost:4200` (the Gateway) right away: students at `/`,
|
||||
instructors at `/admin`.
|
||||
instructors at `/admin` (sign in with the owner account you just bootstrapped, then create
|
||||
further instructor accounts from the Instructors tab).
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -42,16 +50,20 @@ instructors at `/admin`.
|
||||
- `test/e2e-phase2b.js` — the classroom acceptance workflow: instructor creates a cohort, adds
|
||||
students, assigns curriculum, Prepare Labs (concurrent), students complete different
|
||||
scenarios, instructor resets one, instructor destroys all cohort Labs after class — progress
|
||||
survives every step. Needs `ADMIN_PASSWORD` set (see `platform/.env`).
|
||||
survives every step.
|
||||
- `test/e2e-phase3a.js` — persistent sessions (including a real Platform API restart), instructor
|
||||
auth (no enumeration, rate limiting), RBAC across owner/admin/instructor, cohort-scoped
|
||||
instructor access, CSRF enforcement, password change, and audit logging.
|
||||
|
||||
Both e2e suites are slow (real k3s boots) and need the full stack up. Neither substitutes for
|
||||
the manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation)
|
||||
in `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
|
||||
All three e2e suites need `OWNER_EMAIL` / `OWNER_PASSWORD` set to a real bootstrapped instructor
|
||||
account (see "Running the full stack locally" above) — the old `ADMIN_PASSWORD` mechanism is
|
||||
gone. They're slow (real k3s boots) and need the full stack up. None of them substitute for the
|
||||
manual real-Lab-image checks (node-pty, terminal WebSocket, `kubectl`, scenario validation) in
|
||||
`docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §8 — those need a browser-equivalent client and
|
||||
aren't automated here.
|
||||
|
||||
## Known limitations
|
||||
|
||||
See `provisioner/README.md` (Phase 1), `docs/PHASE2A_GATEWAY_AND_STUDENT_FLOW.md` §9 (Phase
|
||||
2A), and `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 (Phase 2B) for the full, honest lists —
|
||||
including in-memory sessions, the single shared admin credential, and exam mode's live
|
||||
bookkeeping staying Lab-local.
|
||||
2A), and `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` (Phase 3A, supersedes Phase 2B's
|
||||
identity limitations) for the full, honest lists.
|
||||
|
||||
+41
-15
@@ -1,7 +1,9 @@
|
||||
# RootNode Academy — Platform API (Phase 2B)
|
||||
# RootNode Academy — Platform API (Phase 3A)
|
||||
|
||||
Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the design; this is the operational reference.
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` for the original split/migration design and
|
||||
`docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` for persistent sessions, per-instructor
|
||||
accounts, RBAC, and audit logging — this file is the operational reference.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
@@ -10,25 +12,46 @@ Lightweight service: no k3s, no `--privileged`, no Lab terminal runtime. See
|
||||
| `PLATFORM_DB_URL` | no (defaults to local dev Postgres) | Postgres connection string |
|
||||
| `PROVISIONER_URL` | no | Provisioner base URL |
|
||||
| `PROVISIONER_SHARED_SECRET` | **yes** | Internal Provisioner auth — process refuses to start without it |
|
||||
| `ADMIN_PASSWORD` | **yes** | Instructor login credential — process refuses to start without it. Verified via scrypt+timing-safe compare, never stored or logged in plaintext |
|
||||
| `PLATFORM_API_PORT` | no | Listen port, default 4300 |
|
||||
| `FRONTEND_DIST_DIR` | no | Where the built frontend lives — set by the Dockerfile, override for local non-Docker dev |
|
||||
| `SCENARIOS_DIR` / `BUNDLES_DIR` / `CURRICULUM_PATH_FILE` | no | Override the default `scenarios/` locations, mainly for local dev outside Docker |
|
||||
| `STUDENT_SESSION_TTL_HOURS` / `STUDENT_SESSION_IDLE_HOURS` | no | Default 8h / 2h |
|
||||
| `INSTRUCTOR_SESSION_TTL_HOURS` / `INSTRUCTOR_SESSION_IDLE_HOURS` | no | Default 12h / 2h |
|
||||
| `COOKIE_SECURE` | no | Set to `1` once served over real HTTPS — adds `Secure` to every session/CSRF cookie |
|
||||
| `TRUST_PROXY` | no | Set to `1` once fronted by a trusted reverse proxy — honors `X-Forwarded-For` for `req.ip` (audit `source_ip`, login rate limiting) |
|
||||
|
||||
There is no `ADMIN_PASSWORD` anymore — instructor accounts live in Postgres (`instructors`
|
||||
table), created via `bootstrap-owner.js` for the first one and the admin portal after that.
|
||||
|
||||
## Local dev
|
||||
|
||||
```bash
|
||||
cd platform
|
||||
echo "PROVISIONER_SHARED_SECRET=$(openssl rand -hex 32)" > .env
|
||||
echo "ADMIN_PASSWORD=$(openssl rand -hex 12)" >> .env
|
||||
docker compose -f docker-compose.dev.yml build
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
cd api
|
||||
PLATFORM_DB_URL=postgres://postgres:postgres@localhost:5433/rootnode_platform \
|
||||
node bootstrap-owner.js [email protected] "Your Name" "a-real-password-12-chars-plus"
|
||||
```
|
||||
|
||||
Everything is reachable through the Gateway at `http://localhost:4200` — students at `/`,
|
||||
instructors at `/admin`. Platform API itself isn't meant to be hit directly by a browser (same
|
||||
boundary as the Provisioner relative to Platform API), though nothing stops you from curling
|
||||
it directly at `:4300` for local debugging.
|
||||
instructors at `/admin` (sign in with the bootstrapped owner). Platform API itself isn't meant
|
||||
to be hit directly by a browser (same boundary as the Provisioner relative to Platform API),
|
||||
though nothing stops you from curling it directly at `:4300` for local debugging.
|
||||
|
||||
## Owner bootstrap
|
||||
|
||||
```bash
|
||||
node bootstrap-owner.js <email> <display_name> [password]
|
||||
```
|
||||
|
||||
Prefer `BOOTSTRAP_OWNER_PASSWORD` over the positional argument outside a throwaway local test —
|
||||
CLI args can leak into shell history and `ps`. Refuses to run if an active owner already exists
|
||||
unless the target email belongs to an existing instructor (the intended recovery path if a
|
||||
platform is ever locked out with zero owners: point this at a known instructor account to
|
||||
promote them).
|
||||
|
||||
## SQLite migration
|
||||
|
||||
@@ -42,17 +65,20 @@ docker run --rm -v <identity-data-volume>:/data \
|
||||
|
||||
Always backs up the SQLite file first, is safe to re-run, and writes a JSON report — see
|
||||
`docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §2/§3 for the full guarantees and rollback
|
||||
procedure.
|
||||
procedure. This step is about academic data (students/cohorts/progress) — it has nothing to do
|
||||
with instructor accounts, which are Phase 3A-native and start empty until you bootstrap one.
|
||||
|
||||
## Tests
|
||||
|
||||
`platform/test/e2e-phase2a.js` (student-flow regression, cookie-based) and
|
||||
`platform/test/e2e-phase2b.js` (the classroom acceptance workflow: cohort → students →
|
||||
curriculum → Prepare Labs → complete scenarios → reset → destroy-labs → progress survives).
|
||||
Both need the full stack up and `ADMIN_PASSWORD` set — see `platform/README.md`.
|
||||
`platform/test/e2e-phase2a.js` (student-flow regression), `platform/test/e2e-phase2b.js` (the
|
||||
classroom acceptance workflow), and `platform/test/e2e-phase3a.js` (persistent sessions
|
||||
including a real restart, instructor auth, RBAC, cohort scoping, CSRF, audit log). All three
|
||||
need the full stack up and `OWNER_EMAIL` / `OWNER_PASSWORD` set to a bootstrapped account — see
|
||||
`platform/README.md`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
See `docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md` §13 — in-memory sessions (a restart logs
|
||||
everyone out), exam mode's live bookkeeping stays Lab-local, single shared admin credential
|
||||
rather than per-instructor accounts.
|
||||
See `docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md` "Known limitations" — sessions are
|
||||
Postgres-backed (survive a restart) but there's no distributed revocation channel to the
|
||||
Gateway's short-lived whoami cache, exam mode's live bookkeeping stays Lab-local, and OIDC/SSO
|
||||
is a documented adapter interface, not a working integration yet.
|
||||
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
// One-time owner bootstrap — the only supported way to create the first instructor account,
|
||||
// so standing up a fresh Platform API never requires editing Postgres by hand.
|
||||
//
|
||||
// node bootstrap-owner.js [email protected] "Your Name" [password]
|
||||
//
|
||||
// Prefer BOOTSTRAP_OWNER_PASSWORD over the CLI argument in anything other than a throwaway
|
||||
// local test — CLI args can leak into shell history and `ps`.
|
||||
//
|
||||
// Refuses to run if an active owner already exists, UNLESS the target email belongs to an
|
||||
// existing instructor — in that case it promotes them to owner (the intended recovery path if
|
||||
// a platform is ever locked out with zero owners: point this at a known instructor account).
|
||||
|
||||
const { getPool, runMigrations } = require('./src/db');
|
||||
const { hashPassword } = require('./src/auth/password');
|
||||
|
||||
async function main() {
|
||||
const [, , email, displayName, cliPassword] = process.argv;
|
||||
const password = cliPassword || process.env.BOOTSTRAP_OWNER_PASSWORD;
|
||||
if (!email || !displayName || !password) {
|
||||
console.error('Usage: node bootstrap-owner.js <email> <display_name> [password]');
|
||||
console.error(' (or set BOOTSTRAP_OWNER_PASSWORD instead of passing the password)');
|
||||
process.exit(1);
|
||||
}
|
||||
if (password.length < 12) {
|
||||
console.error('Password must be at least 12 characters.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await runMigrations();
|
||||
const pool = getPool();
|
||||
const emailLower = email.toLowerCase().trim();
|
||||
|
||||
const { rows: existing } = await pool.query(`SELECT instructor_id, role, status FROM instructors WHERE email = $1`, [emailLower]);
|
||||
const { rows: ownerCount } = await pool.query(`SELECT count(*)::int AS n FROM instructors WHERE role = 'owner' AND status = 'active'`);
|
||||
|
||||
if (ownerCount[0].n > 0 && !existing[0]) {
|
||||
console.error(`Refusing: ${ownerCount[0].n} active owner(s) already exist and ${emailLower} isn't one of your instructors.`);
|
||||
console.error('Use the admin API (as an existing owner) to add instructors instead.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = hashPassword(password);
|
||||
let instructorId;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
if (existing[0]) {
|
||||
await client.query(
|
||||
`UPDATE instructors SET password_hash = $1, display_name = $2, role = 'owner', status = 'active', updated_at = now() WHERE instructor_id = $3`,
|
||||
[passwordHash, displayName.trim(), existing[0].instructor_id]
|
||||
);
|
||||
instructorId = existing[0].instructor_id;
|
||||
} else {
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO instructors (email, display_name, password_hash, role, status) VALUES ($1, $2, $3, 'owner', 'active') RETURNING instructor_id`,
|
||||
[emailLower, displayName.trim(), passwordHash]
|
||||
);
|
||||
instructorId = rows[0].instructor_id;
|
||||
await client.query(
|
||||
`INSERT INTO auth_identities (instructor_id, provider, provider_subject, provider_email) VALUES ($1, 'local', $2, $3)`,
|
||||
[instructorId, String(instructorId), emailLower]
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
|
||||
console.log(`Owner ready: ${emailLower} (${instructorId})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('FATAL:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,20 @@
|
||||
-- instructors — replaces the single shared ADMIN_PASSWORD (Phase 2B) with real per-person
|
||||
-- accounts. password_hash is "<salt-hex>:<hash-hex>" from scrypt (see src/auth/password.js),
|
||||
-- NULL only if an instructor is ever external-auth-only (future OIDC, see auth_identities).
|
||||
--
|
||||
-- Roles are a plain CHECK, not a lookup table — three roles, not expected to churn often, and
|
||||
-- a CHECK is enough to keep bad data out without a join on every permission check.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS instructors (
|
||||
instructor_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE, -- always stored lowercased by the application
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'instructor' CHECK (role IN ('owner', 'admin', 'instructor')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_instructors_role_status ON instructors(role, status);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- auth_identities — lets a local-password identity and a future OIDC identity map to the same
|
||||
-- instructor record without a schema change when OIDC lands. Every instructor gets a 'local'
|
||||
-- row today (provider_subject = their own instructor_id as text); a Google/OIDC login later
|
||||
-- just adds a second row for the same instructor_id. See
|
||||
-- docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for the adapter interface this backs.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_identities (
|
||||
auth_identity_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
instructor_id UUID NOT NULL REFERENCES instructors(instructor_id) ON DELETE CASCADE,
|
||||
provider TEXT NOT NULL, -- 'local' | 'google' | 'oidc:<name>'
|
||||
provider_subject TEXT NOT NULL,
|
||||
provider_email TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
UNIQUE (provider, provider_subject)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_identities_instructor ON auth_identities(instructor_id);
|
||||
@@ -0,0 +1,35 @@
|
||||
-- sessions — replaces Phase 2B's in-memory studentSessions/adminSessions Maps. A Platform API
|
||||
-- restart no longer logs everyone out, and sessions are individually revocable/auditable.
|
||||
--
|
||||
-- Only a SHA-256 hash of the opaque bearer token is stored (token_hash) — never the raw token
|
||||
-- itself, so a database read (backup, dump, compromised replica) can't be turned into working
|
||||
-- session cookies.
|
||||
--
|
||||
-- expires_at is the absolute ceiling (set once, at creation). idle_expires_at slides forward on
|
||||
-- every authenticated request (see src/sessions.js resolve*Session), capped at expires_at —
|
||||
-- whichever of the two is hit first ends the session. session_type distinguishes student vs.
|
||||
-- instructor sessions; the CHECK below keeps a session row from ever being ambiguous about
|
||||
-- which kind it is, so a bug elsewhere can't accidentally treat one as the other.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_type TEXT NOT NULL CHECK (session_type IN ('student', 'instructor')),
|
||||
student_id TEXT REFERENCES students(student_id),
|
||||
instructor_id UUID REFERENCES instructors(instructor_id),
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
idle_expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
metadata JSONB,
|
||||
CHECK (
|
||||
(session_type = 'student' AND student_id IS NOT NULL AND instructor_id IS NULL) OR
|
||||
(session_type = 'instructor' AND instructor_id IS NOT NULL AND student_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_instructor ON sessions(instructor_id) WHERE instructor_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_student ON sessions(student_id) WHERE student_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expires_at);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- cohort_instructors — scopes the 'instructor' role to specific cohorts. 'owner' and 'admin'
|
||||
-- are never checked against this table (they have global teaching visibility by role alone,
|
||||
-- see src/rbac.js) — this only matters for the plain 'instructor' role.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cohort_instructors (
|
||||
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
|
||||
instructor_id UUID NOT NULL REFERENCES instructors(instructor_id),
|
||||
role_in_cohort TEXT NOT NULL DEFAULT 'teacher',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (cohort_id, instructor_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cohort_instructors_instructor ON cohort_instructors(instructor_id);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- audit_events — platform-level security/admin audit log, distinct from the Provisioner's
|
||||
-- environment_events (which is Lab-lifecycle-only). Never stores plaintext passwords, raw
|
||||
-- session tokens, Provisioner secrets, or raw routing tokens — see src/audit.js callers.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
audit_event_id BIGSERIAL PRIMARY KEY,
|
||||
actor_type TEXT NOT NULL, -- 'instructor' | 'student' | 'system'
|
||||
actor_id TEXT, -- instructor_id or student_id as text; NULL for e.g. a failed login with no matching account
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
result TEXT NOT NULL, -- 'success' | 'failure'
|
||||
source_ip TEXT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_events(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor_type, actor_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
|
||||
@@ -6,7 +6,8 @@
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"migrate-sqlite": "node migrate-sqlite.js"
|
||||
"migrate-sqlite": "node migrate-sqlite.js",
|
||||
"bootstrap-owner": "node bootstrap-owner.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
// Admin password verification. Phase 2B scope, explicitly not production SSO/OIDC (that's
|
||||
// Phase 3+, documented in docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md).
|
||||
//
|
||||
// ADMIN_PASSWORD is read from the environment (the same secret-injection channel every other
|
||||
// component here uses — PROVISIONER_SHARED_SECRET, etc. — not a new pattern). It is never
|
||||
// stored in a database or logged. Comparison is done via scrypt + a timing-safe compare rather
|
||||
// than a plain string comparison, so a login attempt never directly compares raw password
|
||||
// bytes (defense in depth, cheap to do correctly with only Node's stdlib — no new dependency).
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||||
const SALT = 'rootnode-admin-fixed-salt-v1'; // fixed salt is fine here: single credential, not
|
||||
// a multi-user password table needing per-row salts
|
||||
|
||||
if (!ADMIN_PASSWORD) {
|
||||
console.error('FATAL: ADMIN_PASSWORD is not set. Refusing to start with no way to authenticate an instructor.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const expectedHash = crypto.scryptSync(ADMIN_PASSWORD, SALT, 64);
|
||||
|
||||
function verifyAdminPassword(submitted) {
|
||||
if (!submitted || typeof submitted !== 'string') return false;
|
||||
const submittedHash = crypto.scryptSync(submitted, SALT, 64);
|
||||
return crypto.timingSafeEqual(expectedHash, submittedHash);
|
||||
}
|
||||
|
||||
module.exports = { verifyAdminPassword };
|
||||
@@ -1,50 +1,71 @@
|
||||
// Instructor/Admin API — /api/admin/*. Every route here requires an admin session
|
||||
// (adminSession middleware), completely separate from student sessions (different cookie,
|
||||
// different token namespace, see sessions.js). Student sessions can never call these routes;
|
||||
// admin sessions can never be used for /api/environment/* student routes either, since those
|
||||
// require a *student* session specifically.
|
||||
// Instructor/Admin API — /api/admin/*. Every route requires an instructor session
|
||||
// (instructorSession) plus a role check (rbac.js) — completely separate from student sessions
|
||||
// (different cookie, different token namespace, see sessions.js). Student sessions can never
|
||||
// call these routes.
|
||||
//
|
||||
// This module owns every instructor-facing Provisioner interaction (prepare-labs, reset,
|
||||
// destroy, events) — the Admin UI never talks to the Provisioner directly, same boundary as
|
||||
// the student path (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §12).
|
||||
// Permission shape (see docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for the full matrix):
|
||||
// owner — everything, including instructor account lifecycle and audit log.
|
||||
// admin — cohorts/students/curriculum/labs, global teaching visibility. No instructor
|
||||
// account administration.
|
||||
// instructor — same operational actions, but scoped to cohorts they're assigned to via
|
||||
// cohort_instructors (requireCohortAccess / requireStudentCohortAccess).
|
||||
//
|
||||
// This module still owns every instructor-facing Provisioner interaction (prepare-labs, reset,
|
||||
// destroy, events) — the Admin UI never talks to the Provisioner directly.
|
||||
|
||||
const { listInstructors, createInstructor, assertNotLastOwner } = require('./instructors');
|
||||
|
||||
module.exports = function registerAdminRoutes(app, deps) {
|
||||
const { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress } = deps;
|
||||
const { instructorSession, rbac, provisioner, curriculum, getPool, loadProgress, recordAudit } = deps;
|
||||
const { requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess } = rbac;
|
||||
|
||||
// ── Admin auth ───────────────────────────────────────────────────────────────────────
|
||||
app.post('/api/admin/login', (req, res) => {
|
||||
const { password } = req.body || {};
|
||||
if (!verifyAdminPassword(password)) return res.status(401).json({ error: 'invalid_credentials' });
|
||||
const token = sessions.createAdminSession();
|
||||
res.append('Set-Cookie', `${ADMIN_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=14400`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin/logout', (req, res) => {
|
||||
const header = req.headers.cookie || '';
|
||||
const token = Object.fromEntries(header.split(';').map((p) => p.trim().split('=')))[ADMIN_COOKIE];
|
||||
sessions.destroyAdminSession(token);
|
||||
res.append('Set-Cookie', `${ADMIN_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/admin/whoami', adminSession, (req, res) => res.json({ admin: true }));
|
||||
|
||||
const admin = express_router_guard(app, adminSession);
|
||||
// Tiny helper: every admin.<verb> call goes through instructorSession first, then whatever
|
||||
// role/scope middleware is passed, then the handler (last function in the list — auto-wrapped
|
||||
// so a thrown/rejected error reaches Express's error handler instead of crashing the process,
|
||||
// same as every other async handler in this Express 4 app).
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => {
|
||||
try {
|
||||
const result = fn(req, res, next);
|
||||
if (result && typeof result.catch === 'function') result.catch(next);
|
||||
} catch (err) { next(err); }
|
||||
};
|
||||
}
|
||||
const admin = {
|
||||
get: (p, ...mw) => app.get(p, instructorSession, ...mw.map(wrap)),
|
||||
post: (p, ...mw) => app.post(p, instructorSession, ...mw.map(wrap)),
|
||||
patch: (p, ...mw) => app.patch(p, instructorSession, ...mw.map(wrap)),
|
||||
delete: (p, ...mw) => app.delete(p, instructorSession, ...mw.map(wrap)),
|
||||
};
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/dashboard', async (req, res) => {
|
||||
// Global for owner/admin. A plain 'instructor' only sees stats for cohorts they're assigned
|
||||
// to — an empty cohort_instructors row set means an empty (not global) dashboard, not a 403,
|
||||
// since the dashboard itself isn't a sensitive resource, just its contents.
|
||||
admin.get('/api/admin/dashboard', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const scoped = req.instructor.role === 'instructor';
|
||||
const cohortFilter = scoped
|
||||
? `AND e.cohort_id IN (SELECT cohort_id FROM cohort_instructors WHERE instructor_id = '${req.instructor.instructor_id}')`
|
||||
: '';
|
||||
// (string-interpolated only for a UUID we already validated came from req.instructor, never
|
||||
// from request input — every other query below stays parameterized as usual.)
|
||||
const [cohorts, students, envs] = await Promise.all([
|
||||
pool.query(`SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`),
|
||||
pool.query(`SELECT count(*)::int AS n FROM students WHERE status = 'active'`),
|
||||
pool.query(scoped
|
||||
? `SELECT count(DISTINCT c.cohort_id)::int AS n FROM cohorts c JOIN cohort_instructors ci ON ci.cohort_id = c.cohort_id WHERE c.status = 'active' AND ci.instructor_id = $1`
|
||||
: `SELECT count(*)::int AS n FROM cohorts WHERE status = 'active'`, scoped ? [req.instructor.instructor_id] : []),
|
||||
scoped
|
||||
? pool.query(`SELECT count(DISTINCT e.student_id)::int AS n FROM cohort_enrollments e JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled'`, [req.instructor.instructor_id])
|
||||
: pool.query(`SELECT count(*)::int AS n FROM students WHERE status = 'active'`),
|
||||
pool.query(`SELECT status, count(*)::int AS n FROM environments GROUP BY status`),
|
||||
]);
|
||||
const envCounts = Object.fromEntries(envs.rows.map((r) => [r.status, r.n]));
|
||||
const recentCompletions = await pool.query(
|
||||
`SELECT sa.student_id, s.display_name, sa.scenario_id, sa.completed_at
|
||||
FROM scenario_attempts sa JOIN students s ON s.student_id = sa.student_id
|
||||
WHERE sa.result = 'passed' ORDER BY sa.completed_at DESC LIMIT 10`
|
||||
LEFT JOIN cohort_enrollments e ON e.student_id = s.student_id
|
||||
WHERE sa.result = 'passed' ${cohortFilter}
|
||||
ORDER BY sa.completed_at DESC LIMIT 10`
|
||||
);
|
||||
const recentFailures = await pool.query(
|
||||
`SELECT student_id, lab_host_id, status, error_reason, started_at
|
||||
@@ -60,17 +81,24 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
idle_or_destroyed: envCounts.destroyed || 0,
|
||||
},
|
||||
recent_completions: recentCompletions.rows,
|
||||
recent_failures: recentFailures.rows,
|
||||
recent_failures: scoped ? [] : recentFailures.rows, // env-level failures aren't cohort-scoped data; keep them admin/owner-only
|
||||
});
|
||||
});
|
||||
|
||||
// ── Students ─────────────────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/students', async (req, res) => {
|
||||
const { rows } = await getPool().query(`SELECT * FROM students ORDER BY display_name`);
|
||||
admin.get('/api/admin/students', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = req.instructor.role === 'instructor'
|
||||
? await pool.query(
|
||||
`SELECT DISTINCT s.* FROM students s
|
||||
JOIN cohort_enrollments e ON e.student_id = s.student_id
|
||||
JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
||||
WHERE ci.instructor_id = $1 AND e.enrollment_status = 'enrolled' ORDER BY s.display_name`, [req.instructor.instructor_id])
|
||||
: await pool.query(`SELECT * FROM students ORDER BY display_name`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students', async (req, res) => {
|
||||
admin.post('/api/admin/students', requireAdminOrOwner, async (req, res) => {
|
||||
const { display_name, cohort_id } = req.body || {};
|
||||
if (!display_name) return res.status(400).json({ error: 'display_name is required' });
|
||||
const pool = getPool();
|
||||
@@ -83,10 +111,11 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
[cohort_id, studentId]
|
||||
);
|
||||
}
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_add', resourceType: 'student', resourceId: studentId, result: 'success', sourceIp: req.ip, metadata: { cohort_id } });
|
||||
res.status(201).json({ student_id: studentId, display_name: display_name.trim() });
|
||||
});
|
||||
|
||||
admin.patch('/api/admin/students/:id', async (req, res) => {
|
||||
admin.patch('/api/admin/students/:id', requireAdminOrOwner, async (req, res) => {
|
||||
const { display_name, status } = req.body || {};
|
||||
const fields = [], values = [];
|
||||
if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name); }
|
||||
@@ -99,7 +128,7 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/students/:id/progress', async (req, res) => {
|
||||
admin.get('/api/admin/students/:id/progress', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
const progress = await loadProgress(getPool(), req.params.id);
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
const scenarios = curriculum.loadScenarios();
|
||||
@@ -114,25 +143,25 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
res.json({ stages });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/students/:id/attempts', async (req, res) => {
|
||||
admin.get('/api/admin/students/:id/attempts', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT * FROM scenario_attempts WHERE student_id = $1 ORDER BY created_at DESC LIMIT 100`, [req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
admin.get('/api/admin/students/:id/environment', async (req, res) => {
|
||||
admin.get('/api/admin/students/:id/environment', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
const { status, body } = await provisioner.listForStudent(req.params.id);
|
||||
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
|
||||
res.json(body);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students/:id/environment/provision', async (req, res) => {
|
||||
admin.post('/api/admin/students/:id/environment/provision', requireAnyInstructor, requireStudentCohortAccess('id'), async (req, res) => {
|
||||
const { status, body } = await provisioner.provision(req.params.id);
|
||||
res.status(status === 409 ? 200 : status).json(body);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/students/:id/cohort', async (req, res) => {
|
||||
admin.post('/api/admin/students/:id/cohort', requireAdminOrOwner, async (req, res) => {
|
||||
const { cohort_id } = req.body || {};
|
||||
if (!cohort_id) return res.status(400).json({ error: 'cohort_id required' });
|
||||
await getPool().query(
|
||||
@@ -140,28 +169,37 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
ON CONFLICT (cohort_id, student_id) DO UPDATE SET enrollment_status = 'enrolled'`,
|
||||
[cohort_id, req.params.id]
|
||||
);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_change', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id, op: 'add' } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.delete('/api/admin/students/:id/cohort/:cohortId', async (req, res) => {
|
||||
admin.delete('/api/admin/students/:id/cohort/:cohortId', requireAdminOrOwner, async (req, res) => {
|
||||
await getPool().query(
|
||||
`UPDATE cohort_enrollments SET enrollment_status = 'removed' WHERE cohort_id = $1 AND student_id = $2`,
|
||||
[req.params.cohortId, req.params.id]
|
||||
);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'roster_change', resourceType: 'student', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { cohort_id: req.params.cohortId, op: 'remove' } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Cohorts ──────────────────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/cohorts', async (req, res) => {
|
||||
const { rows } = await getPool().query(`
|
||||
SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count
|
||||
FROM cohorts c LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id
|
||||
GROUP BY c.cohort_id ORDER BY c.created_at DESC
|
||||
`);
|
||||
admin.get('/api/admin/cohorts', requireAnyInstructor, async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows } = req.instructor.role === 'instructor'
|
||||
? await pool.query(`
|
||||
SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count
|
||||
FROM cohorts c
|
||||
JOIN cohort_instructors ci ON ci.cohort_id = c.cohort_id AND ci.instructor_id = $1
|
||||
LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id
|
||||
GROUP BY c.cohort_id ORDER BY c.created_at DESC`, [req.instructor.instructor_id])
|
||||
: await pool.query(`
|
||||
SELECT c.*, count(e.student_id) FILTER (WHERE e.enrollment_status = 'enrolled')::int AS student_count
|
||||
FROM cohorts c LEFT JOIN cohort_enrollments e ON e.cohort_id = c.cohort_id
|
||||
GROUP BY c.cohort_id ORDER BY c.created_at DESC`);
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/cohorts', async (req, res) => {
|
||||
admin.post('/api/admin/cohorts', requireAdminOrOwner, async (req, res) => {
|
||||
const { cohort_id, name, tier, start_date, end_date, seat_target } = req.body || {};
|
||||
if (!cohort_id || !name) return res.status(400).json({ error: 'cohort_id and name are required' });
|
||||
try {
|
||||
@@ -173,10 +211,11 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'cohort_id already exists' });
|
||||
throw e;
|
||||
}
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'cohort_create', resourceType: 'cohort', resourceId: cohort_id, result: 'success', sourceIp: req.ip });
|
||||
res.status(201).json({ cohort_id });
|
||||
});
|
||||
|
||||
admin.patch('/api/admin/cohorts/:id', async (req, res) => {
|
||||
admin.patch('/api/admin/cohorts/:id', requireAdminOrOwner, async (req, res) => {
|
||||
const { name, tier, start_date, end_date, status, seat_target } = req.body || {};
|
||||
const map = { name, tier, start_date, end_date, status, seat_target };
|
||||
const fields = [], values = [];
|
||||
@@ -188,10 +227,24 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
values.push(req.params.id);
|
||||
const { rowCount } = await getPool().query(`UPDATE cohorts SET ${fields.join(', ')} WHERE cohort_id = $${values.length}`, values);
|
||||
if (!rowCount) return res.status(404).json({ error: 'not found' });
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'cohort_update', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: map });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/cohorts/:id/students', async (req, res) => {
|
||||
// Assign an instructor to a cohort (admin/owner only — this IS the scoping mechanism, so a
|
||||
// plain instructor can't grant themselves or anyone else access to a cohort).
|
||||
admin.post('/api/admin/cohorts/:id/instructors', requireAdminOrOwner, async (req, res) => {
|
||||
const { instructor_id, role_in_cohort } = req.body || {};
|
||||
if (!instructor_id) return res.status(400).json({ error: 'instructor_id required' });
|
||||
await getPool().query(
|
||||
`INSERT INTO cohort_instructors (cohort_id, instructor_id, role_in_cohort) VALUES ($1, $2, $3)
|
||||
ON CONFLICT (cohort_id, instructor_id) DO UPDATE SET role_in_cohort = EXCLUDED.role_in_cohort`,
|
||||
[req.params.id, instructor_id, role_in_cohort || 'teacher']
|
||||
);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/cohorts/:id/students', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
const { rows } = await getPool().query(
|
||||
`SELECT s.student_id, s.display_name, s.status, s.last_active_at, e.enrolled_at
|
||||
FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
|
||||
@@ -201,16 +254,17 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
admin.post('/api/admin/cohorts/:id/curriculum', async (req, res) => {
|
||||
admin.post('/api/admin/cohorts/:id/curriculum', requireAdminOrOwner, async (req, res) => {
|
||||
const { curriculum_path } = req.body || {};
|
||||
await getPool().query(
|
||||
`INSERT INTO curriculum_assignments (cohort_id, curriculum_path) VALUES ($1, $2)`,
|
||||
[req.params.id, curriculum_path || 'rootnode-path']
|
||||
);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'curriculum_assign', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { curriculum_path } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.get('/api/admin/cohorts/:id/progress', async (req, res) => {
|
||||
admin.get('/api/admin/cohorts/:id/progress', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
const pool = getPool();
|
||||
const { rows: students } = await pool.query(
|
||||
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
|
||||
@@ -232,15 +286,12 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
});
|
||||
|
||||
// ── Classroom prep: Prepare Labs / Destroy Cohort Labs ──────────────────────────────────
|
||||
admin.post('/api/admin/cohorts/:id/prepare-labs', async (req, res) => {
|
||||
admin.post('/api/admin/cohorts/:id/prepare-labs', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
const { rows: students } = await getPool().query(
|
||||
`SELECT s.student_id, s.display_name FROM cohort_enrollments e JOIN students s ON s.student_id = e.student_id
|
||||
WHERE e.cohort_id = $1 AND e.enrollment_status = 'enrolled' AND s.status = 'active'`,
|
||||
[req.params.id]
|
||||
);
|
||||
// Provision concurrently — each is a real k3s boot (1-3 min); sequential would make a
|
||||
// 20-student cohort prep take the better part of an hour. Promise.allSettled so one
|
||||
// student's failure doesn't block reporting on the rest.
|
||||
const results = await Promise.allSettled(students.map(async (s) => {
|
||||
const existing = await provisioner.listForStudent(s.student_id);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
@@ -249,12 +300,13 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
if (status !== 201) return { student_id: s.student_id, display_name: s.display_name, status: 'error', error: body?.error };
|
||||
return { student_id: s.student_id, display_name: s.display_name, status: body.status };
|
||||
}));
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'prepare_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
|
||||
res.json({
|
||||
results: results.map((r, i) => r.status === 'fulfilled' ? r.value : { student_id: students[i].student_id, display_name: students[i].display_name, status: 'error', error: r.reason?.message }),
|
||||
});
|
||||
});
|
||||
|
||||
admin.post('/api/admin/cohorts/:id/destroy-labs', async (req, res) => {
|
||||
admin.post('/api/admin/cohorts/:id/destroy-labs', requireAnyInstructor, requireCohortAccess('id'), async (req, res) => {
|
||||
const { rows: students } = await getPool().query(
|
||||
`SELECT student_id FROM cohort_enrollments WHERE cohort_id = $1 AND enrollment_status = 'enrolled'`,
|
||||
[req.params.id]
|
||||
@@ -266,35 +318,113 @@ module.exports = function registerAdminRoutes(app, deps) {
|
||||
await provisioner.destroy(active.environment_id);
|
||||
return { student_id: s.student_id, status: 'destroyed' };
|
||||
}));
|
||||
// Academic progress is Postgres-side and untouched by any of this — destroying compute
|
||||
// never touches the students/progress/scenario_attempts tables.
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'destroy_labs', resourceType: 'cohort', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { student_count: students.length } });
|
||||
// Academic progress is Postgres-side and untouched by any of this.
|
||||
res.json({ results: results.map((r) => r.status === 'fulfilled' ? r.value : { status: 'error', error: r.reason?.message }) });
|
||||
});
|
||||
|
||||
// ── Instructor environment controls ─────────────────────────────────────────────────────
|
||||
admin.post('/api/admin/environments/:id/reset', async (req, res) => {
|
||||
// ── Instructor environment controls (by raw environment id — admin/owner only; a plain
|
||||
// instructor operates on Labs via the cohort/student-scoped routes above instead, since
|
||||
// resolving a bare environment id back to a cohort would need an extra Provisioner round
|
||||
// trip on every call — a documented simplification, see docs/PHASE3A...) ───────────────────
|
||||
admin.post('/api/admin/environments/:id/reset', requireAdminOrOwner, async (req, res) => {
|
||||
const { status, body } = await provisioner.reset(req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_reset', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
|
||||
res.status(status).json(body);
|
||||
});
|
||||
|
||||
admin.delete('/api/admin/environments/:id', async (req, res) => {
|
||||
admin.delete('/api/admin/environments/:id', requireAdminOrOwner, async (req, res) => {
|
||||
const { status, body } = await provisioner.destroy(req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'lab_destroy', resourceType: 'environment', resourceId: req.params.id, result: status === 200 ? 'success' : 'failure', sourceIp: req.ip });
|
||||
res.status(status).json(body);
|
||||
});
|
||||
|
||||
admin.get('/api/admin/environments/:id/events', async (req, res) => {
|
||||
admin.get('/api/admin/environments/:id/events', requireAdminOrOwner, async (req, res) => {
|
||||
const { status, body } = await provisioner.events(req.params.id);
|
||||
res.status(status).json(body);
|
||||
});
|
||||
};
|
||||
|
||||
// Tiny helper so every admin.<verb>(...) call below doesn't need `adminSession` repeated
|
||||
// as a second argument by hand — same effect, less visual noise.
|
||||
function express_router_guard(app, adminSession) {
|
||||
return {
|
||||
get: (p, h) => app.get(p, adminSession, h),
|
||||
post: (p, h) => app.post(p, adminSession, h),
|
||||
patch: (p, h) => app.patch(p, adminSession, h),
|
||||
delete: (p, h) => app.delete(p, adminSession, h),
|
||||
};
|
||||
}
|
||||
// ── Instructor accounts — owner only ────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/instructors', requireOwner, async (req, res) => {
|
||||
res.json(await listInstructors(getPool()));
|
||||
});
|
||||
|
||||
admin.post('/api/admin/instructors', requireOwner, async (req, res) => {
|
||||
const { email, display_name, role, password } = req.body || {};
|
||||
if (!email || !display_name || !password) return res.status(400).json({ error: 'email, display_name, and password are required' });
|
||||
if (password.length < 12) return res.status(400).json({ error: 'password must be at least 12 characters' });
|
||||
if (role && !['owner', 'admin', 'instructor'].includes(role)) return res.status(400).json({ error: 'invalid role' });
|
||||
let instructor;
|
||||
try {
|
||||
instructor = await createInstructor(getPool(), { email, displayName: display_name, role, password });
|
||||
} catch (e) {
|
||||
if (e.code === '23505') return res.status(409).json({ error: 'email already in use' });
|
||||
throw e;
|
||||
}
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'instructor_create', resourceType: 'instructor', resourceId: instructor.instructor_id, result: 'success', sourceIp: req.ip, metadata: { email: instructor.email, role: instructor.role } });
|
||||
const { password_hash, ...pub } = instructor;
|
||||
res.status(201).json(pub);
|
||||
});
|
||||
|
||||
admin.get('/api/admin/instructors/:id', requireOwner, async (req, res) => {
|
||||
const { rows } = await getPool().query(`SELECT * FROM instructors WHERE instructor_id = $1`, [req.params.id]);
|
||||
if (!rows[0]) return res.status(404).json({ error: 'not found' });
|
||||
const { password_hash, ...pub } = rows[0];
|
||||
const sessionsList = await require('./sessions').listInstructorSessions(getPool(), req.params.id);
|
||||
res.json({ ...pub, sessions: sessionsList });
|
||||
});
|
||||
|
||||
admin.patch('/api/admin/instructors/:id', requireOwner, async (req, res) => {
|
||||
const { display_name, role, status } = req.body || {};
|
||||
const pool = getPool();
|
||||
if (role && !['owner', 'admin', 'instructor'].includes(role)) return res.status(400).json({ error: 'invalid role' });
|
||||
if (status && !['active', 'inactive'].includes(status)) return res.status(400).json({ error: 'invalid status' });
|
||||
|
||||
const { rows: targetRows } = await pool.query(`SELECT * FROM instructors WHERE instructor_id = $1`, [req.params.id]);
|
||||
const target = targetRows[0];
|
||||
if (!target) return res.status(404).json({ error: 'not found' });
|
||||
|
||||
const demotingFromOwner = target.role === 'owner' && role && role !== 'owner';
|
||||
const deactivatingOwner = target.role === 'owner' && status === 'inactive';
|
||||
if (demotingFromOwner || deactivatingOwner) {
|
||||
try { await assertNotLastOwner(pool, req.params.id); }
|
||||
catch (e) { if (e.code === 'LAST_OWNER') return res.status(400).json({ error: 'last_owner', message: e.message }); throw e; }
|
||||
}
|
||||
|
||||
const fields = [], values = [];
|
||||
if (display_name) { fields.push(`display_name = $${fields.length + 1}`); values.push(display_name.trim()); }
|
||||
if (role) { fields.push(`role = $${fields.length + 1}`); values.push(role); }
|
||||
if (status) { fields.push(`status = $${fields.length + 1}`); values.push(status); }
|
||||
if (!fields.length) return res.status(400).json({ error: 'nothing to update' });
|
||||
fields.push('updated_at = now()');
|
||||
values.push(req.params.id);
|
||||
await pool.query(`UPDATE instructors SET ${fields.join(', ')} WHERE instructor_id = $${values.length}`, values);
|
||||
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'instructor_update', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip, metadata: { display_name, role, status } });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/instructors/:id/revoke-sessions', requireOwner, async (req, res) => {
|
||||
await require('./sessions').revokeAllInstructorSessions(getPool(), req.params.id);
|
||||
await recordAudit(getPool(), { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'sessions_revoked', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
admin.post('/api/admin/instructors/:id/reset-password', requireOwner, async (req, res) => {
|
||||
const { new_password } = req.body || {};
|
||||
if (!new_password || new_password.length < 12) return res.status(400).json({ error: 'new_password must be at least 12 characters' });
|
||||
const { hashPassword } = require('./auth/password');
|
||||
const pool = getPool();
|
||||
const { rowCount } = await pool.query(`UPDATE instructors SET password_hash = $1, updated_at = now() WHERE instructor_id = $2`, [hashPassword(new_password), req.params.id]);
|
||||
if (!rowCount) return res.status(404).json({ error: 'not found' });
|
||||
await require('./sessions').revokeAllInstructorSessions(pool, req.params.id); // force re-login everywhere with the new password
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'password_reset', resourceType: 'instructor', resourceId: req.params.id, result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Audit log — admin/owner ──────────────────────────────────────────────────────────────
|
||||
admin.get('/api/admin/audit-events', requireAdminOrOwner, async (req, res) => {
|
||||
const { rows } = await getPool().query(`SELECT * FROM audit_events ORDER BY created_at DESC LIMIT 200`);
|
||||
res.json(rows);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Platform-level security/admin audit log — distinct from the Provisioner's environment_events
|
||||
// (Lab lifecycle only). Never pass plaintext passwords, raw session tokens, Provisioner
|
||||
// secrets, or raw routing tokens into `metadata` — callers are responsible for that, this
|
||||
// module doesn't scrub input.
|
||||
|
||||
async function recordAudit(pool, { actorType, actorId, action, resourceType, resourceId, result, sourceIp, metadata }) {
|
||||
try {
|
||||
await pool.query(
|
||||
`INSERT INTO audit_events (actor_type, actor_id, action, resource_type, resource_id, result, source_ip, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[actorType, actorId ? String(actorId) : null, action, resourceType || null, resourceId ? String(resourceId) : null,
|
||||
result, sourceIp || null, metadata ? JSON.stringify(metadata) : null]
|
||||
);
|
||||
} catch (e) {
|
||||
// Audit logging must never take down the request it's describing.
|
||||
console.error('[audit] failed to record event:', action, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { recordAudit };
|
||||
@@ -0,0 +1,27 @@
|
||||
// Password hashing for instructor accounts — scrypt (Node stdlib, no new dependency) with a
|
||||
// fresh random salt per instructor, stored alongside the hash as "<salt-hex>:<hash-hex>".
|
||||
// Phase 2B's admin-auth.js used one FIXED salt because there was exactly one shared password;
|
||||
// now that there are real per-person accounts, each one gets its own salt.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const KEY_LEN = 64;
|
||||
|
||||
function hashPassword(plain) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = crypto.scryptSync(plain, salt, KEY_LEN);
|
||||
return `${salt.toString('hex')}:${hash.toString('hex')}`;
|
||||
}
|
||||
|
||||
function verifyPassword(plain, stored) {
|
||||
if (!plain || !stored) return false;
|
||||
const [saltHex, hashHex] = stored.split(':');
|
||||
if (!saltHex || !hashHex) return false;
|
||||
const salt = Buffer.from(saltHex, 'hex');
|
||||
const expected = Buffer.from(hashHex, 'hex');
|
||||
const actual = crypto.scryptSync(plain, salt, expected.length || KEY_LEN);
|
||||
if (actual.length !== expected.length) return false;
|
||||
return crypto.timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
module.exports = { hashPassword, verifyPassword };
|
||||
@@ -0,0 +1,21 @@
|
||||
// Cookie construction, centralized so the Secure attribute is set consistently everywhere
|
||||
// instead of hand-built per call site. Secure is opt-in via COOKIE_SECURE=1 — off by default
|
||||
// because local dev and the current single-VM pilot deployment run over plain HTTP; anything
|
||||
// served over real HTTPS should set COOKIE_SECURE=1 (see docs/PHASE3A... "production HTTPS
|
||||
// expectations").
|
||||
|
||||
const SECURE = process.env.COOKIE_SECURE === '1';
|
||||
|
||||
function buildCookie(name, value, { maxAgeSec, httpOnly = true } = {}) {
|
||||
const parts = [`${name}=${value}`, 'Path=/', 'SameSite=Lax'];
|
||||
if (httpOnly) parts.push('HttpOnly');
|
||||
if (SECURE) parts.push('Secure');
|
||||
parts.push(`Max-Age=${maxAgeSec != null ? maxAgeSec : 0}`);
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function clearCookie(name) {
|
||||
return buildCookie(name, '', { maxAgeSec: 0 });
|
||||
}
|
||||
|
||||
module.exports = { buildCookie, clearCookie };
|
||||
@@ -0,0 +1,102 @@
|
||||
// POST /api/auth/instructor/login, POST /api/auth/logout, GET /api/auth/session,
|
||||
// POST /api/auth/change-password — replaces Phase 2B's single shared ADMIN_PASSWORD
|
||||
// (/api/admin/login) with real per-instructor accounts. See
|
||||
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
|
||||
|
||||
const { verifyPassword, hashPassword } = require('./auth/password');
|
||||
const { getInstructorByEmail, publicInstructor } = require('./instructors');
|
||||
const { recordAudit } = require('./audit');
|
||||
const { makeCsrfToken, tooManyAttempts, parseCookies, checkCsrf } = require('./security');
|
||||
const { buildCookie, clearCookie } = require('./cookies');
|
||||
|
||||
const INSTRUCTOR_COOKIE = 'rn_instructor';
|
||||
const CSRF_COOKIE = 'rn_csrf_i';
|
||||
const LOGIN_MAX_ATTEMPTS = 8;
|
||||
const LOGIN_WINDOW_MS = 5 * 60 * 1000; // 5 min
|
||||
|
||||
module.exports = function registerInstructorAuthRoutes(app, { pool, sessions, instructorSession }) {
|
||||
app.post('/api/auth/instructor/login', async (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
const rateKey = `${req.ip}:${String(email || '').toLowerCase()}`;
|
||||
if (tooManyAttempts(rateKey, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS)) {
|
||||
return res.status(429).json({ error: 'too_many_attempts', message: 'Too many login attempts. Try again in a few minutes.' });
|
||||
}
|
||||
if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
|
||||
|
||||
const instructor = await getInstructorByEmail(pool, email);
|
||||
// Same generic response whether the email doesn't exist or the password is wrong — no
|
||||
// account enumeration. Only once the password is ALSO correct do we distinguish
|
||||
// "deactivated," which doesn't help an attacker who doesn't already know the password.
|
||||
const passwordOk = instructor && verifyPassword(password, instructor.password_hash);
|
||||
if (!passwordOk) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor?.instructor_id, action: 'login',
|
||||
result: 'failure', sourceIp: req.ip, metadata: { email: String(email).toLowerCase() },
|
||||
});
|
||||
return res.status(401).json({ error: 'invalid_credentials', message: 'Invalid email or password.' });
|
||||
}
|
||||
if (instructor.status !== 'active') {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'login',
|
||||
result: 'failure', sourceIp: req.ip, metadata: { reason: 'inactive' },
|
||||
});
|
||||
return res.status(403).json({ error: 'account_inactive', message: 'This account has been deactivated.' });
|
||||
}
|
||||
|
||||
const token = await sessions.createInstructorSession(pool, instructor.instructor_id);
|
||||
const csrf = makeCsrfToken();
|
||||
await pool.query(`UPDATE instructors SET last_login_at = now() WHERE instructor_id = $1`, [instructor.instructor_id]);
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'login', result: 'success', sourceIp: req.ip });
|
||||
|
||||
res.append('Set-Cookie', buildCookie(INSTRUCTOR_COOKIE, token, { maxAgeSec: 12 * 3600 }));
|
||||
res.append('Set-Cookie', buildCookie(CSRF_COOKIE, csrf, { maxAgeSec: 12 * 3600, httpOnly: false }));
|
||||
res.json({ instructor: publicInstructor({ ...instructor, last_login_at: new Date() }) });
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, res) => {
|
||||
const token = parseCookies(req)[INSTRUCTOR_COOKIE];
|
||||
// CSRF is still checked here even though logout is low-severity to forge (the worst case is
|
||||
// an annoying forced sign-out, not data exposure) — consistent enforcement is simpler to
|
||||
// reason about than a documented exception per route.
|
||||
if (!checkCsrf(req, CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
|
||||
// Resolve directly rather than requiring the strict instructorSession middleware — logout
|
||||
// must still succeed (and clear cookies) even against an already-expired/invalid session.
|
||||
const instructorId = await sessions.resolveInstructorSession(pool, token);
|
||||
if (instructorId) {
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: instructorId, action: 'logout', result: 'success', sourceIp: req.ip });
|
||||
}
|
||||
await sessions.destroyInstructorSession(pool, token);
|
||||
res.append('Set-Cookie', clearCookie(INSTRUCTOR_COOKIE));
|
||||
res.append('Set-Cookie', clearCookie(CSRF_COOKIE));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/auth/session — "whoami" for the instructor portal.
|
||||
app.get('/api/auth/session', instructorSession, (req, res) => {
|
||||
res.json({ instructor: publicInstructor(req.instructor) });
|
||||
});
|
||||
|
||||
app.post('/api/auth/change-password', instructorSession, async (req, res) => {
|
||||
// CSRF already checked by instructorSession (server.js) for every non-GET method.
|
||||
const { old_password, new_password } = req.body || {};
|
||||
if (!old_password || !new_password || new_password.length < 12) {
|
||||
return res.status(400).json({ error: 'new_password must be at least 12 characters' });
|
||||
}
|
||||
const instructor = req.instructor; // full row (studentSession-style middleware already fetched it)
|
||||
if (!verifyPassword(old_password, instructor.password_hash)) {
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'password_change', result: 'failure', sourceIp: req.ip });
|
||||
return res.status(401).json({ error: 'invalid_credentials', message: 'Current password is incorrect.' });
|
||||
}
|
||||
const newHash = hashPassword(new_password);
|
||||
await pool.query(`UPDATE instructors SET password_hash = $1, updated_at = now() WHERE instructor_id = $2`, [newHash, instructor.instructor_id]);
|
||||
// Kill any other lingering sessions for this account — a password change should invalidate
|
||||
// access anywhere else that was logged in, but not the session that just made the change.
|
||||
const currentToken = parseCookies(req)[INSTRUCTOR_COOKIE];
|
||||
await sessions.revokeAllInstructorSessions(pool, instructor.instructor_id, currentToken);
|
||||
await recordAudit(pool, { actorType: 'instructor', actorId: instructor.instructor_id, action: 'password_change', result: 'success', sourceIp: req.ip });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.INSTRUCTOR_COOKIE = INSTRUCTOR_COOKIE;
|
||||
module.exports.CSRF_COOKIE = CSRF_COOKIE;
|
||||
@@ -0,0 +1,77 @@
|
||||
// Instructor account CRUD + the small set of invariants that protect the platform from
|
||||
// locking itself out (never zero active owners). Shared by the auth routes (login/session) and
|
||||
// the admin instructor-management routes.
|
||||
|
||||
const { hashPassword } = require('./auth/password');
|
||||
|
||||
function publicInstructor(row) {
|
||||
if (!row) return null;
|
||||
const { password_hash, ...rest } = row;
|
||||
return rest;
|
||||
}
|
||||
|
||||
async function getActiveInstructor(pool, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM instructors WHERE instructor_id = $1 AND status = 'active'`, [instructorId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// Returns the row regardless of status — the login flow needs to tell "wrong password" apart
|
||||
// from "correct password, but account is deactivated" (see instructor-auth-routes.js), which
|
||||
// requires seeing inactive accounts too.
|
||||
async function getInstructorByEmail(pool, email) {
|
||||
const { rows } = await pool.query(`SELECT * FROM instructors WHERE email = $1`, [String(email).toLowerCase().trim()]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function listInstructors(pool) {
|
||||
const { rows } = await pool.query(`SELECT * FROM instructors ORDER BY created_at`);
|
||||
return rows.map(publicInstructor);
|
||||
}
|
||||
|
||||
async function countActiveOwners(pool, excludingId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT count(*)::int AS n FROM instructors WHERE role = 'owner' AND status = 'active' AND instructor_id != $1`,
|
||||
[excludingId || '00000000-0000-0000-0000-000000000000']
|
||||
);
|
||||
return rows[0].n;
|
||||
}
|
||||
|
||||
async function createInstructor(pool, { email, displayName, role, password }) {
|
||||
const emailLower = String(email).toLowerCase().trim();
|
||||
const passwordHash = hashPassword(password);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows } = await client.query(
|
||||
`INSERT INTO instructors (email, display_name, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[emailLower, displayName.trim(), passwordHash, role || 'instructor']
|
||||
);
|
||||
const instructor = rows[0];
|
||||
await client.query(
|
||||
`INSERT INTO auth_identities (instructor_id, provider, provider_subject, provider_email) VALUES ($1, 'local', $2, $3)`,
|
||||
[instructor.instructor_id, String(instructor.instructor_id), emailLower]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
return instructor;
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the one invariant that matters: never let the platform end up with zero active
|
||||
// owners. Called before any update that would deactivate an owner or demote their role.
|
||||
async function assertNotLastOwner(pool, instructorId) {
|
||||
const remaining = await countActiveOwners(pool, instructorId);
|
||||
if (remaining === 0) {
|
||||
const err = new Error('Cannot remove the last active owner.');
|
||||
err.code = 'LAST_OWNER';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { publicInstructor, getActiveInstructor, getInstructorByEmail, listInstructors, countActiveOwners, createInstructor, assertNotLastOwner };
|
||||
@@ -0,0 +1,82 @@
|
||||
// Role-based authorization — enforced server-side only. The admin frontend hides controls a
|
||||
// role can't use, but that's UX, not security: every route below re-checks on the server
|
||||
// regardless of what the browser sent or hid.
|
||||
//
|
||||
// Roles: owner > admin > instructor. See docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md for
|
||||
// the full permission matrix. Deny reasons are never leaked in the response body — a 403 just
|
||||
// says "forbidden," not which check failed or whether the resource exists.
|
||||
|
||||
const { recordAudit } = require('./audit');
|
||||
|
||||
// Factory bound to a pool (for audit logging) rather than a bare export — every guard here
|
||||
// needs to write an audit_events row on denial, so they all need the pool.
|
||||
module.exports = function createRbac(pool) {
|
||||
function requireRole(...allowedRoles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.instructor) return res.status(401).json({ error: 'no_session' });
|
||||
if (!allowedRoles.includes(req.instructor.role)) {
|
||||
recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: req.instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'route', resourceId: `${req.method} ${req.path}`, result: 'failure', sourceIp: req.ip,
|
||||
metadata: { required: allowedRoles, actual: req.instructor.role },
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
const requireOwner = requireRole('owner');
|
||||
const requireAdminOrOwner = requireRole('owner', 'admin');
|
||||
const requireAnyInstructor = requireRole('owner', 'admin', 'instructor');
|
||||
|
||||
// Cohort-scoped access: owner/admin bypass (global teaching visibility by role alone). A
|
||||
// plain 'instructor' must have a cohort_instructors row for this cohort_id, or gets 403 —
|
||||
// same as any other authorization failure, no distinction made between "not your cohort" and
|
||||
// "cohort doesn't exist" in the response.
|
||||
function requireCohortAccess(cohortIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const cohortId = req.params[cohortIdParam];
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM cohort_instructors WHERE cohort_id = $1 AND instructor_id = $2`,
|
||||
[cohortId, instructor.instructor_id]
|
||||
);
|
||||
if (!rowCount) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'cohort', resourceId: cohortId, result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
// Same idea, for routes keyed by student_id — checks whether the student is enrolled in ANY
|
||||
// cohort this instructor is assigned to.
|
||||
function requireStudentCohortAccess(studentIdParam = 'id') {
|
||||
return async (req, res, next) => {
|
||||
const instructor = req.instructor;
|
||||
if (['owner', 'admin'].includes(instructor.role)) return next();
|
||||
const studentId = req.params[studentIdParam];
|
||||
const { rowCount } = await pool.query(
|
||||
`SELECT 1 FROM cohort_enrollments e
|
||||
JOIN cohort_instructors ci ON ci.cohort_id = e.cohort_id
|
||||
WHERE e.student_id = $1 AND e.enrollment_status = 'enrolled' AND ci.instructor_id = $2`,
|
||||
[studentId, instructor.instructor_id]
|
||||
);
|
||||
if (!rowCount) {
|
||||
await recordAudit(pool, {
|
||||
actorType: 'instructor', actorId: instructor.instructor_id, action: 'authz_denied',
|
||||
resourceType: 'student', resourceId: studentId, result: 'failure', sourceIp: req.ip,
|
||||
});
|
||||
return res.status(403).json({ error: 'forbidden' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
return { requireRole, requireOwner, requireAdminOrOwner, requireAnyInstructor, requireCohortAccess, requireStudentCohortAccess };
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
// CSRF (double-submit cookie) and login rate limiting. No new dependency — both are small
|
||||
// enough to hand-roll, and SameSite=Lax cookies already do most of the CSRF work; this is
|
||||
// defense in depth on top of that, not a replacement for it.
|
||||
//
|
||||
// CSRF: on login, a non-HttpOnly token is set as a cookie (rn_csrf_s for students, rn_csrf_i
|
||||
// for instructors) — non-HttpOnly *on purpose*, so our own frontend JS can read it via
|
||||
// document.cookie and echo it back as a header. A cross-origin attacker's page can trigger a
|
||||
// cookie-carrying request but cannot read the cookie's value (browser same-origin policy) or
|
||||
// add a custom header to it without CORS, which we don't grant to other origins — so it can
|
||||
// never produce a request where the header matches the cookie.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
function makeCsrfToken() {
|
||||
return crypto.randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
function parseCookies(req) {
|
||||
const header = req.headers.cookie;
|
||||
if (!header) return {};
|
||||
return Object.fromEntries(
|
||||
header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)])
|
||||
);
|
||||
}
|
||||
|
||||
// Verifies X-CSRF-Token header against the given cookie for any state-changing method.
|
||||
// GET/HEAD/OPTIONS are read-only and exempt (nothing to protect).
|
||||
function checkCsrf(req, cookieName) {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return true;
|
||||
const cookieValue = parseCookies(req)[cookieName];
|
||||
const header = req.headers['x-csrf-token'];
|
||||
return !!cookieValue && !!header && cookieValue === header;
|
||||
}
|
||||
|
||||
// ── Login rate limiting ─────────────────────────────────────────────────────────────────
|
||||
// In-memory sliding window, keyed by "ip:identifier" (e.g. ip:email). Fine for a single-process
|
||||
// Platform API — a shared store (Postgres/Redis) would be needed for multi-instance, not a
|
||||
// concern at this scale (see docs/PHASE3A... known limitations).
|
||||
const attempts = new Map();
|
||||
|
||||
function tooManyAttempts(key, max, windowMs) {
|
||||
const now = Date.now();
|
||||
const arr = (attempts.get(key) || []).filter((t) => now - t < windowMs);
|
||||
arr.push(now);
|
||||
attempts.set(key, arr);
|
||||
return arr.length > max;
|
||||
}
|
||||
|
||||
setInterval(() => {
|
||||
const cutoff = Date.now() - 60 * 60 * 1000;
|
||||
for (const [key, arr] of attempts) {
|
||||
const kept = arr.filter((t) => t > cutoff);
|
||||
if (kept.length) attempts.set(key, kept); else attempts.delete(key);
|
||||
}
|
||||
}, 15 * 60 * 1000).unref();
|
||||
|
||||
module.exports = { makeCsrfToken, parseCookies, checkCsrf, tooManyAttempts };
|
||||
+89
-50
@@ -1,60 +1,77 @@
|
||||
// RootNode Academy — Platform API (Phase 2B).
|
||||
// RootNode Academy — Platform API (Phase 3A revision).
|
||||
//
|
||||
// Lightweight service: no k3s, no --privileged, no Lab terminal runtime. Owns students,
|
||||
// cohorts, enrollments, curriculum assignments, progress, scenario attempts, student/admin
|
||||
// session identity, and all Provisioner (environment orchestration) calls — both the
|
||||
// student-facing launch/reset path and the instructor/admin control plane. See
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md.
|
||||
// cohorts, enrollments, curriculum assignments, progress, scenario attempts, instructor
|
||||
// accounts, persistent student/instructor sessions, RBAC, audit logging, and all Provisioner
|
||||
// (environment orchestration) calls. See docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md.
|
||||
//
|
||||
// Reached only through the Gateway (platform/gateway) — never directly by browser JS, the
|
||||
// same trust boundary the Provisioner already has relative to this service.
|
||||
// Reached only through the Gateway (platform/gateway) — never directly by browser JS.
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getPool, runMigrations } = require('./db');
|
||||
const sessions = require('./sessions');
|
||||
const { verifyAdminPassword } = require('./admin-auth');
|
||||
const { getActiveInstructor } = require('./instructors');
|
||||
const { provisioner } = require('./provisioner-client');
|
||||
const curriculum = require('./curriculum');
|
||||
const { loadProgress, saveOneProgress, touchStudent } = require('./progress');
|
||||
const { recordAudit } = require('./audit');
|
||||
const { parseCookies, checkCsrf, makeCsrfToken } = require('./security');
|
||||
const { buildCookie, clearCookie } = require('./cookies');
|
||||
const createRbac = require('./rbac');
|
||||
const { INSTRUCTOR_COOKIE, CSRF_COOKIE: INSTRUCTOR_CSRF_COOKIE } = require('./instructor-auth-routes');
|
||||
|
||||
const PORT = Number(process.env.PLATFORM_API_PORT) || 4300;
|
||||
const STUDENT_COOKIE = 'rn_session';
|
||||
const ADMIN_COOKIE = 'rn_admin';
|
||||
const STUDENT_CSRF_COOKIE = 'rn_csrf_s';
|
||||
|
||||
// Deliberate, not automatic: honoring X-Forwarded-For for req.ip (audit source_ip, login rate
|
||||
// limiting) is only correct when there's a trusted reverse proxy in front of this process. Off
|
||||
// by default (direct connections / dev); set TRUST_PROXY=1 once fronted by one in production.
|
||||
const app = express();
|
||||
if (process.env.TRUST_PROXY === '1') app.set('trust proxy', 1);
|
||||
|
||||
app.use(express.json());
|
||||
const FRONTEND_DIST = process.env.FRONTEND_DIST_DIR || path.join(__dirname, '../../../frontend/dist');
|
||||
app.use(express.static(FRONTEND_DIST));
|
||||
|
||||
function parseCookies(req) {
|
||||
const header = req.headers.cookie;
|
||||
if (!header) return {};
|
||||
return Object.fromEntries(
|
||||
header.split(';').map((p) => p.trim().split('=')).filter((p) => p.length === 2).map(([k, v]) => [k, decodeURIComponent(v)])
|
||||
);
|
||||
}
|
||||
|
||||
// ── Student session middleware ──────────────────────────────────────────────────────────
|
||||
// This is the actual fix for the Phase 2A pilot ?sid= mechanism: student_id is derived ONLY
|
||||
// from an opaque, server-issued session cookie the browser cannot fabricate — never from a
|
||||
// header or query parameter a client controls. See docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3.
|
||||
function studentSession(req, res, next) {
|
||||
// student_id is derived ONLY from an opaque, server-issued session token (now Postgres-backed,
|
||||
// not the in-memory Map from Phase 2B) — never from a header or query parameter a client
|
||||
// controls. CSRF is checked here too, for every state-changing student request.
|
||||
async function studentSession(req, res, next) {
|
||||
const token = parseCookies(req)[STUDENT_COOKIE];
|
||||
const studentId = sessions.resolveStudentSession(token);
|
||||
const studentId = await sessions.resolveStudentSession(getPool(), token);
|
||||
if (!studentId) return res.status(401).json({ error: 'no_session', message: 'Please log in.' });
|
||||
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
|
||||
req.studentId = studentId;
|
||||
req.sessionToken = token;
|
||||
next();
|
||||
}
|
||||
|
||||
function adminSession(req, res, next) {
|
||||
const token = parseCookies(req)[ADMIN_COOKIE];
|
||||
if (!sessions.resolveAdminSession(token)) {
|
||||
return res.status(401).json({ error: 'no_admin_session', message: 'Instructor login required.' });
|
||||
// ── Instructor session middleware ───────────────────────────────────────────────────────
|
||||
// Re-fetches the instructor row from Postgres on every request rather than trusting anything
|
||||
// cached in the session — a role change or deactivation takes effect on the very next request,
|
||||
// with no separate "revoke sessions on role change" mechanism needed.
|
||||
async function instructorSession(req, res, next) {
|
||||
const token = parseCookies(req)[INSTRUCTOR_COOKIE];
|
||||
const instructorId = await sessions.resolveInstructorSession(getPool(), token);
|
||||
if (!instructorId) return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' });
|
||||
const instructor = await getActiveInstructor(getPool(), instructorId);
|
||||
if (!instructor) {
|
||||
await sessions.destroyInstructorSession(getPool(), token); // deactivated since the session was issued
|
||||
return res.status(401).json({ error: 'no_session', message: 'Instructor login required.' });
|
||||
}
|
||||
if (!checkCsrf(req, INSTRUCTOR_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
|
||||
req.instructor = instructor;
|
||||
req.sessionToken = token;
|
||||
next();
|
||||
}
|
||||
|
||||
function wrap(fn) {
|
||||
return (req, res, next) => fn(req, res, next).catch(next);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
// STUDENT-FACING: roster / login / session
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
@@ -81,27 +98,38 @@ app.get('/api/roster', async (req, res) => {
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
// Roster name-picking is CLASSROOM/PILOT IDENTIFICATION, not production authentication — there
|
||||
// is no password or possession factor here, only "does this student_id exist." What Phase 3A
|
||||
// hardens is everything AFTER that pick: the resulting session is server-controlled, persistent,
|
||||
// can't be forged/switched by editing client state, and is checked on every Lab request. See
|
||||
// docs/PHASE3A_IDENTITY_AND_INSTRUCTOR_ACCESS.md §5 for the future path (one-time codes,
|
||||
// institutional SSO) that upgrades identification without touching this session system.
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { student_id } = req.body || {};
|
||||
const { rows } = await getPool().query(`SELECT student_id, display_name, status FROM students WHERE student_id = $1`, [student_id]);
|
||||
const student = rows[0];
|
||||
if (!student || student.status !== 'active') return res.status(404).json({ error: 'Student not found' });
|
||||
await touchStudent(getPool(), student.student_id);
|
||||
const token = sessions.createStudentSession(student.student_id);
|
||||
res.append('Set-Cookie', `${STUDENT_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`);
|
||||
const token = await sessions.createStudentSession(getPool(), student.student_id);
|
||||
const csrf = makeCsrfToken();
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_COOKIE, token, { maxAgeSec: 8 * 3600 }));
|
||||
res.append('Set-Cookie', buildCookie(STUDENT_CSRF_COOKIE, csrf, { maxAgeSec: 8 * 3600, httpOnly: false }));
|
||||
res.json({ student_id: student.student_id, display_name: student.display_name });
|
||||
});
|
||||
|
||||
app.post('/api/logout', (req, res) => {
|
||||
app.post('/api/logout', async (req, res) => {
|
||||
if (!checkCsrf(req, STUDENT_CSRF_COOKIE)) return res.status(403).json({ error: 'csrf' });
|
||||
const token = parseCookies(req)[STUDENT_COOKIE];
|
||||
sessions.destroyStudentSession(token);
|
||||
res.append('Set-Cookie', `${STUDENT_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
|
||||
await sessions.destroyStudentSession(getPool(), token);
|
||||
res.append('Set-Cookie', clearCookie(STUDENT_COOKIE));
|
||||
res.append('Set-Cookie', clearCookie(STUDENT_CSRF_COOKIE));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/session/whoami — used by the Gateway to resolve "who is this browser" for Lab
|
||||
// ownership checks (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3). Also usable by the frontend.
|
||||
app.get('/api/session/whoami', studentSession, (req, res) => {
|
||||
// ownership checks. Read-only, so it isn't behind the CSRF check (studentSession only enforces
|
||||
// CSRF on non-GET methods — see checkCsrf).
|
||||
app.get('/api/session/whoami', wrap(studentSession), (req, res) => {
|
||||
res.json({ student_id: req.studentId });
|
||||
});
|
||||
|
||||
@@ -109,7 +137,7 @@ app.get('/api/session/whoami', studentSession, (req, res) => {
|
||||
// STUDENT-FACING: curriculum / progress
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
app.get('/api/curriculum/path', studentSession, async (req, res) => {
|
||||
app.get('/api/curriculum/path', wrap(studentSession), async (req, res) => {
|
||||
const path = curriculum.loadCurriculumPath();
|
||||
if (!path) return res.status(404).json({ error: 'Curriculum path not found' });
|
||||
const scenarios = curriculum.loadScenarios();
|
||||
@@ -130,7 +158,7 @@ app.get('/api/curriculum/path', studentSession, async (req, res) => {
|
||||
res.json({ id: path.id, name: path.name, tagline: path.tagline, stages, next_stage: firstIncompleteStage });
|
||||
});
|
||||
|
||||
app.get('/api/progress', studentSession, async (req, res) => {
|
||||
app.get('/api/progress', wrap(studentSession), async (req, res) => {
|
||||
const scenarios = curriculum.loadScenarios();
|
||||
const progress = await loadProgress(getPool(), req.studentId);
|
||||
const total = scenarios.length;
|
||||
@@ -138,12 +166,7 @@ app.get('/api/progress', studentSession, async (req, res) => {
|
||||
res.json({ total, completed, details: progress });
|
||||
});
|
||||
|
||||
// POST /api/progress/record — Phase 2A's progress relay, now Postgres-backed and also logging
|
||||
// a scenario_attempts row (new in Phase 2B — real per-attempt history, not just aggregate
|
||||
// status). Called by the Gateway after a real validate/answer succeeded against the student's
|
||||
// own Lab — never re-runs validation itself. See relayScenarioOutcome in
|
||||
// platform/gateway/src/server.js.
|
||||
app.post('/api/progress/record', studentSession, async (req, res) => {
|
||||
app.post('/api/progress/record', wrap(studentSession), async (req, res) => {
|
||||
const { scenario_id, status, environment_id, validation_count } = req.body || {};
|
||||
if (!scenario_id || !['completed', 'in_progress'].includes(status)) {
|
||||
return res.status(400).json({ error: 'scenario_id and a valid status are required' });
|
||||
@@ -166,8 +189,7 @@ app.post('/api/progress/record', studentSession, async (req, res) => {
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
// STUDENT-FACING: environment lifecycle (moved here from the Gateway in Phase 2A — Phase 2B
|
||||
// consolidates ALL Provisioner access into this one service)
|
||||
// STUDENT-FACING: environment lifecycle
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function publicEnvView(env) {
|
||||
@@ -185,14 +207,14 @@ async function publicEnvView(env) {
|
||||
return view;
|
||||
}
|
||||
|
||||
app.get('/api/environment/status', studentSession, async (req, res) => {
|
||||
app.get('/api/environment/status', wrap(studentSession), async (req, res) => {
|
||||
const { status, body } = await provisioner.listForStudent(req.studentId);
|
||||
if (status !== 200) return res.status(502).json({ error: 'provisioner_unreachable' });
|
||||
const active = body.find((e) => !['destroyed', 'error'].includes(e.status)) || body.find((e) => e.status === 'error');
|
||||
res.json(await publicEnvView(active));
|
||||
});
|
||||
|
||||
app.post('/api/environment/launch', studentSession, async (req, res) => {
|
||||
app.post('/api/environment/launch', wrap(studentSession), async (req, res) => {
|
||||
const existing = await provisioner.listForStudent(req.studentId);
|
||||
const active = existing.body?.find((e) => ['requested', 'provisioning', 'running', 'resetting'].includes(e.status));
|
||||
if (active) return res.json(await publicEnvView(active));
|
||||
@@ -206,7 +228,7 @@ app.post('/api/environment/launch', studentSession, async (req, res) => {
|
||||
res.json(await publicEnvView({ environment_id: body.environment_id, status: body.status, routing_token: body.routing_token }));
|
||||
});
|
||||
|
||||
app.post('/api/environment/reset', studentSession, async (req, res) => {
|
||||
app.post('/api/environment/reset', wrap(studentSession), async (req, res) => {
|
||||
const existing = await provisioner.listForStudent(req.studentId);
|
||||
const env = existing.body?.find((e) => e.status !== 'destroyed');
|
||||
if (!env) return res.status(404).json({ error: 'no_environment' });
|
||||
@@ -215,24 +237,41 @@ app.post('/api/environment/reset', studentSession, async (req, res) => {
|
||||
res.json(await publicEnvView(body));
|
||||
});
|
||||
|
||||
// Internal — the Gateway calls this to resolve a routing token to its owning environment
|
||||
// (student_id, internal_ip, status) for Lab proxy ownership checks. Same trust boundary as
|
||||
// the Provisioner's own by-token endpoint (internal callers only, never the browser).
|
||||
// Internal — the Gateway calls this to resolve a routing token to its owning environment.
|
||||
app.get('/api/environment/by-token/:token', async (req, res) => {
|
||||
const { status, body } = await provisioner.getEnvironmentByToken(req.params.token);
|
||||
res.status(status).json(body);
|
||||
});
|
||||
|
||||
require('./admin-routes')(app, { adminSession, verifyAdminPassword, sessions, ADMIN_COOKIE, provisioner, curriculum, getPool, loadProgress, saveOneProgress });
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
// INSTRUCTOR-FACING: auth + admin API
|
||||
// ═══════════════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
require('./instructor-auth-routes')(app, { pool: getPool(), sessions, instructorSession: wrap(instructorSession) });
|
||||
|
||||
require('./admin-routes')(app, {
|
||||
instructorSession: wrap(instructorSession), rbac: createRbac(getPool()), provisioner, curriculum,
|
||||
getPool, loadProgress, saveOneProgress, recordAudit,
|
||||
});
|
||||
|
||||
// Fallback to the platform-shell SPA for any unmatched non-API route
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(FRONTEND_DIST, 'index.html'));
|
||||
});
|
||||
|
||||
// Last-resort error handler — an unhandled DB error (or anything else) in any async route
|
||||
// reaches here via next(err) instead of crashing the process or leaking a stack trace to the
|
||||
// client. Every new async route/middleware added in Phase 3A funnels through this.
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[unhandled route error]', err);
|
||||
if (res.headersSent) return next(err);
|
||||
res.status(500).json({ error: 'internal_error' });
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const applied = await runMigrations();
|
||||
if (applied.length) console.log('Applied migrations:', applied.join(', '));
|
||||
sessions.startSessionSweeper(getPool());
|
||||
app.listen(PORT, () => console.log(`Platform API listening on :${PORT}`));
|
||||
}
|
||||
|
||||
|
||||
+104
-52
@@ -1,67 +1,119 @@
|
||||
// Server-controlled session store — replaces Phase 2A's pilot ?sid= mechanism (client-supplied,
|
||||
// trivially forgeable) with opaque, server-issued tokens the browser can't fabricate or guess.
|
||||
// In-memory, single-process: fine for a pilot admin/classroom tool (see
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md "Known limitations" — a Platform API restart logs
|
||||
// everyone out, which is an acceptable tradeoff at this scale, not something silently ignored).
|
||||
// Persistent session store (Phase 3A) — replaces Phase 2B's in-memory studentSessions/
|
||||
// adminSessions Maps. Sessions live in Postgres (the `sessions` table), so a Platform API
|
||||
// restart no longer logs everyone out, and individual sessions can be revoked/audited.
|
||||
//
|
||||
// Two separate, non-overlapping session kinds (student / admin) so a student session can never
|
||||
// be mistaken for an admin one regardless of any bug elsewhere — they use different cookie
|
||||
// names AND different token namespaces.
|
||||
// The browser only ever holds an opaque, unguessable bearer token (32 random bytes,
|
||||
// base64url). The server stores only a SHA-256 hash of it (token_hash) — a database dump
|
||||
// can't be replayed as working session cookies, same principle as password hashing.
|
||||
//
|
||||
// Two independent lifecycles, matching Phase 2B's separation: student sessions and instructor
|
||||
// sessions never share a token namespace or a cookie name, so a bug can't confuse one for the
|
||||
// other. Each has an absolute expiry (expires_at, fixed at creation) and a sliding idle expiry
|
||||
// (idle_expires_at, pushed forward on every authenticated use, never past expires_at) —
|
||||
// whichever is hit first ends the session.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const STUDENT_TTL_MS = 8 * 60 * 60 * 1000; // 8h — a work/class session
|
||||
const ADMIN_TTL_MS = 4 * 60 * 60 * 1000; // 4h — shorter, this is a privileged session
|
||||
|
||||
const studentSessions = new Map(); // token -> { student_id, expiresAt }
|
||||
const adminSessions = new Map(); // token -> { expiresAt }
|
||||
const STUDENT_TTL_MS = Number(process.env.STUDENT_SESSION_TTL_HOURS || 8) * 60 * 60 * 1000;
|
||||
const STUDENT_IDLE_TTL_MS = Number(process.env.STUDENT_SESSION_IDLE_HOURS || 2) * 60 * 60 * 1000;
|
||||
const INSTRUCTOR_TTL_MS = Number(process.env.INSTRUCTOR_SESSION_TTL_HOURS || 12) * 60 * 60 * 1000;
|
||||
const INSTRUCTOR_IDLE_TTL_MS = Number(process.env.INSTRUCTOR_SESSION_IDLE_HOURS || 2) * 60 * 60 * 1000;
|
||||
|
||||
function makeToken() {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function createStudentSession(studentId) {
|
||||
function hashToken(token) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
async function createSession(pool, { type, studentId, instructorId, ttlMs, idleTtlMs, metadata }) {
|
||||
const token = makeToken();
|
||||
studentSessions.set(token, { student_id: studentId, expiresAt: Date.now() + STUDENT_TTL_MS });
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveStudentSession(token) {
|
||||
if (!token) return null;
|
||||
const s = studentSessions.get(token);
|
||||
if (!s || s.expiresAt < Date.now()) { studentSessions.delete(token); return null; }
|
||||
return s.student_id;
|
||||
}
|
||||
|
||||
function destroyStudentSession(token) {
|
||||
studentSessions.delete(token);
|
||||
}
|
||||
|
||||
function createAdminSession() {
|
||||
const token = makeToken();
|
||||
adminSessions.set(token, { expiresAt: Date.now() + ADMIN_TTL_MS });
|
||||
return token;
|
||||
}
|
||||
|
||||
function resolveAdminSession(token) {
|
||||
if (!token) return false;
|
||||
const s = adminSessions.get(token);
|
||||
if (!s || s.expiresAt < Date.now()) { adminSessions.delete(token); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
function destroyAdminSession(token) {
|
||||
adminSessions.delete(token);
|
||||
}
|
||||
|
||||
// Periodic sweep so long-idle sessions don't accumulate forever in memory.
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [token, s] of studentSessions) if (s.expiresAt < now) studentSessions.delete(token);
|
||||
for (const [token, s] of adminSessions) if (s.expiresAt < now) adminSessions.delete(token);
|
||||
}, 10 * 60 * 1000).unref();
|
||||
const expiresAt = new Date(now + ttlMs);
|
||||
const idleExpiresAt = new Date(Math.min(now + idleTtlMs, expiresAt.getTime()));
|
||||
await pool.query(
|
||||
`INSERT INTO sessions (session_type, student_id, instructor_id, token_hash, expires_at, idle_expires_at, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[type, studentId || null, instructorId || null, hashToken(token), expiresAt, idleExpiresAt, metadata ? JSON.stringify(metadata) : null]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
// Validates + slides the idle window forward in one round trip. Returns null on any failure
|
||||
// (missing, expired, idle-timed-out, or revoked) without distinguishing why — the caller
|
||||
// doesn't need to know, and not distinguishing avoids leaking session state to a client.
|
||||
async function resolveSession(pool, token, type, idleTtlMs) {
|
||||
if (!token) return null;
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE sessions
|
||||
SET last_seen_at = now(), idle_expires_at = LEAST(now() + $3::interval, expires_at)
|
||||
WHERE token_hash = $1 AND session_type = $2 AND revoked_at IS NULL
|
||||
AND expires_at > now() AND idle_expires_at > now()
|
||||
RETURNING student_id, instructor_id`,
|
||||
[hashToken(token), type, `${idleTtlMs} milliseconds`]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function revokeSession(pool, token) {
|
||||
if (!token) return;
|
||||
await pool.query(`UPDATE sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, [hashToken(token)]);
|
||||
}
|
||||
|
||||
// ── Student sessions ─────────────────────────────────────────────────────────────────────
|
||||
function createStudentSession(pool, studentId) {
|
||||
return createSession(pool, { type: 'student', studentId, ttlMs: STUDENT_TTL_MS, idleTtlMs: STUDENT_IDLE_TTL_MS });
|
||||
}
|
||||
async function resolveStudentSession(pool, token) {
|
||||
const row = await resolveSession(pool, token, 'student', STUDENT_IDLE_TTL_MS);
|
||||
return row ? row.student_id : null;
|
||||
}
|
||||
const destroyStudentSession = revokeSession;
|
||||
|
||||
// ── Instructor sessions ──────────────────────────────────────────────────────────────────
|
||||
function createInstructorSession(pool, instructorId) {
|
||||
return createSession(pool, { type: 'instructor', instructorId, ttlMs: INSTRUCTOR_TTL_MS, idleTtlMs: INSTRUCTOR_IDLE_TTL_MS });
|
||||
}
|
||||
async function resolveInstructorSession(pool, token) {
|
||||
const row = await resolveSession(pool, token, 'instructor', INSTRUCTOR_IDLE_TTL_MS);
|
||||
return row ? row.instructor_id : null;
|
||||
}
|
||||
const destroyInstructorSession = revokeSession;
|
||||
|
||||
// Revokes every live session for an instructor — used for the owner-facing "revoke all
|
||||
// sessions" control, and automatically after a password change (except the session that just
|
||||
// changed the password, so the user isn't immediately logged out of their own action).
|
||||
async function revokeAllInstructorSessions(pool, instructorId, exceptToken) {
|
||||
const exceptHash = exceptToken ? hashToken(exceptToken) : null;
|
||||
await pool.query(
|
||||
`UPDATE sessions SET revoked_at = now()
|
||||
WHERE instructor_id = $1 AND revoked_at IS NULL AND ($2::text IS NULL OR token_hash != $2)`,
|
||||
[instructorId, exceptHash]
|
||||
);
|
||||
}
|
||||
|
||||
async function listInstructorSessions(pool, instructorId) {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT session_id, created_at, last_seen_at, expires_at, revoked_at
|
||||
FROM sessions WHERE instructor_id = $1 ORDER BY created_at DESC LIMIT 20`,
|
||||
[instructorId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Periodic cleanup of long-dead rows so the table doesn't grow forever — cosmetic/storage only,
|
||||
// not a security control (expired/revoked rows already fail resolveSession's WHERE clause).
|
||||
function startSessionSweeper(pool) {
|
||||
setInterval(() => {
|
||||
pool.query(`DELETE FROM sessions WHERE expires_at < now() - interval '7 days'`).catch((e) => {
|
||||
console.error('[session sweep] failed:', e.message);
|
||||
});
|
||||
}, 60 * 60 * 1000).unref();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createStudentSession, resolveStudentSession, destroyStudentSession,
|
||||
createAdminSession, resolveAdminSession, destroyAdminSession,
|
||||
createInstructorSession, resolveInstructorSession, destroyInstructorSession,
|
||||
revokeAllInstructorSessions, listInstructorSessions, startSessionSweeper,
|
||||
};
|
||||
|
||||
@@ -68,8 +68,11 @@ services:
|
||||
PLATFORM_DB_URL: postgres://postgres:postgres@localhost:5433/rootnode_platform
|
||||
PROVISIONER_URL: "http://localhost:4100"
|
||||
PROVISIONER_SHARED_SECRET: ${PROVISIONER_SHARED_SECRET:?set a real secret, do not use a default}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set an instructor password, do not use a default}
|
||||
PLATFORM_API_PORT: "4300"
|
||||
# No ADMIN_PASSWORD anymore (Phase 3A) — instructor accounts live in Postgres, created via
|
||||
# `npm run bootstrap-owner` for the first owner and the admin portal after that.
|
||||
# COOKIE_SECURE: "1" # set once served over real HTTPS
|
||||
# TRUST_PROXY: "1" # set once fronted by a trusted reverse proxy
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -23,6 +23,7 @@ const envCache = require('./env-cache');
|
||||
const PORT = Number(process.env.GATEWAY_PORT) || 4200;
|
||||
const PLATFORM_API_URL = process.env.PLATFORM_API_URL || 'http://localhost:4300';
|
||||
const STUDENT_COOKIE = 'rn_session';
|
||||
const STUDENT_CSRF_COOKIE = 'rn_csrf_s'; // Phase 3A — see relayScenarioOutcome for why the Gateway needs this too
|
||||
// Routing hint only — NOT a security check by itself (ownership is always re-verified via the
|
||||
// session, see checkOwnership/resolveOwnedRunningEnvironment). Its only job is disambiguating
|
||||
// "this unprefixed request came from inside the Lab UI" from "this came from the dashboard,"
|
||||
@@ -30,7 +31,9 @@ const STUDENT_COOKIE = 'rn_session';
|
||||
// dashboard too, and naively using it hijacked EVERY unmatched dashboard request (including
|
||||
// GET /api/session/whoami) to the student's Lab container once one existed. Found by testing.
|
||||
const LAB_ACTIVE_COOKIE = 'rn_lab_active';
|
||||
const SESSION_CACHE_TTL_MS = 5000;
|
||||
// Phase 3A: shortened from 5000ms so a revoked/expired session stops working here within a
|
||||
// tighter, testable bound (see platform/test/e2e-phase3a.js "revoked mid-use" case).
|
||||
const SESSION_CACHE_TTL_MS = 2000;
|
||||
|
||||
const app = express();
|
||||
// Deliberately no express.json() globally — see the Phase 2A note this carries forward:
|
||||
@@ -120,15 +123,26 @@ async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcom
|
||||
const passed = extractOutcome(body);
|
||||
if (passed !== null) {
|
||||
const validationCount = Array.isArray(body.checks) ? body.checks.length : undefined;
|
||||
const token = parseCookies(req)[STUDENT_COOKIE];
|
||||
const cookies = parseCookies(req);
|
||||
const token = cookies[STUDENT_COOKIE];
|
||||
const csrf = cookies[STUDENT_CSRF_COOKIE] || '';
|
||||
// Phase 3A: /api/progress/record is CSRF-protected like every other student mutation. The
|
||||
// double-submit check needs BOTH the rn_csrf_s cookie AND a matching X-CSRF-Token header on
|
||||
// the request Platform API actually sees — so both are forwarded here, not just the header
|
||||
// (an earlier version of this fix sent only the header, which still 403'd).
|
||||
fetch(`${PLATFORM_API_URL}/api/progress/record`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Cookie: `${STUDENT_COOKIE}=${token}` },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `${STUDENT_COOKIE}=${token}; ${STUDENT_CSRF_COOKIE}=${csrf}`,
|
||||
'X-CSRF-Token': csrf,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
scenario_id: req.params.scenarioId, status: passed ? 'completed' : 'in_progress',
|
||||
environment_id: env.environment_id, validation_count: validationCount,
|
||||
}),
|
||||
}).catch((e) => console.error('[progress relay] failed:', e.message));
|
||||
}).then((r) => { if (!r.ok) console.error('[progress relay] Platform API rejected the update:', r.status); })
|
||||
.catch((e) => console.error('[progress relay] failed:', e.message));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,26 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 2A student-flow regression, updated for Phase 2B's session-cookie architecture.
|
||||
// Originally used X-Student-Id headers and a ?sid= query param — both mechanisms are gone as
|
||||
// of Phase 2B (docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md §3); this version exercises the exact
|
||||
// same flow through the real rn_session cookie instead, since that's how it actually works now.
|
||||
// Phase 2A student-flow regression, updated for Phase 3A's persistent session + per-instructor
|
||||
// auth model. Originally used X-Student-Id headers and a ?sid= query param — both mechanisms
|
||||
// are long gone; this version exercises the same flow through the real rn_session cookie, with
|
||||
// the admin roster-add step now going through a real instructor account instead of a shared
|
||||
// ADMIN_PASSWORD.
|
||||
//
|
||||
// Runs against a live platform/docker-compose.dev.yml stack. Slow (real k3s boots). No
|
||||
// framework, matching project convention.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d && node test/e2e-phase2a.js
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase2a.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const COHORT = `e2e-test-${Date.now()}`;
|
||||
|
||||
// Minimal manual cookie jar — Node's fetch doesn't persist cookies across calls the way a
|
||||
// browser does, so each "session" here explicitly carries its own Set-Cookie forward.
|
||||
function makeSession() {
|
||||
let cookie = '';
|
||||
return {
|
||||
async request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
if (setCookie) cookie = setCookie.split(';')[0];
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||
return { status: res.status, body: json };
|
||||
},
|
||||
rawFetch(path) {
|
||||
return fetch(`${GW}${path}`, { headers: cookie ? { Cookie: cookie } : {} });
|
||||
},
|
||||
};
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
@@ -42,10 +30,11 @@ async function test(name, fn) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 2A student-flow regression (Phase 2B session cookies) — slow, real k3s boots\n');
|
||||
console.log('Phase 2A student-flow regression (Phase 3A sessions/auth) — slow, real k3s boots\n');
|
||||
|
||||
const admin = makeSession();
|
||||
await admin.request('POST', '/api/admin/login', { password: process.env.ADMIN_PASSWORD });
|
||||
const admin = makeSession(GW);
|
||||
const { status: loginStatus } = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(loginStatus, 200, 'owner login must succeed — did you bootstrap one?');
|
||||
|
||||
let alice, bob;
|
||||
await test('roster: add two students via admin', async () => {
|
||||
@@ -55,8 +44,8 @@ async function main() {
|
||||
assert.notEqual(alice, bob);
|
||||
});
|
||||
|
||||
const aliceSession = makeSession();
|
||||
const bobSession = makeSession();
|
||||
const aliceSession = makeSession(GW);
|
||||
const bobSession = makeSession(GW);
|
||||
|
||||
await test('login: student session cookie is issued and required for curriculum', async () => {
|
||||
const { status } = await aliceSession.request('POST', '/api/login', { student_id: alice });
|
||||
|
||||
@@ -1,38 +1,28 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 2B classroom workflow — the actual acceptance test from
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md: instructor creates a cohort, adds three students,
|
||||
// assigns curriculum, prepares Labs for the whole cohort, two students complete different
|
||||
// scenarios, instructor resets one student's Lab, instructor destroys all cohort Labs after
|
||||
// class — progress survives every step.
|
||||
// Phase 2B classroom workflow — the acceptance test from
|
||||
// docs/PHASE2B_ADMIN_AND_PLATFORM_DATA.md, updated for Phase 3A's per-instructor accounts:
|
||||
// instructor creates a cohort, adds three students, assigns curriculum, prepares Labs for the
|
||||
// whole cohort, two students complete different scenarios, instructor resets one student's
|
||||
// Lab, instructor destroys all cohort Labs after class — progress survives every step.
|
||||
//
|
||||
// Runs against a live platform/docker-compose.dev.yml stack. Slow (three real k3s boots,
|
||||
// concurrently). No framework, matching project convention.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// ADMIN_PASSWORD=<value from platform/.env> node test/e2e-phase2b.js
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase2b.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
|
||||
if (!ADMIN_PASSWORD) { console.error('Set ADMIN_PASSWORD (see platform/.env)'); process.exit(1); }
|
||||
const COHORT_ID = `e2e-cohort-${Date.now()}`;
|
||||
|
||||
function makeSession() {
|
||||
let cookie = '';
|
||||
return {
|
||||
async request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (cookie) headers.Cookie = cookie;
|
||||
const res = await fetch(`${GW}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
const setCookie = res.headers.get('set-cookie');
|
||||
if (setCookie) cookie = setCookie.split(';')[0];
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||
return { status: res.status, body: json };
|
||||
},
|
||||
};
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const COHORT_ID = `e2e-cohort-${Date.now()}`;
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
@@ -41,15 +31,15 @@ async function test(name, fn) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 2B classroom workflow acceptance test — slow, real k3s boots\n');
|
||||
console.log('Phase 2B classroom workflow acceptance test (Phase 3A auth) — slow, real k3s boots\n');
|
||||
|
||||
const admin = makeSession();
|
||||
const other = makeSession();
|
||||
const admin = makeSession(GW);
|
||||
const other = makeSession(GW);
|
||||
|
||||
await test('admin auth: wrong password rejected, correct password issues a session', async () => {
|
||||
const bad = await admin.request('POST', '/api/admin/login', { password: 'definitely-wrong' });
|
||||
await test('instructor auth: wrong password rejected, correct password issues a session', async () => {
|
||||
const bad = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: 'definitely-wrong' });
|
||||
assert.equal(bad.status, 401);
|
||||
const good = await admin.request('POST', '/api/admin/login', { password: ADMIN_PASSWORD });
|
||||
const good = await admin.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(good.status, 200);
|
||||
});
|
||||
|
||||
@@ -89,8 +79,8 @@ async function main() {
|
||||
});
|
||||
|
||||
const [aliceId, bobId] = students;
|
||||
const alice = makeSession();
|
||||
const bob = makeSession();
|
||||
const alice = makeSession(GW);
|
||||
const bob = makeSession(GW);
|
||||
|
||||
await test('students: Alice and Bob log in and launch their ALREADY-PREPARED labs instantly', async () => {
|
||||
await alice.request('POST', '/api/login', { student_id: aliceId });
|
||||
@@ -140,9 +130,6 @@ async function main() {
|
||||
});
|
||||
|
||||
await test('regression: Phase 1 boundary still holds — no Lab container publishes a host port', async () => {
|
||||
// Sanity check via the admin environment list — this doesn't re-run the Provisioner's own
|
||||
// iptables tests (see platform/provisioner/test/run.js for those), just confirms nothing
|
||||
// in the Phase 2B layer introduced a published port.
|
||||
const { body: envs } = await admin.request('GET', `/api/admin/students/${aliceId}/environment`);
|
||||
assert.ok(envs.every((e) => !e.internal_port || e.internal_port === 80));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env node
|
||||
// Phase 3A — identity and instructor access. Covers session persistence (including a real
|
||||
// Platform API restart), per-instructor auth (no shared ADMIN_PASSWORD), RBAC across
|
||||
// owner/admin/instructor, cohort-scoped instructor access, CSRF enforcement, login rate
|
||||
// limiting, audit logging, and a repeat of the Phase 2B classroom workflow with a real
|
||||
// instructor account instead of a shared password.
|
||||
//
|
||||
// Runs against a live platform/docker-compose.dev.yml stack, and shells out to `docker compose
|
||||
// restart api` for the restart-persistence test — so it must run from a checkout where that
|
||||
// compose file's `api` service is actually running (see Usage). No framework, matching project
|
||||
// convention.
|
||||
//
|
||||
// Usage: cd platform && docker compose -f docker-compose.dev.yml up -d
|
||||
// (cd api && PLATFORM_DB_URL=... node bootstrap-owner.js OWNER_EMAIL "Owner" OWNER_PASSWORD)
|
||||
// OWNER_EMAIL=... OWNER_PASSWORD=... node test/e2e-phase3a.js
|
||||
|
||||
const assert = require('assert');
|
||||
const { execSync } = require('child_process');
|
||||
const { makeSession } = require('./session-helper');
|
||||
|
||||
const GW = process.env.GATEWAY_URL || 'http://localhost:4200';
|
||||
const API = process.env.PLATFORM_API_URL || 'http://localhost:4300'; // direct — a few checks (restart) need to bypass the Gateway's proxy caching
|
||||
const OWNER_EMAIL = process.env.OWNER_EMAIL;
|
||||
const OWNER_PASSWORD = process.env.OWNER_PASSWORD;
|
||||
const SKIP_RESTART_TEST = process.env.SKIP_RESTART_TEST === '1';
|
||||
if (!OWNER_EMAIL || !OWNER_PASSWORD) {
|
||||
console.error('Set OWNER_EMAIL and OWNER_PASSWORD (bootstrap one first with platform/api/bootstrap-owner.js)');
|
||||
process.exit(1);
|
||||
}
|
||||
const RUN_ID = Date.now();
|
||||
|
||||
let passed = 0, failed = 0;
|
||||
async function test(name, fn) {
|
||||
try { await fn(); console.log(` ok — ${name}`); passed++; }
|
||||
catch (e) { console.error(` FAIL — ${name}\n ${e.message}`); failed++; }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Phase 3A identity/RBAC acceptance test — slow, real k3s boots + a real API restart\n');
|
||||
|
||||
const owner = makeSession(GW);
|
||||
|
||||
await test('instructor auth: no account enumeration (unknown email and wrong password look identical)', async () => {
|
||||
const unknown = await owner.request('POST', '/api/auth/instructor/login', { email: `nobody-${RUN_ID}@rootnode.test`, password: 'whatever12345' });
|
||||
const wrongPw = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: 'definitely-wrong-password' });
|
||||
assert.equal(unknown.status, 401);
|
||||
assert.equal(wrongPw.status, 401);
|
||||
assert.deepEqual(unknown.body, wrongPw.body);
|
||||
});
|
||||
|
||||
await test('instructor auth: valid login issues a session + CSRF cookie', async () => {
|
||||
const { status } = await owner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
assert.equal(status, 200);
|
||||
assert.ok(owner.hasCookie('rn_instructor'));
|
||||
assert.ok(owner.hasCookie('rn_csrf_i'));
|
||||
});
|
||||
|
||||
await test('CSRF: mutating request without the header is rejected', async () => {
|
||||
const res = await fetch(`${GW}/api/admin/cohorts`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: `rn_instructor=${owner.getCookie('rn_instructor')}` },
|
||||
body: JSON.stringify({ cohort_id: `csrf-should-fail-${RUN_ID}`, name: 'nope' }),
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
await test('CSRF: mutating request WITH the header succeeds', async () => {
|
||||
const { status } = await owner.request('POST', '/api/admin/cohorts', { cohort_id: `csrf-ok-${RUN_ID}`, name: 'CSRF OK' });
|
||||
assert.equal(status, 201);
|
||||
});
|
||||
|
||||
await test('rate limiting: repeated bad logins for one identity get throttled', async () => {
|
||||
const probe = makeSession(GW);
|
||||
let sawThrottle = false;
|
||||
for (let i = 0; i < 9; i++) {
|
||||
const { status } = await probe.request('POST', '/api/auth/instructor/login', { email: `throttle-${RUN_ID}@rootnode.test`, password: 'wrong' });
|
||||
if (status === 429) sawThrottle = true;
|
||||
}
|
||||
assert.ok(sawThrottle, 'expected a 429 within 9 rapid attempts');
|
||||
});
|
||||
|
||||
let adminId, instructorId;
|
||||
await test('owner: create an admin-role and an instructor-role account', async () => {
|
||||
const a = await owner.request('POST', '/api/admin/instructors', { email: `admin-${RUN_ID}@rootnode.test`, display_name: 'E2E Admin', role: 'admin', password: 'AdminPassword123' });
|
||||
assert.equal(a.status, 201);
|
||||
adminId = a.body.instructor_id;
|
||||
const i = await owner.request('POST', '/api/admin/instructors', { email: `teach-${RUN_ID}@rootnode.test`, display_name: 'E2E Teacher', role: 'instructor', password: 'TeacherPassword123' });
|
||||
assert.equal(i.status, 201);
|
||||
instructorId = i.body.instructor_id;
|
||||
});
|
||||
|
||||
const teacher = makeSession(GW);
|
||||
await test('RBAC: instructor role rejected from owner-only routes', async () => {
|
||||
await teacher.request('POST', '/api/auth/instructor/login', { email: `teach-${RUN_ID}@rootnode.test`, password: 'TeacherPassword123' });
|
||||
const { status } = await teacher.request('GET', '/api/admin/instructors');
|
||||
assert.equal(status, 403);
|
||||
});
|
||||
|
||||
await test('RBAC: instructor role rejected from admin/owner cohort-creation route', async () => {
|
||||
const { status } = await teacher.request('POST', '/api/admin/cohorts', { cohort_id: `teacher-should-fail-${RUN_ID}`, name: 'nope' });
|
||||
assert.equal(status, 403);
|
||||
});
|
||||
|
||||
const admin = makeSession(GW);
|
||||
await test('RBAC: admin role can create cohorts (below owner, above instructor)', async () => {
|
||||
await admin.request('POST', '/api/auth/instructor/login', { email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' });
|
||||
const { status } = await admin.request('POST', '/api/admin/cohorts', { cohort_id: `admin-ok-${RUN_ID}`, name: 'Admin OK' });
|
||||
assert.equal(status, 201);
|
||||
});
|
||||
|
||||
await test('RBAC: admin role rejected from owner-only instructor management', async () => {
|
||||
const { status } = await admin.request('GET', '/api/admin/instructors');
|
||||
assert.equal(status, 403);
|
||||
});
|
||||
|
||||
const scopedCohort = `scoped-${RUN_ID}`;
|
||||
const unscopedCohort = `unscoped-${RUN_ID}`;
|
||||
await test('cohort scoping: owner creates two cohorts, assigns the instructor to only one', async () => {
|
||||
assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: scopedCohort, name: 'Scoped' })).status, 201);
|
||||
assert.equal((await owner.request('POST', '/api/admin/cohorts', { cohort_id: unscopedCohort, name: 'Unscoped' })).status, 201);
|
||||
assert.equal((await owner.request('POST', `/api/admin/cohorts/${scopedCohort}/instructors`, { instructor_id: instructorId })).status, 200);
|
||||
});
|
||||
|
||||
await test('cohort scoping: instructor sees the assigned cohort, not the other', async () => {
|
||||
const { body } = await teacher.request('GET', '/api/admin/cohorts');
|
||||
const ids = body.map((c) => c.cohort_id);
|
||||
assert.ok(ids.includes(scopedCohort));
|
||||
assert.ok(!ids.includes(unscopedCohort));
|
||||
});
|
||||
|
||||
await test('cohort scoping: instructor allowed on the assigned cohort, 403 on the other', async () => {
|
||||
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${scopedCohort}/students`)).status, 200);
|
||||
assert.equal((await teacher.request('GET', `/api/admin/cohorts/${unscopedCohort}/students`)).status, 403);
|
||||
});
|
||||
|
||||
await test('owner-safety: cannot deactivate the last active owner', async () => {
|
||||
const { body: whoami } = await owner.request('GET', '/api/auth/session');
|
||||
const { status, body } = await owner.request('PATCH', `/api/admin/instructors/${whoami.instructor.instructor_id}`, { status: 'inactive' });
|
||||
assert.equal(status, 400);
|
||||
assert.equal(body.error, 'last_owner');
|
||||
});
|
||||
|
||||
await test('role/status changes take effect on the very next request, no separate revoke needed', async () => {
|
||||
assert.equal((await teacher.request('GET', '/api/admin/dashboard')).status, 200); // works before deactivation
|
||||
await owner.request('PATCH', `/api/admin/instructors/${instructorId}`, { status: 'inactive' });
|
||||
const { status } = await teacher.request('GET', '/api/admin/dashboard');
|
||||
assert.equal(status, 401);
|
||||
});
|
||||
|
||||
await test('password change: old password rejected afterward, other sessions revoked', async () => {
|
||||
const second = makeSession(GW);
|
||||
await second.request('POST', '/api/auth/instructor/login', { email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' });
|
||||
await admin.request('POST', '/api/auth/change-password', { old_password: 'AdminPassword123', new_password: 'NewAdminPassword456' });
|
||||
const stillOld = await fetch(`${GW}/api/auth/instructor/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: `admin-${RUN_ID}@rootnode.test`, password: 'AdminPassword123' }) });
|
||||
assert.equal(stillOld.status, 401);
|
||||
const newWorks = await fetch(`${GW}/api/auth/instructor/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: `admin-${RUN_ID}@rootnode.test`, password: 'NewAdminPassword456' }) });
|
||||
assert.equal(newWorks.status, 200);
|
||||
// The OTHER session (logged in before the change) should now be revoked.
|
||||
const { status } = await second.request('GET', '/api/auth/session');
|
||||
assert.equal(status, 401);
|
||||
});
|
||||
|
||||
await test('audit log: security events were actually recorded', async () => {
|
||||
const { body } = await owner.request('GET', '/api/admin/audit-events');
|
||||
const actions = body.map((e) => e.action);
|
||||
assert.ok(actions.includes('login'));
|
||||
assert.ok(actions.includes('authz_denied'));
|
||||
assert.ok(actions.includes('instructor_update'));
|
||||
assert.ok(body.every((e) => !JSON.stringify(e.metadata || {}).match(/password/i)), 'audit metadata must never contain password material');
|
||||
});
|
||||
|
||||
// ── Student session hardening ──────────────────────────────────────────────────────────
|
||||
let studentId;
|
||||
await test('roster: create a student for session tests', async () => {
|
||||
const { status, body } = await owner.request('POST', '/api/admin/students', { display_name: `E2E-Session-${RUN_ID}` });
|
||||
assert.equal(status, 201);
|
||||
studentId = body.student_id;
|
||||
});
|
||||
|
||||
const student = makeSession(GW);
|
||||
await test('student session: cannot call admin routes', async () => {
|
||||
await student.request('POST', '/api/login', { student_id: studentId });
|
||||
const { status } = await student.request('GET', '/api/admin/dashboard');
|
||||
assert.equal(status, 401);
|
||||
});
|
||||
|
||||
await test('student session: logout revokes, curriculum then rejected', async () => {
|
||||
assert.equal((await student.request('GET', '/api/curriculum/path')).status, 200);
|
||||
await student.request('POST', '/api/logout');
|
||||
assert.equal((await student.request('GET', '/api/curriculum/path')).status, 401);
|
||||
});
|
||||
|
||||
// ── Session persistence across a real restart ──────────────────────────────────────────
|
||||
if (!SKIP_RESTART_TEST) {
|
||||
await test('session persistence: instructor AND student sessions survive a Platform API restart', async () => {
|
||||
const persistOwner = makeSession(GW);
|
||||
await persistOwner.request('POST', '/api/auth/instructor/login', { email: OWNER_EMAIL, password: OWNER_PASSWORD });
|
||||
const persistStudent = makeSession(GW);
|
||||
await persistStudent.request('POST', '/api/login', { student_id: studentId });
|
||||
|
||||
execSync('docker compose -f docker-compose.dev.yml restart api', { cwd: __dirname + '/..', stdio: 'pipe' });
|
||||
// Give the container a moment to re-apply migrations and start listening again.
|
||||
let up = false;
|
||||
for (let i = 0; i < 30 && !up; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
try { up = (await fetch(`${API}/api/roster/cohorts`)).ok; } catch { /* still restarting */ }
|
||||
}
|
||||
assert.ok(up, 'Platform API did not come back up in time');
|
||||
|
||||
assert.equal((await persistOwner.request('GET', '/api/auth/session')).status, 200);
|
||||
assert.equal((await persistStudent.request('GET', '/api/curriculum/path')).status, 200);
|
||||
});
|
||||
} else {
|
||||
console.log(' (skipped) session persistence: restart test — SKIP_RESTART_TEST=1');
|
||||
}
|
||||
|
||||
console.log(`\n${passed} passed, ${failed} failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('Test run crashed:', e); process.exit(1); });
|
||||
@@ -0,0 +1,53 @@
|
||||
// Shared cookie-jar test helper for the e2e suites (Phase 2A/2B/3A). Node's fetch doesn't
|
||||
// persist cookies across calls the way a browser does, and Phase 3A's CSRF double-submit
|
||||
// pattern means every state-changing request needs an X-CSRF-Token header echoing whichever
|
||||
// rn_csrf_* cookie the jar is currently holding — this centralizes both instead of repeating
|
||||
// slightly-different versions in every test file.
|
||||
|
||||
function makeSession(baseUrl) {
|
||||
const jar = new Map(); // cookie name -> value
|
||||
|
||||
function cookieHeader() {
|
||||
return [...jar].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
}
|
||||
function csrfToken() {
|
||||
return jar.get('rn_csrf_s') || jar.get('rn_csrf_i') || '';
|
||||
}
|
||||
function absorbSetCookie(res) {
|
||||
const cookies = typeof res.headers.getSetCookie === 'function' ? res.headers.getSetCookie() : [];
|
||||
for (const c of cookies) {
|
||||
const [pair] = c.split(';');
|
||||
const eq = pair.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
const name = pair.slice(0, eq).trim();
|
||||
const value = pair.slice(eq + 1).trim();
|
||||
if (value === '') jar.delete(name); else jar.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async request(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const cookies = cookieHeader();
|
||||
if (cookies) headers.Cookie = cookies;
|
||||
if (!['GET', 'HEAD'].includes(method)) headers['X-CSRF-Token'] = csrfToken();
|
||||
const res = await fetch(`${baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
||||
absorbSetCookie(res);
|
||||
const text = await res.text();
|
||||
let json; try { json = JSON.parse(text); } catch { json = text; }
|
||||
return { status: res.status, body: json };
|
||||
},
|
||||
rawFetch(path) {
|
||||
const cookies = cookieHeader();
|
||||
return fetch(`${baseUrl}${path}`, { headers: cookies ? { Cookie: cookies } : {} });
|
||||
},
|
||||
hasCookie(name) { return jar.has(name); },
|
||||
getCookie(name) { return jar.get(name); },
|
||||
// Lets a test simulate "this browser was never issued a CSRF cookie" or "attach the wrong
|
||||
// one" for a negative CSRF test, without hand-rolling a second fetch call.
|
||||
setCookie(name, value) { jar.set(name, value); },
|
||||
clearCookie(name) { jar.delete(name); },
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { makeSession };
|
||||
Reference in New Issue
Block a user