ADR-047 — Online workspace: no student credential, therefore no write access¶
Status¶
Quiz implementation status (verified 2026-10-07): apps/codespace is
present (imported by M6-03) and Quiz's codespace module talks to it
(M6-06: the work mode, the grants, the sync, the start route, the sessions
summary); M6-04 makes it deployable (two instances on the engine VM,
prod and staging, by the CI: ADR-016's M6-04 amendment, the switch from
heig-classroom's portal in apps/codespace/deploy/RUNBOOK.md), M6-05
sizes the VM, and from M6-07 it launches online_seb from a seb
session; no SEB project opens to students before proof B is recorded. See
merge progress.
Amended (2026-10-11, security audit M11 and L25): the platform's service token is single use and bound to its request, and every token's lifetime is capped: ADR-078's amendment of that date.
Amended (2026-10-11, security audit L24): what a session may serve on the portal's one origin (amendment).
Amended (2026-10-11, security audit L7): the start route is a POST.
Scope: the start route of amendment "M6-06" (its path and method) and
M6-07's launch from a seb session. A navigable GET that froze the work
mode and minted a launch token could be triggered by a cross-site link.
The launch is now POST /app/api/projects/:projectId/workspace/start,
under the double-submit CSRF check of every session write; same access
rule (the caller's own portal session or the project's seb session, the
student branch with a claimed seat, the 404 otherwise; an impersonation
writes nothing, 403), same refusals as 409 { error } (the project page
shows them as ?workspace=<code>), and it answers { url }, the portal's
/launch?token=…, Cache-Control: no-store, which the page navigates to.
The portal is unchanged: it never linked to the old path. ADR-089's
station launch was already a POST.
Amended (2026-10-08, product owner): an administrator always holds the
workspace grant. Scope: amendment "M6-06" (B)'s last sentence, and (C)
for an administrator. An account whose role is admin (users.role, the
role rule's SUPER_ADMIN_EMAIL, whether or not a Super Powers session is
active) is granted with at least the default quota
(DEFAULT_MAX_ACTIVE_SESSIONS, 2), the most permissive of that and any
row its addresses hold. grantOf (modules/codespace/service.ts) is the
one place that says so, so the mode switch, the staff's view and the quota
sent to the portal (an administrator holding the quota as creator or
oldest owner) give one answer. The owner check is unchanged: an
administrator sets a mode on a course where they hold an owner seat, or
under Super Powers (ADR-054). The administration lists no grant row for
them and needs no switch.
Amended (2026-10-07, M6-07, D21 points 2–5): Safe Exam Browser for a
project. Scope: amendment "M6-06" (A)'s online_seb clauses and point 7.
Quiz builds the project's .seb (GET /app/api/projects/:id/seb, a
claimed seat of a published online_seb project); its start URL is Quiz's
one-time ticket (no second sign-in), its URL filter adds the portal's host
and SEB_EXTRA_ALLOWED_HOSTS. The ticket opens a seb session confined to
the project (ADR-027 addendum "M6-07"); from it, and from it only, the
start route mints the launch token with the seb claim, the session's
Config Key (seb_required otherwise). The portal's /launch in exam mode
refuses a token without the claim and checks SEB's header against it,
then the Browser Exam Keys when the project has some, then sets its
IP-bound exam_session. Point 7 as amended: the Browser Exam Keys are
optional on the portal (an empty list is the Config Key alone), and
Quiz sends none: BEK list: after proof B step 7 (per-student .seb
likely gives per-student BEKs) — SEB's BEK covers the configuration,
which is per student here (06 §6.3). online_seb is synced like
online. The portal builds no .seb any more (its /exam/:id.seb,
Config Key and salt are gone).
Amended (2026-10-07, M6-03, product owner): the portal's GitHub relay and
its own login. Scope: the portal side of points 2 and 6 below, nothing
on the platform side.
(a) Relay off. The relay and the development (Forgejo) forge are
imported, heig-classroom's App-backed GitHub forge is not, and the portal
runs with FORGE_KIND=none by default: a push lands in the session's
staging.git and its PushEvent is written (the proof of submission),
nothing is relayed to GitHub. The portal refuses to start, in every
environment, when GITHUB_APP_ID or GITHUB_APP_PRIVATE_KEY_PATH is set:
the only App it was ever configured with is heig-classroom's, which Quiz
never uses (root invariant 15, D23). Whether Quiz's own App key goes on the
engine VM, and so whether the relay is turned back on, is open: an ADR
at M6-04/M6-05 decides it. Until then the "only the portal relay writes"
of point 2 is read as "nothing writes from a workspace".
(b) No login of its own. The portal's OIDC login (/auth/*), its home
page with a Start button, its /teacher/sessions dashboard and its
standalone /exam/:id/start route are removed: a user exists only through
the platform's launch token, every portal account is a student, and an
exam opens through /launch only. A teacher sees an assignment's
workspaces in Quiz, which calls the portal's
GET /api/assignments/:id/sessions with a service token
(CodespaceSessionSummary, @quiz/contracts). The portal's seb/
directory is kept as is until M6-07 moves it onto packages/seb.
Amended (2026-10-07, M6-06, product owner): the work mode in Quiz.
Scope: points 1 to 6 on the platform side, as Quiz implements them; the
portal is unchanged.
(A) The mode. projects.work_mode (free | online | online_seb,
default free, migration 0079_codespace_module). It is set by its own
route, PUT /app/api/projects/:id/workspace/mode, in any state of the
project, never by the create or the patch. Point 3's one-way door becomes:
the mode is frozen once a workspace was launched for the project — the
first launch token issued to a student seat, codespace_projects.first_launch_at
— and any change after that is 409 work_mode_frozen; a staff seat's
launch (ADR-077, a teacher testing the project on their test repository)
never freezes it; before it, every mode may
change, free included. A group project stays free (F-PROJ-06): an
online mode on a group project, or group mode on an online project, is
409 work_mode_group. Point 2 applies from the next invitation: push in
free, pull in online, none in online_seb (the repository is
provisioned, nobody is invited; a second Accept answers it as it stands);
an invitation already sent keeps the permission it had. online_seb can
be set, but its start is refused outside Safe Exam Browser with a named
refusal (seb_required) until M6-07, and it is not synced: the portal
refuses an exam without Browser Exam Keys, which come with M6-07.
(B) Who. Only an owner of the course (ADR-068, requireCourseRole:
an assistant gets 403 owner_required, in the loader, before the body)
whose teacher_grants row has codespace_enabled = true may set a
non-free mode (403 codespace_not_granted otherwise; going back to free
needs no grant). The grant is two columns on teacher_grants,
codespace_enabled (false) and codespace_max_active_sessions (2),
edited by an administrator (PATCH /app/api/admin/teachers/:gid/codespace).
An account's grant is read on its VERIFIED addresses only (its verified
ones, and its sign-in address when the identity provider verified it), the
most permissive row winning, as the role rule does; the administrator, whose address cannot hold a grant, is not
granted (an administrator acts as an owner under Super Powers, ADR-054,
never past the grant) — superseded 2026-10-08: an administrator is always
granted (amendment above).
(C) Whose quota. The quota an online project consumes is carried by its
creator while they still hold an owner seat on the course, otherwise by
the oldest owner seat (quotaHolder, @quiz/domain); it is sent to
the portal with each sync (teacher and quota of
CodespaceAssignmentSync), and the portal enforces it at launch (its
429). Quiz does not count the live workspaces itself.
(D) Off. With CODESPACE_URL empty the codespace module registers no
route (every one a 404, indistinguishable from a missing entity), the
administration lists no grant, and a project's student view says no
workspace.
The two messages of point 6 are Quiz's: the sync is the codespace.sync
job (no singleton key: Quiz's queues dedupe nothing, #273; the PUT is
idempotent), its last outcome on codespace_projects with the staff's
Resync (POST …/workspace/sync); the start route is
GET /app/codespace/start/:projectId (the portal's classroomStartUrl
already builds that path), loaded through the classroom's student branch
with a claimed seat and the caller's own portal session — an
impersonation, a request carrying a Bearer token: the 404; a seb or kiosk session is
anonymous there (ADR-027's default deny) until M6-07 —; the token's jti
is audited (codespace.launch_issued), the token never; the decision is the pure
workspaceStartRefusal (@quiz/domain). The staff read the project's
workspaces through GET …/workspace/sessions, named by Quiz for the
classroom's students and by nobody otherwise (the portal's address of an
unmatched account is not sent).
Seeding, while the portal's forge is off (M6-03 amendment (a)): the
workspace is seeded from the project's distribution repository, which the
portal clones anonymously. A PUBLIC distribution repository seeds it; a
private one (Quiz's default) syncs, but cannot seed a workspace, until the
decision on Quiz's App on the engine VM (M6-04/M6-05).
Imported from heig-classroom (2026-09-30, merge task M0-03, ADR-035),
where it is ADR-013 — Quiz's own ADR-013 is pool sharing, so it takes the
next free number, 047. The body below is classroom's, verbatim. Read it
with the renames of the merge: assignment ⇒ project
(assignments.work_mode ⇒ projects.work_mode; the portal's own
/api/assignments/:id route is the portal's business); classroom
(org-bound, owned) ⇒ a Quiz course plus classroom, and "the owner
of the class" ⇒ the course's staff (D04), except for the quota below:
whose teacher_grants quota an online project consumes once several
staff share a course is for M6 to decide; a codespace session ⇒
workspace in Quiz's vocabulary; @hgc/codespace ⇒ @quiz/codespace;
CLASSROOM_URL ⇒ PLATFORM_URL; apps/server ⇒ apps/api;
classroom.chevallier.io ⇒ Quiz. The portal (apps/codespace) and this
mode come with phase M6, after the cutover (D09, settled 2026-10-01: the
workspace is rebuilt and tried in Quiz before any migration of it). How SEB reaches a project — the
platform building every .seb, Browser Exam Keys optional per activity —
is D21, settled 2026-10-01 on the suggestion, for M6; point 7 below holds
until M6 implements it. Its two HS256
messages are the cross-VM exception recorded in ADR-027's status.
Classroom's requirement ids (GR-, GH-, NFR-, AU-) are those of
heig-classroom's specification; docs/spec/02-exigences-fonctionnelles.md
receives their Quiz form (M0-04), and until then they are read in
heig-classroom's docs/.
Amended (2026-10-07, ADR-078, product owner): the open question of the M6-03 amendment (a) and of the seeding note above is settled — no App key on the engine VM; the portal seeds and relays with installation tokens Quiz issues for one repository (forge quiz, merge task M6-10).
Amended (2026-10-09, ADR-089, proposed): the M6-07 amendment's "seb claim only" — an online_seb project may also accept a kiosk station, whose launch token carries a kiosk claim instead.
Extended (2026-10-05, ADR-075, proposed): supervised evaluations may open the workspace through the workspace question type, without a git channel; the project rules below are unchanged.
Status in heig-classroom: Accepted (2026-09-17, portal milestone 2).
Context¶
The apps/codespace portal makes the student work inside a hardened container served by
code-server, possibly under Safe Exam Browser. Its founding invariant is that no secret
enters the student container: no GitHub token, no SSH key, no credential helper. It is the
container that pushes — through a relay authenticated by the source IP address on the
internal bridge — and not the student from their editor.
Now, in the historical classroom flow (the "free" mode), the student repository is
provisioned with push permission: the student clones and pushes with their own GitHub
account. Keeping that right in online mode would mean two write paths coexisting on the same
repository (the portal relay, and the student from any browser), which makes the content of
an exam indefensible: nothing distinguishes a commit produced in the supervised session from
a commit pushed from home.
We also have to decide who may turn the feature on. The container engine is a privileged
component on a dedicated VM with bounded capacity (a few dozen sessions); opening it to every
teacher of classroom.chevallier.io at once makes no sense while the pilot covers one or two
classes.
Decision¶
- An assignment has a work mode (
assignments.work_mode,WorkModeof the shared contract):free(unchanged),online,online_seb. The default isfree: every existing assignment keeps exactly its behaviour. - No student credential, therefore no write access. Provisioning
(
github/provision.ts) invites the student with the permission: free→push(the historical flow, strictly unchanged);online→pull: the student reads their repository and re-reads their commits, but only the portal relay writes to it — so there is no student credential to distribute, expire or revoke;online_seb→ no invitation at all: during an exam, the student has no access to the repository before grading. The anti force-push and anti-deletion ruleset (hgc-protect, GH-21..23) stays in place in all three modes: it also protects against the relay.- A one-way door. An assignment published in an
online*mode cannot go back tofree(409work_mode_frozen). Its repositories were provisioned without write access; going back tofreewould leave every student in front of a repository they cannot push to, and grantingpushafter the fact would contradict exactly the invariant this mode protects. A new assignment is created instead. - Activation by the administrator, teacher by teacher, with a quota of simultaneous
sessions: two columns on
teacher_grants(codespace_enabledatfalse,codespace_max_active_sessionsat 2). A teacher without that grant does not see the "Work mode" section of the form (the front end readsMe.codespace) and the API refuses any non-freemode with a 403 — the server-side check is the only authoritative one. - Global absence is possible. An empty
CODESPACE_URLmeans the feature does not exist: no administration column, no selector, and the/app/codespace/*routes answer 404. ACODESPACE_URLwithout aCODESPACE_LAUNCH_SECRETof at least 32 characters makes startup fail (ADR-010: secrets travel through the environment). - Two HS256-signed messages, never a cross import (the import rule of the root
CLAUDE.md): - classroom → portal:
PUT ${CODESPACE_URL}/api/assignments/:idwith aCodespaceAssignmentSyncbody and a 2-minuteServiceTokenClaimsinAuthorization: Bearer. The call goes through a pg-boss job (codespace.sync, singleton keycodespace:<assignment>, ADR-004/ADR-011): a portal that is restarting never makes saving an assignment fail, recovery is free, and the state of the last attempt (codespace_synced_at,codespace_sync_error) is shown to the teacher with a "Resync" button. - student → portal:
GET /app/codespace/start/:aidchecks enrollment, acceptance, publication and mode, issues a 5-minuteLaunchTokenClaimswith a randomjtiand redirects with a 303 to${CODESPACE_URL}/launch?token=…. It is a navigable GET because the portal also uses it as thestartURLof Safe Exam Browser, which can only navigate. Issuance is logged (codespace.launch_issued) by itsjti; the token itself never enters the log (AU-41). - Browser Exam Keys are teacher-side secrets. They are stored on the assignment
(
browser_exam_keys), sent to the portal in the synchronization message, and never included in a student payload.
Consequences¶
- In online mode a student can no longer push from their own machine: that is the intended
effect, but it means the portal becomes indispensable to submission. An outage of the
portal during an online lab is therefore a submission incident, not merely a comfort one —
the
freemode stays the default for everything that does not need supervision. - The grading CI is unchanged: it triggers on the relay's pushes exactly as on a student's, and the whole grading chain (GR-05..16) ignores the mode.
- The migration is purely additive (columns with defaults, no rewrite): the production
service gets
work_mode = 'free'everywhere and does not change behaviour. - The quota is carried by the owner of the class, not by the staff member who saves the assignment: it is the person running the course who consumes the VM's capacity.
- The portal stays extractable: all it knows of classroom is those two signed messages.
Rejected alternatives¶
- Keeping
pushin online mode and relying on supervision: two write paths on the same repository, indefensible exam content, and the "no secret in the container" invariant would lose its point since the student would have a credential anyway. - Distributing a short-lived token inside the container so the student pushes themselves: that is exactly the secret the container hardening forbids, and it would be exfiltrable from any terminal in the editor.
- A synchronous HTTP call to the portal when the assignment is saved: an unavailable portal VM would make an unrelated teacher action fail, and we would have to reinvent the recovery that pg-boss already provides.
- Global activation through an environment variable: it would be impossible to run one class without exposing all the others, and there would be nowhere to put the per-teacher quota.
- A single global quota rather than one per teacher: a teacher starting an exam would consume everyone else's capacity without any screen showing it.
Amendment 2026-10-11: one origin for every session (audit L24)¶
Scope: what the portal lets a session serve on its origin, shared by every
session (/s/<session>/). Decided by the product owner on 2026-10-11 (the
short fix):
- code-server runs with
--disable-proxy, and the portal refuses/s/<session>/proxy/<port>/and…/absproxy/<port>/itself (403). - The portal drops
Set-CookieandService-Worker-Allowedfrom every answer of code-server. - The portal serves
/vscode-remote-resourcewithContent-Security-Policy: default-src 'none'; sandboxandX-Content-Type-Options: nosniff. - The student image ships no notebook support: code-server's bundled
ipynbandnotebook-renderersextensions are deleted at build time (the orchestrator's decision of the same day, on the owner's authority).
Rationale: the port proxy put whatever a student's program serves on the shared origin, with its own headers, so it could set cookies on the whole origin or register a service worker over it; nothing in the product uses it. VS Code 1.137 already sends rule 3's headers, so the workbench is known to work with them (verified in Chromium through a real portal).
VS Code's webviews are served from the same origin and run script there:
a notebook's HTML output did so as soon as the .ipynb was opened, which
rule 4 closes (an .ipynb no longer opens as a notebook, only as text;
verified in Chromium).
Known gaps, closed only by a subdomain per session (each session its own
origin, a wildcard certificate): a markdown preview runs script once the
student turns its security off, and a student's process can take port 8080
for the moment between a SIGUSR1 to code-server and the container's exit.
Either can set a path=/ cookie in that student's own browser; neither can
widen a service worker's scope, and another browser never loads the page
without the session's cookie. An image that must serve a web application
waits for that fix.