D16: Okta admin bootstrap and break-glass posture; correct T10.4 scope

Decision, raised during T10.4 hazard review:

- Admin bootstrap: manage_users.py moves from creating an admin account to
  promoting an existing one, by username, on a row Okta's JIT provisioning
  (T10.3) already created. Rejected blind account creation — the exact
  OKTA_IDENTITY_CLAIM format is still unconfirmed by security, and a
  hand-typed username that doesn't match it produces an orphaned second
  account instead of promoting the real one. Ongoing (non-bootstrap) admin
  naming needs no new work: html/users.js's existing role dropdown already
  handles it.
- Break glass: none, by design, matching the precedent already on record
  for the abandoned LDAPS design (D13/D14) rather than assumed to carry
  over untested. If Okta is unreachable, the app is unreachable for
  everyone until Okta is restored. Rejected a toggleable emergency local
  login — it would reintroduce the stored credential D15 exists to
  eliminate.

Also corrects T10.4's scope in wave-10.md: hazard review found real call
sites of hash_password/verify_password/password_problem the original
bullet didn't name (create_user(), admin_reset_password(), users.js's
admin forms, browser_check.py/launcher_check.py fixtures), plus a
verification-gate ordering problem (smoketest.py and seed_demo.py
authenticate via POST /api/auth/login, which T10.4 removes, and both are
named explicitly in CLAUDE.md's verification section). Fixed by having
T10.4 switch both scripts to mint a session with auth.create_token()
directly, the same technique browser_check.py already uses, rather than
waiting on T10.7.

D16
This commit is contained in:
2026-09-03 10:35:58 -07:00
parent 7ed3cbec4c
commit c74289aa0d
2 changed files with 103 additions and 3 deletions

View File

@@ -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.

View File

@@ -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.