diff --git a/docs/waves/decisions-2026-09-03.md b/docs/waves/decisions-2026-09-03.md new file mode 100644 index 0000000..6286cde --- /dev/null +++ b/docs/waves/decisions-2026-09-03.md @@ -0,0 +1,77 @@ +# Decisions — 2026-09-03 + +## D16: Okta admin bootstrap, break-glass posture, and the real scope of T10.4 + +Raised during hazard review for T10.4 (remove the local password path). D15 settled +*that* local passwords go away and Okta OIDC is the sole replacement; it did not settle +how an admin account gets named once there is no password to set, or what happens if +Okta itself is unreachable. Both are decided here. + +### Admin bootstrap + +`server/manage_users.py` stays the bootstrap tool — its own docstring already says so +("the `/api/auth/users` endpoint needs an existing admin, so you have to bootstrap one +here") — but it changes from *creating* an account to *promoting* one: + +- An operator with shell/DB access on the server runs it against an account that + already signed in through Okta once and was JIT-provisioned by T10.3 (landing at + `project_user`, per that task). The command sets `role = admin` on that existing row + by username. +- It does **not** create a `User` row from scratch and does not touch `password_hash` + (the column is gone after T10.4's migration). + +Rejected: minting a brand-new admin row by hand-typed username. `OKTA_IDENTITY_CLAIM`'s +exact format is still unconfirmed by security (open item carried from D15/wave-10.md). +A hand-typed username that doesn't exactly match what Okta actually sends produces a +second, orphaned account instead of promoting the real one. Promoting an +already-JIT-provisioned row sidesteps that entirely — it never has to guess the future +claim value. + +Ongoing (non-bootstrap) admin naming needs no new work: `html/users.js` already has a +live role dropdown (`roleSelect`, gated by server-supplied `grantable_roles`) that lets +an existing admin promote any other account, including one JIT-provisioned via Okta. +That path is unrelated to the password removal and keeps working unchanged. + +### Break glass + +No break-glass path, by design. If Okta is unreachable or misconfigured, the app is +unreachable for everyone, including admins, until Okta is restored. + +This matches the precedent already on record for the abandoned LDAPS design (D13/D14): +"LDAPS is the *only* path, no local fallback, no break-glass." Carried forward +deliberately rather than assumed to still apply, given Okta's failure modes differ from +an internal LDAP bind — considered and confirmed, not defaulted into. + +Rejected: a toggleable emergency local login gated behind an env flag. It would +reintroduce a stored local credential, exactly what D15 exists to eliminate, for a +scenario (Okta down) judged less likely and less costly than the standing risk of a +forgotten emergency backdoor. + +The server-shell CLI (`manage_users.py`, promoting an existing row) is not a formal +break-glass mechanism — it cannot help if no account has ever signed in through Okta — +but it is the same trust tier as "someone with SSH/container access to prod could +already edit the database directly," and it costs no new engineering. + +### T10.4 scope correction + +Hazard review found real call sites of `hash_password` / `verify_password` / +`password_problem` that the original T10.4 bullet in `wave-10.md` didn't name and that +break the moment those functions are deleted: + +- `create_user()` and `admin_reset_password()` in `server/app.py` (the admin console's + "add user" and "reset password" routes). +- `html/users.js`'s "add user" form (`nu-password` field) and "Reset password" button. +- `server/manage_users.py`'s `create`, `create-admin`, and `reset-password` subcommands + (see bootstrap section above for its replacement). +- `tests/browser_check.py` and `tests/launcher_check.py`, which call + `auth.hash_password()` to seed fixture rows. + +Also found: `server/smoketest.py` and `server/seed_demo.py` authenticate via +`POST /api/auth/login`, which T10.4 removes, and CLAUDE.md's own verification section +names both scripts as required checks. Fix folded into T10.4 rather than deferred to +T10.7: both scripts switch to minting a session with `auth.create_token()` and setting +the cookie directly, the same technique `tests/browser_check.py` already uses instead +of scripting a login form. No live Okta tenant needed, and T10.4 no longer depends on +T10.7's timing. + +`wave-10.md`'s T10.4 bullet is updated to reflect this full scope. diff --git a/docs/waves/wave-10.md b/docs/waves/wave-10.md index af12972..4365b61 100644 --- a/docs/waves/wave-10.md +++ b/docs/waves/wave-10.md @@ -29,9 +29,29 @@ Depends only on `main` as it stands after `D15`. Not sequenced behind any other yet answered. Build with a configurable claim name and a documented default, not a hardcoded one, so the answer can drop in without a code change. -- **T10.4 — Remove the local password path entirely.** Drop `password_hash`, remove the - bcrypt-based `login()`, remove the username/password form. Real deletion, matching - `D15`'s "full replacement," not a toggle or a fallback. +- **T10.4 — Remove the local password path entirely.** Drop `password_hash` (Alembic + migration; plain `op.drop_column`, matching existing precedent for other NOT NULL + columns on `users` — no `batch_alter_table` needed), remove the bcrypt-based + `login()`, remove the username/password form. Real deletion, matching `D15`'s "full + replacement," not a toggle or a fallback. Scope corrected by `D16` after hazard + review turned up more call sites than the original bullet named: + - `create_user()` and `admin_reset_password()` in `server/app.py` (admin console's + "add user" and "reset password" routes) — rework to drop the password field + entirely rather than break. + - `html/users.js`'s "add user" form (`nu-password`) and "Reset password" button — + matching frontend change. + - `server/manage_users.py` — reworked per `D16` from account *creation* to + *promotion*: `create-admin`/`create`/`reset-password` (password-based) are + replaced by a promote-by-username command that operates on a row Okta's JIT + provisioning (T10.3) already created, never a hand-typed new one. This is now + the documented admin-bootstrap path — see `D16`. + - `tests/browser_check.py` and `tests/launcher_check.py` — stop calling + `auth.hash_password()` to seed fixture rows. + - `server/smoketest.py` and `server/seed_demo.py` — currently authenticate via + `POST /api/auth/login`. Switch to minting a session with `auth.create_token()` + directly, the same technique `browser_check.py` already uses, so both scripts + (named explicitly in `CLAUDE.md`'s verification section) keep working without + depending on `T10.7`'s timing. - **T10.5 — Frontend: login becomes a redirect, not a form.** `login.html`/`login.js` change to a "Sign in with Okta" flow. Sign-out lands back on the app's own login page. @@ -53,3 +73,6 @@ Depends only on `main` as it stands after `D15`. Not sequenced behind any other - Final confirmation of the redirect/callback URI (`https://wp.controls.dev/api/auth/okta/callback` proposed, pending security). - The `Business Technology Group` pilot assignment in Okta. + +Closed since first written: admin bootstrap and break-glass posture, previously open +questions, decided in `D16` (2026-09-03) and folded into `T10.4` above.