Compare commits

..

25 Commits

Author SHA1 Message Date
df20b8f18d wave-10: close out claim mapping and redirect URI, live in production
Confirmed by an actual live Okta sign-in after main (cc64c88) deployed: preferred_username is the right identity claim, and the redirect URI works. Matt matched his existing pre-Okta admin account rather than getting JIT-provisioned as a duplicate. Two deploy-time snags recorded, both Case B (config, not data): OKTA_CLIENT_ID/SECRET/ISSUER left empty in Portainer at first (caught cleanly by is_configured()), then OKTA_ISSUER missing its https:// scheme (surfaced as httpx.UnsupportedProtocol, not a deliberate app error - BL-028 still stands). Neither needed the backup. BTG pilot group now includes Cody and Cameron, awaiting Adrian.
2026-09-09 13:34:32 -07:00
cc64c88c3e T10.10: audit manage_users.py promote
cmd_promote() changed a user's role with no audit trail, unlike the
identical change from the web Admin Console (set_user_role() ->
log_event(), action "role_changed"). Not a new privilege - anyone
with container-exec access already has DB access directly, D16's own
trust-tier reasoning - but there was no record of who ran it or what
changed.

Now writes an AuditLog row matching set_user_role()'s shape, tagged
via:cli (mirrors JIT provisioning's via:okta_jit) since a container
shell exec carries no signed-in identity to attribute the change to.

Verified against a scratch SQLite db: audit row lands correctly, role
change persists, the no-such-user refusal still exits 1 clean.
2026-09-03 16:27:13 -07:00
dc13f9b0e3 T10.8: verification (390px/1440px, full suite, per-task done-when)
Walked T10.1-T10.5's claims against the actual code (env vars, routes,
JIT provisioning, the password-removal sweep, login.html) rather than
re-trusting this file's own prose. No drift found.

Full suite via the Docker runner: 39/41 files clean. token_check.py's
exit 2 is a harness mismatch (needs --out/--compare, not a bare run),
not a failure. generalinfo_check.py is 48/49 - the one failure is a
pre-existing rgba() shadow literal from the D11 Micron-assets merge,
confirmed via git show HEAD to predate this wave; logged as BL-031,
not fixed here. okta_auth_check.py re-run fresh: 22/22.

390px/1440px: baseline_shots.py captured all fourteen shots. Visually
confirmed login.html and users.html show the Okta-only sign-in and the
password-free admin UI at both widths.

Wave 10 complete.
2026-09-03 15:22:03 -07:00
d6eae0d846 T10.9: rollback-aware deploy runbook for the Okta cutover
New DEPLOY-runbook-2026-09-03.md, separate from the 2026-08-04 runbook.
Names the five new OKTA_* env vars, treats the pre-deploy backup as the
only way back once 1d60a608bb51 (drop_local_password) commits since its
downgrade() restores the column but not the data, and splits Rollback
into the fixable case (Okta app integration misconfigured, fix and
redeploy api, no data at risk) versus the severe case (abandoning Okta
for local-password code, which only a destructive backup restore can
reach). States D16's no-break-glass posture plainly.

D17 records the decision and why: staged-deploy-sequence docs and
backlog-only were both considered and declined in favor of the runbook.

Logged to backlog.md rather than fixed here: okta_auth.describe() has
no caller (BL-028), users.failed_attempts/locked_until are vestigial
(BL-029), DEPLOY-login-portal.md is fully stale (BL-030).
2026-09-03 14:25:18 -07:00
d7d1e93dd8 T10.6 - record what was built in wave-10.md 2026-09-03 13:48:08 -07:00
f023192b74 T10.6 - deployment docs and env var reference describe Okta, not the never-shipped LDAP config
D13 never shipped, so DEPLOYMENT.md, server/.env.example and server/README.md
still described the original local-password system as of this task starting -
POST /api/auth/login, bcrypt password_hash, create-admin with a prompted
password, self-service reset-password email flow, AUTH_RESET_MINUTES /
AUTH_RESET_COOLDOWN_SECONDS. All of that is gone as of T10.4; these three files
now describe what actually runs.

server/.env.example and DEPLOYMENT.md's env block both gain the five OKTA_*
variables (ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, IDENTITY_CLAIM),
explained the same way AUTH_SECRET_KEY already was - what it does, where to
get it, what happens if it's missing.

Also updated, not originally named in T10.6's bullet but required for the
documented vars to actually reach a running container: docker-compose.yml's
api service sets environment: as an explicit allowlist, not env_file, so the
four new OKTA_* entries had to be added there too or .env would document
something that silently does nothing. OKTA_IDENTITY_CLAIM specifically is NOT
${OKTA_IDENTITY_CLAIM:-} - compose setting an env var to an empty string is
not the same as leaving it unset, and server/okta_auth.py's own default
(preferred_username) only kicks in when the var is truly unset. Mirrored the
same default in the compose file instead, or every deployment that leaves the
optional line commented out in .env would 503 on every sign-in looking for a
claim literally named "".

server/README.md: replaced the login-portal section with the Okta flow
(access gating is Okta's job, not this app's - roles/authorization stay
local), replaced "create the first admin" with the promote-not-create
bootstrap path (D16) and its no-break-glass posture, replaced the curl-based
login example in Quick Test with a pointer to smoketest.py's own
session-minting technique (there is nothing left to curl - Okta requires a
real browser).

DEPLOYMENT.md: same treatment for its own copies of the env block, the
Portainer var list, the users table's password_hash column, the auth
endpoints summary, the smoke-test walkthrough (WP_SMOKE_USER only, must run
inside the api container or local dev sharing AUTH_SECRET_KEY/DATABASE_URL -
no longer targetable from an arbitrary remote workstation), the entire
"Self-service password reset" section (replaced with "Sign-in and admin
bootstrap (Okta)"), and the project_super_user role description / exclusive-
scope bullet, both of which named "reset passwords" as something that no
longer exists.

Left alone, logged rather than fixed here per CLAUDE.md scope discipline:
- users.failed_attempts / locked_until columns are still in the schema and
  still reset to 0/None on every Okta sign-in, but nothing increments them
  anymore since local login() is gone - vestigial, not documented as active
  lockout behavior in either doc now, but not migrated away either.
- server/README.md's "Production - Docker Compose" section (### 1-5) is a
  self-contained alternate quickstart that already duplicated and diverged
  from the real root docker-compose.yml before this task; it uses env_file
  rather than an explicit allowlist so it isn't broken by this change, but
  it's still a second source of truth nobody asked this task to reconcile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:47:48 -07:00
78d1942a0e T10.7 - add an explicit validation note (22/22, 26/26, 71/71) 2026-09-03 13:34:30 -07:00
45aff9c423 T10.7 - record the real browser-suite verification
url_state_check.py and browser_check.py both need a real headless browser via
tests/cdp.py, which the dev sandbox T10.7 was built in does not have and
cannot install (no sudo, network egress allowlisted against every route tried
- apt, a user-space Playwright Chromium download, Docker Hub). Documented as
an open gap in that commit rather than claimed as done.

Run for real since, on a machine with Docker, through a small general-purpose
tool kept deliberately outside this repo (headless-py-test-runner - it has no
idea what repo it is pointed at, so it does not belong in a Work Package Suite
PR). url_state_check.py 26/26, including scenario 2's actual click-through of
the fake-Okta round trip this task built; browser_check.py 71/71. Gap closed -
wave-10.md now says so instead of leaving the question open.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:33:17 -07:00
290c9b078c T10.7 - a fake-OIDC-provider test seam, mirroring ldap_fake.py
Only two calls actually touch the network: Authlib's authorize_redirect and
authorize_access_token. server/okta_fake.py stands in for both, dispatched from
okta_auth._build_oauth() before the real Okta config is even considered, and
production-refusing the same way ldap_fake.is_active() does - a non-SQLite
DATABASE_URL means production, full stop, no matter what WP_OKTA_FAKE_DIRECTORY
says. Everything this app itself decides stays real: the ?next= open-redirect
guard, the disabled-account check, JIT provisioning, and which claim carries
identity all run unmodified in app.py's okta_login()/okta_callback().

The fake needed one thing ldap_fake.py never did: something to actually redirect
the browser to and back, since Okta's real flow leaves the site and LDAP's never
did. Two routes stand in for Okta's own sign-in screen - a plain picker listing
whatever WP_OKTA_FAKE_DIRECTORY defines, and a consent step that hands back an
authorization code (or an error) at okta_callback, exactly the shape a real Okta
redirect would carry. Both are registered in app.py only when the fake is active
at import time, so in production they do not exist at all, not merely refuse a
request - confirmed by starting the app with the env var unset and checking
app.routes directly.

tests/browser_check.py's start_server() takes an optional extra_env now (no
existing caller passes a third positional arg, so none of the ~40 files that
import it needed touching) and sets WP_OKTA_FAKE_DIRECTORY unconditionally,
same reasoning the LDAP predecessor used: almost nothing signs in (seed() mints
tokens directly), but the one check that does should not fail mysteriously.

tests/url_state_check.py scenario 2, SKIPPED since T10.4, is un-skipped and now
drives the real round trip: login.html's own button, the fake picker page, the
fake consent redirect, okta_callback(). Carries forward the LDAP predecessor's
own bug fix too - asserting the app actually LEFT login.html, not just that
wp-creation-index.html appears somewhere in the URL (which the ?next= parameter
alone would satisfy).

tests/okta_auth_check.py is new, mirroring ldap_auth_check.py's two-layer shape:
guards that need no server (the production refusal, single-use/replay on the
authorization code), then a real running app for sign-in itself - an existing
admin surviving unchanged, JIT provisioning at the lowest role, a disabled
account refused despite Okta approving it, an unsolicited callback hit refused
without a 500, a tampered state refused, a denied consent refused, an unknown
identity refused BY THE SERVER (not just absent from the picker), a same-site
next= surviving and an off-site one ignored, and OKTA_IDENTITY_CLAIM genuinely
working under a non-default claim name. 22/22.

One thing this could not verify in this environment: url_state_check.py and
browser_check.py both need a headless Edge/Chrome via cdp.py, and this sandbox
has neither installed and no way to install one (no sudo). Confirmed the failure
is the tests' own designed-for exit 2 ("no headless-capable browser found; set
WP_BROWSER"), not a crash, and separately confirmed start_server() itself boots
cleanly with the fake wired in - health check, the picker page rendering with
the seeded identities, login.html all responding correctly - so the only gap is
the DOM-level click-through, not the server-side mechanism url_state_check
exercises (which okta_auth_check.py covers directly via HTTP instead).

wave-10.md's T10.7 bullet records the shape of what got built and the 22/22
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:11:24 -07:00
72b10283fc T10.5: login becomes an Okta redirect, not a form
server/app.py:
- okta_login(): validates and stashes ?next= (same-site path only) in the
  OAuth-state session before redirecting to Okta, so a deep link an assignment
  email carried (X1/CR-011/CR-014) survives the round trip instead of always
  landing on /index.html.
- okta_callback(): reads that stashed next= back (re-validated on the way out
  too - belt and suspenders against a crafted value) and redirects there on
  success. The two failure paths that used to raise a raw HTTPException -
  OAuthError (sign-in cancelled/failed) and a locally-disabled account - now
  redirect to /login.html?error=... instead: this route is reached by a full
  page browser navigation from Okta, not a fetch() call, so a JSON error body
  just looks like a broken page to whoever is signing in.

html/login.html + html/login.js: rebuilt as a single "Sign in with Okta" link,
replacing the username/password form and the forgot/reset-password views
(gone entirely - no local password exists to reset, per D15/D16/T10.4). Kept
the accessible error/ok banner pattern (role=alert / role=status) byte-for-
byte, since CLAUDE.md names this file as the reference other pages copy for
that pattern. login.js reads ?next= off its own URL (auth-guard.js's
goToLogin() already builds this, unchanged) and forwards it to
/api/auth/okta/login, and shows a plain-language message for ?error=disabled
/ ?error=cancelled, clearing the code from the address bar once shown. Sign-
out (auth-guard.js's wpLogout()) already redirected to login.html - untouched,
already satisfied "lands back on the app's own login page."

Uses a real <a href> rather than a JS-driven navigation, so it's a working
link even before login.js runs, and needs no keyboard/touch handling beyond
what a link gets for free (C1 accessibility).

Also fixed in passing (not a separate commit - this is what exposed it):
_safe_next_path() on the server and safeNext() in login.js enforce the exact
same rule (same-site path only, reject '//' and scheme URLs) so a crafted
?next= can't become an open redirect through a real Okta sign-in.

Verified: a fake-Okta-client round trip against the real app (SessionMiddleware
fix from the prior commit) confirms next= is honored end to end, a malicious
next= is rejected and falls back to /index.html, OAuthError redirects to
?error=cancelled, and a disabled account redirects to ?error=disabled. The
JS-side safeNext() was checked against the same cases directly in Node and
matches the server's validation exactly. login.js passes `node --check`;
login.html parses cleanly. Live 390px/1440px screenshots were NOT captured
this session - the environment's browser pane isn't signed in to view a
published preview of it, so that check needs to happen when this branch is
actually run and opened by a signed-in browser; the layout risk is low since
.card/.brand/.error/.ok/.foot are unchanged from the already-shipped file and
the only new CSS is one simple full-width block link.

wave-10.md T10.5 / D15 / D16
2026-09-03 11:27:26 -07:00
77f8f9f800 Fix T10.2: install SessionMiddleware, required by Authlib
okta_login() and okta_callback() (T10.2) both crash with a 500
(AssertionError: SessionMiddleware must be installed to access
request.session) against a real Okta client, because Authlib's
authorize_redirect() and authorize_access_token() both store/read OIDC
state and nonce in request.session. Never caught by T10.2's or T10.3's
own verification because every prior test mocked authorize_redirect /
authorize_access_token directly, bypassing Authlib's real implementation
entirely. Found while starting T10.5 and reproducing the real flow.

Adds starlette.middleware.sessions.SessionMiddleware, on its own cookie
(wp_oauth_state, distinct from the app's real session cookie wp_session)
with a short 10-minute lifetime and same_site=lax so it survives the
top-level redirect back from Okta. This cookie carries nothing but
ephemeral per-attempt OAuth state — no identity, no long-term secret —
so it reuses auth.SECRET_KEY rather than adding a new required config
knob. Reused in T10.5 to carry the post-login redirect target across
the same round trip.

Adds itsdangerous to requirements.txt — SessionMiddleware's hard
dependency, not previously needed anywhere in this app.

Verified: reproduced the crash against server.app with a fake (network-
bypassed) Authlib client and no SessionMiddleware, confirmed the
AssertionError, then confirmed the same request succeeds (302 to the
authorize URL, wp_oauth_state cookie set) once the middleware is added.

wave-10.md T10.2 (bug fix)
2026-09-03 11:13:28 -07:00
73da684b99 T10.4: remove the local password path entirely
Real deletion (D15's 'full replacement'), not a toggle. Okta is now the only
credential this app accepts anywhere.

Backend:
- server/models.py: drop User.password_hash.
- server/alembic/versions/1d60a608bb51_...: matching migration (op.drop_column,
  same plain-drop precedent as project_role/locked_until/etc.; downgrade re-adds
  it with server_default='').
- server/auth.py: remove hash_password/verify_password/password_problem/
  MIN_PASSWORD_LEN/_COMMON_PASSWORDS, create_reset_token/decode_reset_token/
  RESET_MINUTES, the bcrypt import. Roles/tokens/cookies/get_current_user
  untouched.
- server/app.py: remove login(), the whole self-service reset-password block
  (forgot-password/reset-available/reset-password), and change_password()
  (POST /api/auth/password). Rework create_user() to drop the password field
  (with a docstring note: the username must exactly match the eventual Okta
  identity claim, or a later sign-in provisions a second account instead of
  matching this one). Remove admin_reset_password() outright - nothing left to
  reset. Fixes a bug this task's own predecessor left behind: okta_callback()'s
  JIT provisioning (T10.3) was still setting password_hash="", which would have
  raised TypeError the moment the column was actually dropped.

Admin bootstrap (D16): server/manage_users.py moves from creating accounts
(create/create-admin/reset-password, all password-based) to a single 'promote
<username> --role <role>' command that changes the role on a row Okta's JIT
provisioning already created - the documented path for naming the first admin.
list/disable/enable unchanged.

Frontend: html/users.js drops the password field and validation from
createUser(), removes resetPw() and its button (nothing left to reset).
html/users.html drops the #nu-password input, adds a tooltip on username
explaining the exact-match-to-Okta requirement. html/auth-guard.js removes the
wpChangePassword dialog; html/wp-sidenav.js removes the 'Password' menu item
that opened it.

Tests: tests/browser_check.py and tests/launcher_check.py stop hashing a
password to seed fixture rows (and the --keep-server hint now prints a
ready-to-use cookie-setting snippet instead of a dead username/password).
tests/pipeline_check.py and tests/token_check.py drop an unused PW import.
tests/console_dialogs_check.py: the admin password-reset dialog it drove no
longer exists, so that scenario is removed - the prompt-with-validate() UI
pattern it exercised is still covered via creator_dialogs_check.py's
wp-creation-app.js call sites, noted in this file's docstring so the coverage
move isn't silent. tests/url_state_check.py: the "next= survives a real sign-in
via login" scenario is explicitly marked SKIPPED (not deleted, not faked) -
that promise is specific to the login FORM this task removed and can't be
honestly re-proven until T10.5 rebuilds it as an Okta redirect; a minted-token
cookie now stands in as setup only, so scenarios 3-6 in that file still get a
signed-in page to run against.

server/smoketest.py and server/seed_demo.py: switched from POST /api/auth/login
to minting a session the same way okta_callback() does (auth.create_token(),
seeded into the cookie jar) rather than waiting on T10.7. This is a real
operational change, documented in both files' own AUTHENTICATION sections: they
now need to run where AUTH_SECRET_KEY and the database match the target
server's (inside the api container, or local dev) - they can no longer sign in
to an arbitrary remote URL from an unrelated workstation, because Okta requires
a real browser and these are stdlib scripts. The account must already exist;
neither script creates or promotes one.

server/requirements.txt: bcrypt dropped, nothing imports it anymore.

Verified: full Alembic chain (baseline through this migration) upgrades and
downgrades cleanly against a throwaway SQLite DB. okta_callback() JIT
provisioning re-tested against the post-migration schema (would have thrown
before the password_hash="" fix above). create_user() verified via a live HTTP
call with no password field. manage_users.py promote verified end to end
(seed a JIT-shaped row at project_user, promote to admin, list). smoketest.py
and seed_demo.py both run to completion against a live uvicorn instance using
the new minted-session path - 25/25 checks, including logout actually
invalidating the session (proving the cookie-jar seeding didn't just fake the
sign-in, it preserved the real expiry mechanics).

wave-10.md T10.4 / D15 / D16
2026-09-03 10:58:28 -07:00
c74289aa0d 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
2026-09-03 10:35:58 -07:00
7ed3cbec4c T10.3: identity matching and JIT provisioning for Okta sign-in
okta_callback() now completes the sign-in instead of stopping at the claim:

- Matches the OKTA_IDENTITY_CLAIM value to a local account via
  auth.find_user() (username or email, case-insensitive) — the same lookup
  login() already uses, so an account whose username mirrors its AD
  identity needs no migration.
- No match: JIT-provisions a new account at the lowest-privilege role
  (project_user), no project membership, no password hash. Access beyond
  that is still granted locally by an admin/project super user, same as
  any account created by hand via create_user(). Logs a user_created audit
  event (via: okta_jit) for parity with that route.
- Match found but is_active is False: blocked with the same 403 'Account
  is disabled' login() raises today. Okta granting the challenge does not
  override an account this app has disabled locally (D15: 'roles stay
  local').
- On success: issues the same session cookie login() does (auth.create_
  token / auth.set_session_cookie), then redirects the browser to
  /index.html — this route is reached by a full-page navigation from
  Okta's redirect, not a fetch call, so a redirect is required rather than
  the JSON body login() returns.

Verified with a fake Okta client against a throwaway SQLite DB: new
identity provisions correctly (role/email/name/no-password), a repeat
sign-in matches the existing row without duplicating it or touching a
role an admin has since changed, a locally-disabled account is blocked
despite a valid Okta claim, and a missing identity claim is rejected
before touching the database.

wave-10.md T10.3 / D15
2026-09-03 10:16:50 -07:00
a9e5ee3892 T10.2: Okta login-redirect and callback routes
Adds GET /api/auth/okta/login (redirect to Okta's authorize endpoint) and
GET /api/auth/okta/callback (exchange code, validate ID token, pull the
identity claim) to server/app.py, using the oauth.okta client from
okta_auth.py (T10.1).

Access gating is Okta's job, not this route's: only accounts assigned to
the app integration in Okta ever reach the callback, so there is no
app-side group/claim check layered on top (D15, wave-10.md T10.2).

Stops at NotImplementedError once the identity claim is in hand. Matching
that claim to a local account and issuing the session cookie is T10.3, kept
separate per the one-task-per-PR rule.

wave-10.md T10.2 / D15
2026-09-03 10:11:49 -07:00
0ee35ae4ed T10.1: Okta OIDC client dependency and config (wave 10) 2026-09-03 09:57:55 -07:00
044862acba Wave 10: task breakdown for the Okta OIDC build 2026-09-02 17:38:35 -07:00
31c548318b D15: retire D13/D14 (LDAPS, never deployed), move straight to Okta OIDC 2026-09-02 17:20:01 -07:00
8f117680b0 Merge branch 'fix/alembic-transaction-per-migration': truthful migration logs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:35:36 -07:00
6034c08bad Alembic runs one transaction PER MIGRATION, not one for the whole chain
The 2026-08-21 crash at material_items printed 'Running upgrade' lines for
location_nodes and wp_files and then rolled all three back together - env.py
wrapped the entire run in a single transaction. The repair that followed
trusted those lines: material_items was hand-created, the version stamped to
head, and production ran for two days missing two tables it claimed to have.
Found 2026-08-23 when the locations import 500'd on UndefinedTable.

transaction_per_migration=True makes the log truthful: a crash keeps every
step that completed, and a stamp-to-head repair after a crash repairs ONE
migration, not an unknowable prefix of the chain. Verified: the full chain
still applies on a fresh scratch SQLite; the offline --sql render is
unchanged.

The production surgery (creating the two rolled-back tables from the offline
postgres render) is recorded in the session; no version stamp is needed
there - it is already, now truthfully, at head.

Items: BL-027's class, third finding; env.py infrastructure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:35:36 -07:00
17cabbd032 Merge branch 'fix/import-row-hazards': imports reject rows, never 500
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:26:38 -07:00
222c0b1c29 CR-005/D6 fix - a bad CSV row rejects by line number instead of 500ing Postgres
Nick's real location list hit the production import and got 'Internal Server
Error' with no line number - BL-027's class again, three days after the
migration outage: Postgres enforces VARCHAR lengths and refuses control
bytes, SQLite shrugs at both, and the importers were only ever rehearsed on
SQLite. Reproduced both hazards locally (an over-long value and a NUL byte
import cleanly on SQLite; either 500s Postgres wholesale).

Both importers now validate per row, before any INSERT, so every dialect
answers the same way - with the line number and a reason:
- locations: control characters; names over 200; codes over 60; combined
  paths over 200 (checked where the path exists, with read-counts taken
  before the loop so a mid-loop rejection is not counted twice)
- materials: control characters; description/unit/code over 300/20/80

And the client stops lying about it: wp-list-import.js read every response
with r.json(), so a plain-text 500 threw mid-parse and surfaced as 'Could not
reach the server' while the server was answering fine. One tolerant reader
(text -> parse if it parses -> keep status) now serves import, add and patch;
a real error reads 'Import refused - HTTP 500'.

Pins: materials_check +2 (over-long and control-byte rows reject at line,
20/20), locations_check +1 (over-long name rejects at line, 59/59).

Items: CR-005, D6, BL-027 (second instance of its class; the probe-side
dialect guard it proposes is still open).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 15:26:37 -07:00
e31234beef Merge branch 'docs/bl-026-027': the outage's two lessons, backlogged
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:20:32 -07:00
6057d05b98 BL-026 / BL-027 - the two lessons of the Aug 21 outage, logged
BL-026: no version stamp - 'is live current?' took mid-outage fingerprinting;
the fix shape is a git SHA baked at build, served by /api/health, shown on
the admin diagnostics card (D13 candidate).

BL-027: migrations rehearsed on SQLite only - the dialect drift class behind
the outage stays unguarded beyond the one pinned instance; the fix shape is
an offline postgresql-dialect render in the runbook and/or a probe.

Items: backlog only, no code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:20:30 -07:00
64eac0cbbb Merge branch 'fix/material-items-boolean-default': the Postgres deploy hotfix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:05:27 -07:00
51 changed files with 2331 additions and 954 deletions

View File

@@ -0,0 +1,355 @@
# Deploy runbook: WP Suite Okta cutover (wave 10)
**For:** IT / whoever administers the Docker host and Portainer
**From:** m.mabrey@prime-controls.com
**Revised:** 2026-09-03. First version of this runbook.
**Expected duration:** 20-30 minutes, including the backup and the live sign-in check
**Expected downtime:** under a minute, while containers are recreated
This is a separate runbook from `DEPLOY-runbook-2026-08-04.md`, not a revision of it.
That one still applies to its own deploy. Read this one in full before starting.
It has a step that the earlier one does not: this deploy removes the local password
system entirely, and one part of that removal cannot be undone by the usual means.
See "What this deploy changes" below.
---
## Fill these in before handing this over
| Thing | Value |
|---|---|
| Docker host (SSH target) | `________________` |
| Stack name in Portainer | `________________` |
| Site URL | `https://________________` |
| Stack directory on the host (holds `docker-compose.yml` / `backups/`) | `________________` |
| A real Okta account assigned to the app integration, for the live check in Step 4 | `________________` |
Container names are fixed by the compose file and are the same on every host:
`nginx_webserver`, `wp_api`, `wp_db`, `wp_db_backup`.
---
## What this deploy changes
Sign-in changes from a local username/password to Okta OIDC, completely. Not a
toggle, not a fallback. Three things make this more than a routine deploy:
1. **Five new environment variables are required**, and without them nobody can
sign in: `OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`,
`OKTA_REDIRECT_URI`, and optionally `OKTA_IDENTITY_CLAIM`. This is a genuine
change from how deploys usually go here. Do not skip Step 1.5.
2. **A migration drops the `password_hash` column** (`1d60a608bb51`), and it is
**one-way in practice.** Its `downgrade()` re-adds the column, but empty.
The real password hashes are gone the moment this commits, and no `alembic
downgrade` brings them back. If this deploy needs to be undone after that
point, going back to the old local-password code does not work on its own.
See the Rollback section, Case C. This is why Step 1's backup is not
optional the way it sometimes reads in other runbooks.
3. **There is no break-glass path**, by design (recorded decision D16). If Okta
is unreachable or misconfigured after this deploy, the app is unreachable for
everyone, admins included, until Okta is fixed. That is expected behavior,
not a bug to roll back from. See Rollback, Case A/B, before assuming
something is broken.
The visible change for people using the app: the login page becomes "Sign in
with Okta" instead of a username/password form. Nothing else in the app's
day-to-day behavior changes.
---
## Step 0: Record the current state (needed for rollback)
SSH to the Docker host and run:
```bash
docker exec wp_api alembic -c server/alembic.ini current
docker inspect nginx_webserver --format 'nginx image: {{.Image}}'
docker inspect wp_api --format 'api image: {{.Image}}'
```
**Copy the output into your ticket.** Also note the Git commit the Portainer
stack is currently on (Portainer, the stack, the Git reference / last-updated
commit). Without these, rollback is guesswork.
Expected output of the first command before this deploy: `a1b8c6d4e2f9 (head)`.
If it shows anything else, stop and check with me before continuing. This
runbook assumes that starting point.
---
## Step 1: Back up the database
On the Docker host:
```bash
docker exec wp_db_backup /scripts/db-backup.sh
```
Expected output ends with a line like:
```
[db-backup] wrote 1.4M /backups/wpsuite-20260903-141233Z.sql.gz.enc
```
Confirm the file is on the host (substitute the stack directory):
```bash
ls -lt <stack-dir>/backups | head -3
```
**Record that filename.** Do not continue until you have seen the `wrote ...`
line and the file in that listing.
This backup matters more than usual for this deploy. Once the migration in
Step 3 commits, this file becomes the *only* way to get local password hashes
back, for any reason. Treat it as the point you would restore to, not routine
housekeeping.
- A `.sql.gz.enc` extension means backups are encrypted. Expected and correct.
- A `.sql.gz` extension plus a `WARNING: BACKUP_ENC_PASSPHRASE not set` line
means backups are unencrypted. Not a blocker for this deploy; report it back.
- **No SSH access?** Portainer, **Containers**, `wp_db_backup`, **Console**,
connect with `/bin/sh`, then run `/scripts/db-backup.sh`. Same result: the
dump lands on the host, because `/backups` is a bind mount.
---
## Step 1.5: Confirm the Okta app integration is actually ready
Do this before redeploying, not after. Everything here is checked in Okta's own
admin console and in the values that will go into the stack's environment
variables. Nothing touches the WP Suite host yet.
1. The Okta app integration exists (Sign-in method: OIDC, Authorization Code,
Application type: Web Application), and the account listed in the fill-in
table above is assigned to it.
2. `OKTA_REDIRECT_URI` matches a "Sign-in redirect URI" registered on that app
integration **exactly**: scheme, host, and path, including whether it ends
in `/api/auth/okta/callback`.
3. `OKTA_ISSUER`, `OKTA_CLIENT_ID`, and `OKTA_CLIENT_SECRET` are the values from
that same app integration, not a different one.
4. If your Okta configuration puts the directory identity somewhere other than
the `preferred_username` claim, `OKTA_IDENTITY_CLAIM` is set to the right
claim name. If unsure, leave it unset; `preferred_username` is the default.
Add all five to the stack's **Environment variables** in Portainer now, before
Step 2. `OKTA_CLIENT_SECRET` should be handled the same way `AUTH_SECRET_KEY`
already is: not typed anywhere it will be logged.
If any of items 1-3 above are not yet confirmed, stop here and get them
confirmed first. A wrong redirect URI or an unassigned account will not corrupt
anything, but it does mean nobody signs in after this deploy until it is fixed.
See Rollback, Case B, which is the ordinary way that gets fixed and does not
involve the database at all.
---
## Step 2: Redeploy the stack in Portainer
1. Portainer, **Stacks**, select the stack.
2. **Pull and redeploy**, with re-pull / re-build **enabled**.
3. Wait for it to report success.
A plain "restart" or "stop/start" will not pick up new code, and will not pick
up the environment variables added in Step 1.5 either.
---
## Step 3: Confirm the containers came up
```bash
docker ps --filter name=nginx_webserver --filter name=wp_api --filter name=wp_db
```
All three must be `Up`, and `wp_db` should show `(healthy)`. Then check the API
applied its migration cleanly:
```bash
docker logs wp_api --tail 40
```
You are looking for an Alembic `Running upgrade a1b8c6d4e2f9 -> 1d60a608bb51`
line followed by gunicorn starting up, and no traceback. The API refuses to
start if a migration fails, so a restarting `wp_api` container means it failed.
Go to Rollback, Case B, and read the "did the migration commit" note there
before doing anything to the database.
Confirm the database landed on the new revision:
```bash
docker exec wp_api alembic -c server/alembic.ini current
```
Expected: `1d60a608bb51 (head)`. **Once you see this, you have passed the
point of no return described above.** The backup from Step 1 is now the only
way back to a working local-password system, if that is ever needed.
Then verify nginx's own view of its config:
```bash
docker exec nginx_webserver nginx -t
```
Expected: `syntax is ok` / `test is successful`.
---
## Step 4: Confirm Okta sign-in actually works, live
This is the step that matters most for this deploy. A clean container start
does not by itself prove sign-in works, and there is currently no startup log
line that confirms Okta config is good (logged separately as a follow-up, not
fixed as part of this runbook). The only real proof is a live sign-in.
1. Open the site's normal URL in a private/incognito window. It should land on
`login.html` with a "Sign in with Okta" button, not a username/password
form.
2. Click it. You should be redirected to your organization's actual Okta
sign-in page (the real Okta domain from `OKTA_ISSUER`, not this app's own
domain).
3. Sign in with the account from the fill-in table. You should land back on
the WP Suite site, signed in.
4. If this is the account's first-ever sign-in, it is now JIT-provisioned as a
regular user (`project_user`). To make it an admin, on the Docker host:
```bash
docker exec -it wp_api python -m server.manage_users promote <username> --role admin
```
This only works on an account that has already signed in once through Okta.
It promotes an existing row; it does not create one. That is deliberate
(recorded decision D16): there is no other admin-bootstrap path.
If step 2 or 3 fails (redirected to an Okta error page, redirected back to
`login.html` with an error, or nothing happens), this is almost always a
configuration problem from Step 1.5, not a code or database problem. Go to
Rollback, Case B, before considering anything more drastic.
Also confirm the API is reachable through the proxy and the redirect itself is
wired up:
```bash
curl -s https://<site-url>/api/health # -> {"ok": true}
curl -sI https://<site-url>/api/auth/okta/login | grep -i ^location # -> your Okta authorize URL
```
---
## Step 5: Hard-reload once in a browser, then sanity-check
Press **Ctrl+Shift+R** (Cmd+Shift+R on macOS) once. The app uses a service
worker; a normal reload can serve the previous version.
1. Signed in as the account from Step 4, the home page offers to select or
create a project, same as before.
2. Open **Admin Console** as the promoted admin account. The user table shows
the account you just signed in with. There is no password column, no
"reset password" action anywhere in the UI.
3. Open a project and confirm a work package can be opened and edited
normally. Sign-in is the only thing this deploy changes, so the rest of
the app should look untouched.
**Deploy complete.** Please report back: the Step 0 output, the backup
filename from Step 1, and confirmation that Step 4's live sign-in worked.
---
## Rollback
Read this before assuming a rollback is needed. Cases A and B below do **not**
touch the database and are the far more likely outcome of something going
wrong here. Okta configuration is fiddly and easy to get slightly wrong. Case
C is the severe, destructive one, and should be a last resort, not a first
reaction.
### Case A: nginx won't start, or containers won't come up at all
Same as any other deploy: the database is untouched by container start-up
failures. In Portainer, redeploy the stack pinned to the **previous Git
commit** recorded in Step 0, then re-run Step 3.
```bash
docker logs nginx_webserver --tail 100
docker logs wp_api --tail 100
```
Send me whichever of those is relevant.
### Case B: containers are up, but Okta sign-in doesn't work
This is a configuration problem, not a data problem, and does **not** need a
code rollback or a database restore. Check, in order:
1. Is `wp_api`'s log showing anything at all when a sign-in is attempted?
`docker logs wp_api --tail 100`.
2. Do the five `OKTA_*` values in the stack's environment variables actually
match the Okta app integration (Step 1.5)? A copy-paste error in
`OKTA_CLIENT_SECRET` or a redirect URI that's off by a trailing slash are
the two most common causes.
3. Is the account assigned to the Okta app integration? An unassigned account
gets denied by Okta itself, before it ever reaches this app.
4. If a specific person can't sign in but others can, check
`OKTA_IDENTITY_CLAIM`. The claim it reads may not carry that person's
directory identity in the format expected. Confirm with security which
claim Okta is actually issuing.
Fix the environment variable(s) in Portainer, then redeploy (Pull and redeploy
is fine; the migration already applied and does not run again). No backup
restore, no code rollback.
If it's still not working after checking all four, send me the `wp_api` log
from item 1 along with which of items 2-4 you already ruled out.
### Case C: the decision is made to abandon Okta and restore local-password sign-in
This is the case the point-of-no-return warning in "What this deploy changes"
is about. Only reach for this if Case A and B do not apply. That is, Okta
itself is working correctly but a decision has been made to go back to the old
system entirely.
**This cannot be done with a code rollback alone.** The old code expects a
real `password_hash` on every user row. After Step 3 commits, that column is
either gone or (if `alembic downgrade` is run) present but empty. Either way,
nobody's stored password survived, including admins'. The only way to get a
working local-password system back is to restore the full database from the
Step 1 backup, which also rolls back every other change made since that
backup: new work packages, comments, uploaded files, everything.
**Do not do this without confirming with me first.** If it is confirmed, for
an encrypted dump, on the Docker host, in the `backups` directory:
```bash
export BACKUP_ENC_PASSPHRASE='<the passphrase, from the stack env vars>'
openssl enc -d -aes-256-cbc -pbkdf2 -pass env:BACKUP_ENC_PASSPHRASE \
-in wpsuite-<timestamp>.sql.gz.enc \
| gunzip \
| docker exec -i wp_db psql -U wpsuite -d wpsuite
unset BACKUP_ENC_PASSPHRASE
```
For an unencrypted dump, drop the `openssl` stage and pipe `gunzip` straight
into `psql`. Substitute the real values if `POSTGRES_USER` / `POSTGRES_DB` are
not `wpsuite`. After restoring, redeploy pinned to the Git commit recorded in
Step 0, since the restored database matches the old schema, not this one.
Reach me at m.mabrey@prime-controls.com.
---
## Notes
- Do not run `docker compose down -v`. The `-v` flag deletes the `pgdata`
volume and with it the entire database.
- `docker exec <container-name>` is used throughout rather than
`docker compose ...`, because a Portainer-managed Git stack's compose project
lives under Portainer's own data directory and usually isn't reachable from
an ad hoc SSH session the same way.
- There is currently no startup log line confirming Okta config is valid
(`okta_auth.describe()` exists but nothing calls it yet, logged as a
follow-up). Step 4's live sign-in is the real verification until that's
wired in.
- Full background documentation: `DEPLOYMENT.md` and `server/README.md` in the
repository. `docs/waves/decisions-2026-09-03.md` (D16, D17) records why
there is no break-glass path and why this runbook exists as its own document.

View File

@@ -52,27 +52,44 @@ POSTGRES_DB=wpsuite
POSTGRES_USER=wpsuite POSTGRES_USER=wpsuite
POSTGRES_PASSWORD=<strong-random-password> POSTGRES_PASSWORD=<strong-random-password>
# REQUIRED — signs login session cookies. If unset, `docker compose up` errors # REQUIRED — signs login session cookies, AFTER Okta has confirmed who someone
# out and the API refuses to start. Generate once and keep it stable: # is. If unset, `docker compose up` errors out and the API refuses to start.
# Generate once and keep it stable:
# openssl rand -base64 48 # openssl rand -base64 48
AUTH_SECRET_KEY=<strong-random-secret> AUTH_SECRET_KEY=<strong-random-secret>
# REQUIRED (in spirit — see the note below) — Okta OIDC is the only sign-in
# path (D15/D16). There is no local password anywhere in this app to fall back
# to, so without these nobody can sign in at all. Get them from the Okta app
# integration (sign-in method OIDC - Authorization Code, Web Application):
#
# OKTA_ISSUER the authorization server, e.g.
# https://your-org.okta.com/oauth2/default
# OKTA_CLIENT_ID \_ from the app integration
# OKTA_CLIENT_SECRET /
# OKTA_REDIRECT_URI must exactly match a "Sign-in redirect URI"
# registered on the app integration, e.g.
# https://wp-suite.company.local/api/auth/okta/callback
#
# Full explanation, and the optional OKTA_IDENTITY_CLAIM override, in
# server/.env.example. Not enforced at startup the way AUTH_SECRET_KEY is —
# the API starts without these, it just refuses every sign-in and says so in
# `docker compose logs api` (server/okta_auth.py's describe()).
OKTA_ISSUER=<https://your-org.okta.com/oauth2/default>
OKTA_CLIENT_ID=<from the Okta app integration>
OKTA_CLIENT_SECRET=<from the Okta app integration>
OKTA_REDIRECT_URI=<https://your-hostname/api/auth/okta/callback>
# Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds # Encrypts database backups at rest (AES-256). Set this BEFORE the DB holds
# customer IP. Keep the passphrase OFF this host — losing it makes dumps # customer IP. Keep the passphrase OFF this host — losing it makes dumps
# unrecoverable: openssl rand -base64 32 # unrecoverable: openssl rand -base64 32
BACKUP_ENC_PASSPHRASE=<strong-random-passphrase> BACKUP_ENC_PASSPHRASE=<strong-random-passphrase>
# OPTIONAL — SMTP password for WP-assignment email + password-reset links. Email # OPTIONAL — SMTP password for WP-assignment email. Email is OFF by default and
# is OFF by default and enabled from the Admin console; the host/port/from-address # enabled from the Admin console; the host/port/from-address are configured
# are configured there, but the password is only ever read from this variable # there, but the password is only ever read from this variable (never stored
# (never stored in the DB or shown in the UI). Leave unset until you have SMTP # in the DB or shown in the UI). Leave unset until you have SMTP details.
# details.
# SMTP_PASSWORD=<smtp-app-password> # SMTP_PASSWORD=<smtp-app-password>
# OPTIONAL — password-reset link lifetime (minutes) and the per-account send
# cooldown (seconds). Defaults shown; both only matter once email is enabled.
# AUTH_RESET_MINUTES=60
# AUTH_RESET_COOLDOWN_SECONDS=120
``` ```
The API builds its own DB connection string from the `POSTGRES_*` The API builds its own DB connection string from the `POSTGRES_*`
@@ -87,6 +104,7 @@ Generate a strong password with `openssl rand -base64 32`.
> **Portainer note:** for a Git-based stack these go in the stack's > **Portainer note:** for a Git-based stack these go in the stack's
> **Environment variables** section (Portainer doesn't read a local `.env`). > **Environment variables** section (Portainer doesn't read a local `.env`).
> Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` / > Set `POSTGRES_DB` / `POSTGRES_USER` / `POSTGRES_PASSWORD` / `AUTH_SECRET_KEY` /
> `OKTA_ISSUER` / `OKTA_CLIENT_ID` / `OKTA_CLIENT_SECRET` / `OKTA_REDIRECT_URI` /
> `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there. > `BACKUP_ENC_PASSPHRASE` (and `SMTP_PASSWORD`, if you enable email) there.
These are the only credentials in the system, and they never appear in the These are the only credentials in the system, and they never appear in the
@@ -151,25 +169,34 @@ project → SOP → Work Package → the AWP issue gate → status → metrics
archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq. archive round trip → cascade cleanup → sign-out). Stdlib only — no pip/jq.
It **signs in first**, because every `/api/` route except `/api/health` requires a It **signs in first**, because every `/api/` route except `/api/health` requires a
session. Credentials come from the environment so a password stays out of shell session — but there is no local password to sign in with (D15/D16), and Okta
history, and the account must be an **admin**: the run creates a project and deletes requires a real browser to complete, which this script cannot do. So instead
it again, and archiving or deleting one takes Project Admin on it. The script checks of an HTTP login, it mints a session directly the same way `okta_callback()`
the signed-in role up front and warns if it is too low rather than letting you find does after Okta hands back an identity, which means **it has to run somewhere
out in the cleanup step. that can read the same `AUTH_SECRET_KEY` and reach the same database as the
server under test** — inside the `api` container, or local dev against your
own DB. It can no longer sign in to an arbitrary remote URL from an unrelated
workstation the way the old password-based version could.
The account named by `WP_SMOKE_USER` must **already exist** — sign it in
through Okta once first, or pre-create it from the User Directory — and must
be an **admin**: the run creates a project and deletes it again, and deleting
one takes Project Admin on it. The script checks the signed-in role up front
and warns if it is too low rather than letting you find out in the cleanup
step.
```bash ```bash
export WP_SMOKE_USER=<admin-account> # From inside the api container — has AUTH_SECRET_KEY and DATABASE_URL, and
export WP_SMOKE_PASSWORD='…' # hits FastAPI directly. This is the normal way to run it in production:
docker compose exec -e WP_SMOKE_USER api \
# Through the proxy (use --insecure for a self-signed internal cert):
python3 server/smoketest.py https://wp-suite.company.local --insecure
# Or from inside the api container (hits FastAPI directly). Pass the vars through:
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
python /app/server/smoketest.py http://localhost:8000 python /app/server/smoketest.py http://localhost:8000
# Local dev, against the app you're running yourself:
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=<admin-account>
python3 server/smoketest.py http://localhost:8000
# Add --keep to leave a demo project in the DB so you can open it in the UI. # Add --keep to leave a demo project in the DB so you can open it in the UI.
# --user / --password override the environment if you'd rather be explicit. # --user overrides $WP_SMOKE_USER if you'd rather be explicit.
``` ```
Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run Exit codes: **0** all checks passed · **1** one or more checks failed · **2** the run
@@ -257,7 +284,7 @@ users on the same project see the same server-stored SOP and Work Packages.
| `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) | | `sops` | project SOP baselines | `project_id` → projects, `name`, `number`, `complete`, `data` (full SOP JSON) |
| `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) | | `work_packages` | individual IWPs | `project_id` → projects, `sop_id` → sops, `parent_id` (split instances), `number`, `subject`, `type`, `status`, `assignee_id` (owner), `issued_at`, `archived_at`, `data` (full WP JSON) |
| `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` | | `comments` | feedback from any page | `source`, `sop_id`, `wp_id`, `step`, `author`, `text`, `extra` |
| `users` | login accounts | `username`, `password_hash` (bcrypt), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), login-lockout + `token_version` fields | | `users` | login accounts | `username` (matched against Okta's identity claim — no password column; D15/D16), `role`, `full_name`, `email`, `is_active`, `auto_add_projects` + `auto_add_role` (default membership on new projects), `token_version` |
| `project_members` | per-project access control | `user_id` → users, `project_id` → projects | | `project_members` | per-project access control | `user_id` → users, `project_id` → projects |
| `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` | | `audit_log` | append-only activity trail | `actor`, `action`, `entity_type`, `entity_id`, `project_id`, `summary`, `detail` |
| `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) | | `notifications` | in-app record + email outbox | `user_id`, `kind`, `wp_id`, `subject`, `status` (pending / sent / failed / skipped) |
@@ -275,8 +302,9 @@ Work Packages `GET/POST /api/wps`, `GET/DELETE /api/wps/{id}`,
`POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`, `POST /api/wps/{id}/issue`, `POST /api/wps/{id}/status`, `POST /api/wps/{id}/archive`,
`GET /api/wps/metrics` · `GET /api/wps/metrics` ·
Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` · Comments `POST /api/comments` (and `/api/feedback`), `GET /api/comments` ·
Auth `POST /api/auth/login` / `logout`, `GET /api/auth/me`, admin user management Auth `GET /api/auth/okta/login` / `okta/callback` (the Okta sign-in round trip),
under `/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) · `POST /api/auth/logout`, `GET /api/auth/me`, admin user management under
`/api/auth/users` (including `POST /api/auth/users/{id}/auto-add`) ·
Admin-only `GET/PUT /api/settings`, Admin-only `GET/PUT /api/settings`,
`POST /api/settings/test-email`, `GET /api/notifications`, `POST /api/settings/test-email`, `GET /api/notifications`,
`GET /api/projects/{id}/members`. `GET /api/projects/{id}/members`.
@@ -350,27 +378,31 @@ TLS / From address and flips the master toggle.
package contents — so customer IP stays behind the login. package contents — so customer IP stays behind the login.
- Use the card's **Send test email** button to confirm SMTP before enabling. - Use the card's **Send test email** button to confirm SMTP before enabling.
### Self-service password reset ### Sign-in and admin bootstrap (Okta)
Turning email on also enables **Forgot password** on the login page. Until then the There is no local password anywhere in this app — no "Forgot password," no
link explains that an admin must reset it (`server/manage_users.py`, or the Admin reset link, nothing email-related to sign-in (D15/D16). `SMTP_PASSWORD` above
console's **Reset password** button). is purely for WP-assignment notification emails.
- The emailed link carries a short-lived signed token — `AUTH_RESET_MINUTES` Sign-in is entirely Okta's job: `login.html` redirects to Okta, and access
(default 60). It is **single-use**: completing a reset bumps the account's control is **who is assigned to the app integration in Okta** — see step 2's
`token_version`, which both burns the link and signs out that user's other `OKTA_*` variables and [`server/README.md`](server/README.md#sign-in-okta) for
sessions. A completed reset also clears any login lockout. the full flow. The first admin has to sign in through Okta once (landing as an
- `/api/auth/forgot-password` answers **identically for unknown accounts**, so it ordinary `project_user`, auto-provisioned), then get promoted from a shell:
can't be used to discover usernames. Misses are recorded in the audit log
(`password_reset_miss`) instead. ```bash
- One reset mail per account+client per `AUTH_RESET_COOLDOWN_SECONDS` (default 120) docker compose exec api python -m server.manage_users promote alice --role admin
so the form can't be used to flood someone's inbox. The throttle is per worker ```
and in-memory; the token expiry is the real control.
- Reset mails are sent **immediately, not through the notifications outbox** — a This is deliberate, not an oversight: a hand-typed username at account-creation
reset link must never be persisted where an admin could read it and take over an time risks a second, orphaned row if it doesn't exactly match what Okta sends,
account. so the CLI promotes an existing Okta-provisioned row rather than creating one
- Set `app_base_url` in the admin card, or the emailed link will be relative and blind (D16). Every admin after the first can be promoted from the User
therefore useless. Directory page — no shell access needed.
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
unreachable for everyone, including admins, until Okta is restored — the same
posture the abandoned LDAPS design took, carried forward deliberately (D16).
## Permissions roles ## Permissions roles
@@ -382,7 +414,7 @@ which no longer manages accounts.
| Role | May do | | Role | May do |
|---|---| |---|---|
| `admin` | User administration everywhere, app settings, and every project | | `admin` | User administration everywhere, app settings, and every project |
| `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: create accounts, reset passwords, set permissions, grant project access | | `project_super_user` | Everything `project_admin` may do, **plus user administration on the projects they hold the role on**: pre-create accounts by username, set permissions, grant project access |
| `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project | | `project_admin` | On assigned projects: delete work packages, change a **completed** SOP, delete the project |
| `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one | | `project_user` | Create/edit work packages, author a SOP up to completion; may archive a WP but not delete one |
@@ -399,9 +431,9 @@ Its limits are what make it safe to hand out, and all of them are server-side
* **Scope comes from projects, not the job title.** A super user administers the users * **Scope comes from projects, not the job title.** A super user administers the users
of the projects they hold the role on — via their account role, or via of the projects they hold the role on — via their account role, or via
`ProjectMember.role` for a super user on one job only. No projects, no authority. `ProjectMember.role` for a super user on one job only. No projects, no authority.
* **Account changes need EXCLUSIVE scope.** Resetting a password, disabling, renaming, * **Account changes need EXCLUSIVE scope.** Disabling, renaming, changing
changing permissions or deleting are global acts, so they are refused when the permissions or deleting are global acts, so they are refused when the target
target is also on a project the caller does not administer. The directory shows is also on a project the caller does not administer. The directory shows
those rows read-only with the reason. An app admin has to make the change. those rows read-only with the reason. An app admin has to make the change.
* **No admin or super-user targets, and none granted.** A super user may hand out * **No admin or super-user targets, and none granted.** A super user may hand out
`project_admin` / `project_user` only, and may not touch an admin's or another `project_admin` / `project_user` only, and may not touch an admin's or another

View File

@@ -27,10 +27,28 @@ services:
POSTGRES_HOST: db POSTGRES_HOST: db
# Optional full-URL override (must be URL-encoded if used). # Optional full-URL override (must be URL-encoded if used).
DATABASE_URL: ${DATABASE_URL:-} DATABASE_URL: ${DATABASE_URL:-}
# Signs login session cookies. REQUIRED — compose fails fast if it's unset, # Signs login session cookies, AFTER Okta has confirmed who someone is.
# and the API refuses to start in production without it (see server/auth.py). # REQUIRED — compose fails fast if it's unset, and the API refuses to
# start in production without it (see server/auth.py).
AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)} AUTH_SECRET_KEY: ${AUTH_SECRET_KEY:?set AUTH_SECRET_KEY in .env (see server/.env.example)}
AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12} AUTH_SESSION_HOURS: ${AUTH_SESSION_HOURS:-12}
# Okta OIDC — the only sign-in path (D15/D16). Not marked required the
# way AUTH_SECRET_KEY is: the API starts without these, it just refuses
# every sign-in and says so in the startup log (server/okta_auth.py
# describe()). See server/.env.example for what each one is and how to
# get it from the Okta app integration.
OKTA_ISSUER: ${OKTA_ISSUER:-}
OKTA_CLIENT_ID: ${OKTA_CLIENT_ID:-}
OKTA_CLIENT_SECRET: ${OKTA_CLIENT_SECRET:-}
OKTA_REDIRECT_URI: ${OKTA_REDIRECT_URI:-}
# NOT ${OKTA_IDENTITY_CLAIM:-} — server/okta_auth.py's own default only
# applies when the env var is UNSET, and compose setting it to an empty
# string here is not the same thing as leaving it unset. An empty value
# would make the API look for a claim literally named "", which is
# never present, so EVERY sign-in would 503. Mirror the same default
# here instead, so an operator who leaves .env's copy commented out gets
# the real default, not a broken one.
OKTA_IDENTITY_CLAIM: ${OKTA_IDENTITY_CLAIM:-preferred_username}
# Optional — SMTP password for WP-assignment emails. Email is off by # Optional — SMTP password for WP-assignment emails. Email is off by
# default and enabled from the Admin console; this is the only email # default and enabled from the Admin console; this is the only email
# secret and it is never stored in the DB. Leave unset until configured. # secret and it is never stored in the DB. Leave unset until configured.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 340 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -292,7 +292,7 @@ Wave 8 adds these:
```bash ```bash
python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks python tests/kitting_check.py # CR-009/010/012 - statuses, owner, delivery 26 checks
python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks python tests/kitting_notify_check.py # CR-011 - kitting mail, coalesced, gated 17 checks
python tests/materials_check.py # D6 - material list, the CR-005 pattern 17 checks python tests/materials_check.py # D6 - material list, the CR-005 pattern 20 checks
python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks python tests/mreq_check.py # CR-013 - lightweight request, end to end 19 checks
``` ```

View File

@@ -554,3 +554,111 @@ deliberately deferred.
entry rather than a drive-by. entry rather than a drive-by.
- **Suggested wave or follow-up:** next housekeeping pass, with the check - **Suggested wave or follow-up:** next housekeeping pass, with the check
widened so it cannot recur. widened so it cannot recur.
### BL-026 — No version stamp: "is live current?" cannot be answered from the app
- **Found during:** the 2026-08-21 outage triage (the question that started it)
- **Where:** `Dockerfile` / build, `server/app.py` `/api/health`, admin console
- **What:** the app carries no record of what code it is running. `/api/health`
returns `{"ok": true}` and nothing identifies the deployed commit, so
answering "is the live site on the latest code?" took fingerprinting
(probing for files/routes that only exist after certain merges) in the
middle of an outage. The fix: bake the git SHA into the image at build time
(`ARG GIT_SHA`), return it from `/api/health`
(`{"ok": true, "version": "<sha>"}`), and show it on the admin console's
diagnostics card. Then currency is one glance against `git log -1`.
- **Why not now:** new scope — needs its own item id per the working rules
(D13 is the natural next), and it touches the image build, which deserves a
deploy alongside someone with host access.
- **Suggested wave or follow-up:** next housekeeping pass; ~1 task including a
probe check that /api/health carries a version field.
### BL-027 — Migrations are rehearsed on SQLite only; production is Postgres
- **Found during:** the 2026-08-21 production outage (D6's `material_items`
migration crash-looped the api container)
- **Where:** `DEPLOYMENT.md` (the update/deploy steps), `tests/`
- **What:** the migration chain is verified end-to-end on scratch SQLite, but
production runs Postgres, and the dialects disagree exactly where it hurts:
`server_default=sa.text('1')` on a Boolean passed every SQLite rehearsal and
was refused by Postgres at deploy (DatatypeMismatch), taking the API down
until the table was created by hand. The hotfix (64eac0c) fixed that one
instance and pinned the Boolean-default class in `materials_check`; the
CLASS of dialect drift is still unguarded. Two cheap layers: (1) a runbook
step — render `alembic upgrade --sql` for the postgresql dialect and read it
before restarting (offline, needs no live DB; this render would have shown
`DEFAULT 1` on a boolean); (2) better, a probe that renders every migration
for the postgresql dialect on each run and fails on anything the dialect
rejects or on known-bad patterns.
- **Why not now:** the outage is resolved and the one known instance is fixed
and pinned; the systematic guard is its own small task, not a hotfix rider.
- **Suggested wave or follow-up:** next housekeeping pass, paired with BL-026
(both are "deploys should be boring" work).
### BL-028 — `okta_auth.describe()` is never called
- **Found during:** T10.9 (writing the deploy runbook's live-verification step)
- **Where:** `server/okta_auth.py` (`describe()`), `server/app.py` (no caller anywhere)
- **What:** `describe()` exists specifically to shout `"*** FAKE OKTA PROVIDER
ACTIVE..."` or report missing config at a glance, and both
`server/.env.example` and `server/README.md` tell an operator to "check the
startup log line" for it. Nothing prints it. `app.py` never imports or calls
`describe()` at process start, so that log line does not exist and an operator
following the docs will not find it.
- **Why not now:** a runbook is documentation, not server code; wiring a
startup log call is a real (if small) change to `app.py` and wants its own
diff and its own verification, not a rider on T10.9.
- **Suggested wave or follow-up:** next housekeeping pass. One call
(`logger.info(okta_auth.describe())` near startup) plus updating
`DEPLOY-runbook-2026-09-03.md` Step 4 to check the log line once it exists.
### BL-029 — `users.failed_attempts` / `users.locked_until` are vestigial
- **Found during:** T10.6
- **Where:** `server/models.py` (`User.failed_attempts`, `User.locked_until`)
- **What:** both columns exist to support local-password lockout, which T10.4
removed. They are still reset to `0`/`None` on every Okta sign-in but nothing
increments them anymore — dead columns, not a bug, but schema drift from the
D15 cutover.
- **Why not now:** T10.6 is documentation scope; dropping columns is a migration
and belongs with the rest of the local-password cleanup, not folded into a
docs task.
- **Suggested wave or follow-up:** next housekeeping pass, alongside any other
post-cutover schema tidy-up.
### BL-031 — A raw `rgba()` shadow survives in the creator's stylesheet
- **Found during:** T10.8 (the full suite could finally run through Docker; the
dev sandbox never had a headless browser to run it in before)
- **Where:** `html/wp-creation-styles.css:929`, `.asset-results { ... box-shadow:0
8px 24px rgba(20,30,50,.18); }`
- **What:** `generalinfo_check.py` asserts the whole file carries no raw colour
literal (comments excluded) as part of its CR-003 priority-colour check, and
this one line fails it: 48/49. Confirmed via `git show HEAD` that the literal
is already committed, byte-identical, unrelated to wave 10 — it is the Micron
asset picker's dropdown shadow, which arrived with the `origin/Micron-Assets`
merge (D11, 2026-08-20) and was never swept by `C4`'s token pass (`T9.9`
closed before D11 merged). The fix is a straight swap:
`theme-light.css:217` already declares `--wp-shadow-menu: 0 8px 24px
rgba(20, 30, 50, .18)`, the identical value — this line should read
`box-shadow:var(--wp-shadow-menu);`.
- **Why not now:** unrelated to the Okta wave; fixing a D11-era CSS literal
inside T10.8 (auth verification) is exactly the drive-by `CLAUDE.md` forbids.
- **Suggested wave or follow-up:** next housekeeping pass, with `C4`'s other
leftovers. One-line fix, `generalinfo_check.py` already pins it (49/49 once
fixed).
### BL-030 — `DEPLOY-login-portal.md` is fully stale
- **Found during:** T10.9
- **Where:** `DEPLOY-login-portal.md` (repo root)
- **What:** the original username/password login rollout doc. References
`bcrypt`, `create-admin --password`, `AUTH_SECRET_KEY` as the only secret,
and a login form — none of which describe the app since T10.4/T10.5. Someone
handed this to IT today would be told to do things that no longer work.
- **Why not now:** no task currently owns deploy-doc cleanup as a category;
deleting or archiving a doc is a product/records call (`CLAUDE.md`'s "removed
fields are hidden, not deleted" spirit likely applies to docs too, but that is
worth confirming rather than assuming).
- **Suggested wave or follow-up:** next housekeeping pass — needs Nick on
whether to delete, archive, or rewrite it as historical record.

View File

@@ -0,0 +1,75 @@
# Decisions — September 2, 2026
One item, and it retires two decided-and-built items rather than amending them.
---
## D15 — Authentication moves to Okta OIDC; D13 and D14 are retired before deployment
- **Amends:** retires `D13` (LDAPS simple bind against `prime.local`) and `D14` (the CLI
authenticates against the domain). Both were decided and reaffirmed August 21 2026,
built across nine tasks (`T10.1``T10.9`), and verified against the live domain. Neither
reached production. Approved by Nick Siegfried.
- **Surface:** `server/auth.py`, `server/app.py` (`login()`), `html/login.html`,
`html/login.js`, `html/auth-guard.js`. `server/ldap_auth.py` does not carry forward —
there is no LDAPS bind in the new design, not even as a fallback.
- **Wave:** 10. The label is reused fresh: the LDAPS work that previously answered to
"wave 10" was built on `feat/ldaps-directory-auth`, which is deleted rather than merged,
and never appeared in `IMPLEMENTATION.md`'s wave table. It carries no claim on the
number.
### What D13/D14 were, for the record
The branch carrying them is deleted, not merged, so their decision record
(`docs/waves/decisions-2026-08-21.md`) no longer exists on any branch. Preserved here so
the reasoning isn't lost along with it:
D13 chose a direct LDAPS simple bind to `ldaps://prime.local:636` as the sign-in
mechanism: no password stored, a successful bind was the authentication, accounts were
provisioned just-in-time from the directory, and roles stayed local. D14 moved the CLI
onto the same bind, removing `create-admin`/`create`. Both were built, tested
(1284/1288 checks, Aug 24), and screenshotted at 390px and 1440px. Neither ever deployed —
`wp.controls.dev` still runs the pre-D13 local-password login as of this decision.
### The decision
Skip LDAPS entirely. Authentication becomes an Okta OIDC authorization-code flow,
replacing local passwords directly — the same full replacement D13 intended, just via
Okta instead of a domain bind. No LDAPS bind exists in this design at any point.
Four things carry forward from D13 unchanged, because they were never LDAPS-specific to
begin with:
1. **No password is stored.** The app never sees a credential of any kind; Okta owns
authentication entirely.
2. **Accounts are provisioned just-in-time.** A first successful Okta sign-in with no
matching local `users` row creates one, at the default role. The matching logic that
was going to key off a directory search instead keys off an OIDC identity claim.
3. **Roles stay local.** Okta, and AD behind it, supplies identity only. This app decides
what an identity may do. Restated because it is the one rule the whole access-control
design depends on — see `BL-029` and the governance discussion that followed it.
4. **Existing accounts keep their roles** on first Okta login, exactly as D13's criterion
4 read for LDAPS.
### Why this, and not LDAPS first and Okta second
`BL-029` (recorded on this branch as `BL-027` before the renumbering forced by main's
independent use of that number) already laid out why OIDC beats the LDAPS bind on three
counts: this app never sees a password, MFA comes from Okta rather than needing to be
built, and the domain-lockout hazard that forced `AUTH_MAX_ATTEMPTS` down to 2 disappears,
because failed attempts land on Okta rather than on a bind this app makes. D13 was decided
before it was known the company already runs an Okta tenant. Once that was confirmed
(security's scoping reply, September 2026), shipping LDAPS first and replacing it with
Okta days later would mean building and deploying the weaker mechanism on purpose. Going
straight to Okta avoids that.
### What still needs answering before this is buildable
Open from the security scoping thread, not yet closed:
- Which OIDC claim carries the AD `sAMAccountName` equivalent (`preferred_username`,
`upn`, or a custom claim) — asked of security, answer pending.
- The exact redirect/callback URI once the hostname situation is reconfirmed
(`https://wp.controls.dev/api/auth/okta/callback` proposed).
- The `Business Technology Group` pilot in Okta, requested for initial testing, with
normal Okta session/MFA behavior rather than a stricter per-app rule.

View File

@@ -0,0 +1,122 @@
# 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.
## D17: A dedicated rollback-aware runbook for the Okta cutover deploy
Raised while preparing to close out wave 10: T10.4's migration
(`server/alembic/versions/1d60a608bb51_drop_local_password.py`) drops the
`password_hash` column, and its `downgrade()` re-adds the column with
`server_default=''`. That restores the schema, not the data — the real bcrypt hashes
are destroyed the moment `upgrade()`'s `op.drop_column` commits, and no amount of
`alembic downgrade` brings them back. `DEPLOY-runbook-2026-08-04.md`, the existing
precedent for how a deploy of this repo is handed to IT, has no equivalent case in its
own Rollback section — its migrations are additive or reversible, so nothing there
warns an operator that this one is different.
Three options were on the table: write this runbook now; also document a staged
deploy sequence (ship T10.5's Okta-live-alongside-password state first, verify real
sign-ins, then ship T10.4's column drop as a separate follow-up deploy); or log the
hazard to `backlog.md` and stop there. Decided: write the runbook only. Staged
sequencing is real risk reduction but is a second deploy plan on top of a wave that is
otherwise a single cutover (D15's "full replacement," not a toggle) — worth
proposing on its own if IT wants it, not worth building unasked. Backlog-only was
rejected because the hazard is concrete and dated (this wave, this migration), not a
someday item.
### What the runbook has to do differently from the 2026-08-04 precedent
- Name the five `OKTA_*` environment variables as newly required for this deploy —
the precedent's own "no new environment variables" note does not apply here and
restating it unchanged would be actively wrong.
- Treat the pre-deploy backup (`docker exec wp_db_backup /scripts/db-backup.sh`) as
the only way back once `1d60a608bb51` commits, not as routine due diligence.
- Separate two failure modes that look similar but are not: an Okta app integration
that is misconfigured after a clean deploy (redirect URI, client secret, an
unassigned test account) is fixable in place — fix the config, redeploy the `api`
service, no data at risk, migration already applied and stays applied. Deciding to
abandon Okta and restore local-password code is the severe case — the empty
`password_hash` column means the old code has nothing to check a password against,
so the only way back is the destructive backup restore (`DEPLOY-runbook-2026-08-04.md`
Case C's own procedure, reused here). Conflating these two would send an operator
straight to a destructive restore for a problem that a config fix would have solved.
- Fold in D16's no-break-glass posture: if Okta is down or misconfigured, the app is
down for everyone, including admins, by design — not a defect to roll back from.
Filed as **T10.9** in `wave-10.md` rather than folded into T10.6 (already merged and
verified) or T10.8 (UI/test verification, a different kind of check). New scope found
after the task that raised it closed gets a new ID, per `CLAUDE.md`.

233
docs/waves/wave-10.md Normal file
View File

@@ -0,0 +1,233 @@
# Wave 10 — Okta OIDC authentication
Fresh wave 10. The label was previously used by the LDAPS work under `D13`/`D14`, built on
`feat/ldaps-directory-auth`; that branch was deleted rather than merged and never appeared
in `IMPLEMENTATION.md`'s wave table, so it carries no claim on the number. See
`docs/waves/decisions-2026-09-02.md` (`D15`) for why LDAPS was retired before deployment
and Okta chosen instead.
Depends only on `main` as it stands after `D15`. Not sequenced behind any other wave.
## Tasks
- **T10.1 — Add the Okta OIDC client.** Authlib as a dependency. Config via env vars
(`OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_REDIRECT_URI`), same
pattern `AUTH_SECRET_KEY` already uses in `server/auth.py`.
- **T10.2 — Login-redirect and callback routes.** A route that sends the browser to
Okta's authorize endpoint, and a callback route that exchanges the code for tokens and
validates the ID token. Access gating is Okta's job, not this app's: only accounts
assigned to the app integration in Okta can reach it at all, so there is no app-side
required-group or claim check layered on top. This is a deliberate difference from D13,
which had to gate on a required AD group itself because an LDAPS bind alone could not
distinguish an assigned user from any other domain account.
- **T10.3 — Identity matching and JIT provisioning.** Reuses D13's shape
(`_provision_from_directory`-style matching) keyed off an OIDC claim instead of an LDAP
search result. **Open dependency:** which claim carries the AD `sAMAccountName`
equivalent (`preferred_username`, `upn`, or a custom claim) is asked of security and not
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` (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.
- **T10.6 — Deployment docs and env var reference.** `DEPLOYMENT.md`,
`server/.env.example`, `server/README.md` describe the Okta config in place of the LDAP
config they never ended up describing (D13 never shipped, so these still describe the
original local-password system today).
Built: all three rewritten — the five `OKTA_*` vars documented the same way
`AUTH_SECRET_KEY` already was, the login-portal/self-service-reset sections replaced
with the Okta flow and the promote-not-create admin bootstrap (D16), the smoke-test
walkthrough updated for `WP_SMOKE_USER`-only / must-share-`AUTH_SECRET_KEY`-and-DB
(T10.4's `smoketest.py` rewrite). Also `docker-compose.yml`, not originally named in
this bullet: its `api` service sets `environment:` as an explicit allowlist, not
`env_file`, so the documented vars would silently never reach the container without
adding them there too — found and fixed in the same commit rather than shipping docs
for a config path that doesn't work. `OKTA_IDENTITY_CLAIM` mirrors
`okta_auth.py`'s own default (`preferred_username`) in the compose file rather than
defaulting to an empty string, which would 503 every sign-in.
Logged, not fixed here (out of scope): `users.failed_attempts`/`locked_until` are
vestigial (still reset on every Okta sign-in, nothing increments them since local
`login()` is gone); `server/README.md`'s own "Production — Docker Compose" section
is a self-contained alternate quickstart that already diverged from the real root
`docker-compose.yml` before this task and still does.
- **T10.7 — Test coverage without a live Okta dependency.** A fake-OIDC-provider test
seam, mirroring `ldap_fake.py`, so the suite runs with no live Okta tenant reachable.
Built: `server/okta_fake.py` (env-driven, `WP_OKTA_FAKE_DIRECTORY`, production-refusing
the same way `ldap_fake.py` does), dispatched from `okta_auth._build_oauth()` before
the real Authlib client is considered. Only the two Authlib calls that touch the
network — `authorize_redirect` / `authorize_access_token` — are faked; `app.py`'s
`okta_login()`/`okta_callback()` (the `?next=` guard, the disabled-account check, JIT
provisioning, the identity-claim lookup) run unmodified against the fake, same
boundary the LDAP predecessor drew around the anonymous-bind guard. Two fake-only
routes (`/_fake_provider`, `/_fake_provider/consent`) stand in for Okta's own sign-in
screen and are registered in `app.py` only when the fake is active at import time —
in production they do not exist, not merely refuse. `tests/browser_check.py`'s
`start_server()` now takes an optional `extra_env` and sets
`WP_OKTA_FAKE_DIRECTORY` unconditionally (same reasoning `ldap_fake`'s equivalent
used: almost nothing signs in, but the one check that does should not fail
mysteriously). `tests/url_state_check.py` scenario 2 is un-skipped and drives the
real round trip — login.html's button, the fake picker page, the fake consent
redirect, `okta_callback()` — proving `?next=` survives it, same tightened
"actually left login.html" assertion the LDAP predecessor's own bug fix used.
`tests/okta_auth_check.py` is new: the production guard, single-use/replay on the
authorization code, an unsolicited callback hit, a tampered state, a denied consent,
an unknown identity, a disabled account, JIT provisioning, an existing admin
surviving unchanged, same-site vs. off-site `?next=`, and `OKTA_IDENTITY_CLAIM`
genuinely working under a non-default claim name — 22/22.
The dev sandbox this was built in has no headless browser and no way to install
one, so `tests/url_state_check.py` and `tests/browser_check.py` (both need
`tests/cdp.py`'s real headless Chromium) could not be run there — only
`okta_auth_check.py`'s HTTP-level coverage of the same mechanism. Run for real
afterward on a machine with Docker, via a separate general-purpose tool
(`headless-py-test-runner`, kept out of this repo — it is not Work Package Suite
specific): `url_state_check.py` 26/26, including scenario 2's real click-through of
the fake-Okta round trip, and `browser_check.py` 71/71. Gap closed.
**Validated 2026-09-03:** `tests/okta_auth_check.py` 22/22 (no browser needed) ·
`tests/url_state_check.py` 26/26 · `tests/browser_check.py` 71/71 — all three run
clean, the last two against real headless Chromium via Docker.
- **T10.8 — Verification.** 390px and 1440px, full suite, done-when checks per task,
matching the rigor D13 was held to.
Done-when checks per task, verified against the actual code rather than
re-reading this file's own claims: T10.1 (Authlib pinned, the four env vars,
`is_configured()`/`describe()`), T10.2 (the login/callback routes, no
app-side group or claim gate layered on Okta's own), T10.3 (identity-claim
matching, JIT at the lowest role, local deprovisioning still enforced after
Okta approves), T10.4 (zero remaining references to `bcrypt` /
`password_hash` / `hash_password` / `verify_password` anywhere in `.py` or
`.js`, `manage_users.py promote`, `smoketest.py` / `seed_demo.py` /
`browser_check.py` / `launcher_check.py` all minting via `create_token()`),
T10.5 (`login.html` is one Okta link, no password field). All matched what
this file already claimed — no drift found.
Full suite, run through the Docker test runner (all 41 files in `tests/`,
bare invocation): 39 passed clean. `tests/token_check.py` "failed" at exit 2,
but that is a harness mismatch, not a check failure — it is a two-step
snapshot/diff tool (`--out` to capture, `--compare A B` to diff) and prints
usage + exits 2 when run with no arguments, which is what a bare full-suite
pass does to every file. `tests/generalinfo_check.py` scored 48/49 — the one
failure is a raw `rgba()` shadow literal in `wp-creation-styles.css`,
confirmed via `git show HEAD` to already be committed and unrelated to this
wave (it is the Micron asset picker's dropdown shadow from the `D11` merge,
2026-08-20, predating this wave by two weeks). Logged as `BL-031` rather than
fixed here — an unrelated CSS token-rule violation is not this wave's to fix.
`tests/okta_auth_check.py` re-run fresh (no browser needed): 22/22.
390px and 1440px: `tests/baseline_shots.py` captured all fourteen shots
(login, launcher, sop, creator, admin, users, field × two widths) into
`docs/reference/baseline/`. `login-390.png`/`login-1440.png` and
`users-390.png`/`users-1440.png` visually confirmed: the login page is a
single "Sign in with Okta" button with no username/password form at either
width, and the User Directory's table and "Add a user" form both carry no
password column and no reset-password action anywhere, at either width.
Wave 10 is complete. The three items in "Still open" below are external
(security team / Okta admin), not blocked on any task in this wave.
- **T10.10 — Audit `manage_users.py promote`.** Raised in review after T10.8:
`cmd_promote()` changed a user's role with no audit trail at all, unlike the
identical role change from the web Admin Console (`app.py`'s
`set_user_role()``log_event()`, action `"role_changed"`). Not a new
privilege — anyone with Portainer/container-exec access to `wp_api` already
has shell access to the database directly, same trust tier D16 already named
for this command — but there was no record of who ran it or what changed.
Built: `cmd_promote()` now writes an `AuditLog` row with the same
`action`/`detail` shape `set_user_role()` uses (`{"from": old_role, "to":
role}`), tagged `"via": "cli"` (mirrors JIT provisioning's own `"via":
"okta_jit"` tag) and `actor="cli:manage_users"` — a container shell exec
carries no signed-in identity to attribute the change to a real person, so
it names the tool rather than guessing one. Verified end to end against a
scratch SQLite database: the audit row lands with the exact expected shape,
the role change persists, and the existing "no such user" refusal still
exits 1 with no partial write.
- **T10.9 — Rollback-aware deploy runbook.** Raised after hazard review found
`DEPLOY-runbook-2026-08-04.md`'s Rollback section has no case for a migration whose
`downgrade()` cannot restore the data it drops — see `D17`. `T10.4`'s
`1d60a608bb51_drop_local_password` is exactly that: the schema comes back, the
bcrypt hashes do not.
Built: `DEPLOY-runbook-2026-09-03.md`, following the 2026-08-04 runbook's structure
(fill-in table, numbered deploy steps, case-by-case Rollback section, Notes). Names
the five `OKTA_*` vars as newly required (the precedent's "no new environment
variables" note does not carry over), treats the pre-deploy backup as the only way
back once the migration commits, and splits Rollback into the fixable case (Okta app
integration misconfigured — fix and redeploy `api`, no data at risk, migration stays
applied) versus the severe case (abandoning Okta for local-password code — only the
destructive backup restore gets there, reusing the 2026-08-04 runbook's own Case C
procedure). D16's no-break-glass posture is stated plainly rather than left implicit.
Logged, not fixed here (out of scope): `okta_auth.describe()`'s startup log line
(referenced by `DEPLOYMENT.md`/`server/README.md`) has no caller anywhere in
`server/app.py` — nothing actually prints it at process start. The runbook's Step 4
therefore verifies via a live Okta sign-in rather than a log line, and this gap is
flagged in `docs/waves/backlog.md` as a candidate fix (wiring `describe()` into
startup) since it directly bears on deploy verifiability. `DEPLOY-login-portal.md`
is now fully stale (bcrypt, `create-admin --password`, none of which still exist) —
not touched, no task claims it.
## Still open
- The `Business Technology Group` pilot assignment in Okta. Originally six names
(Carlee Swihart, Drew Hilliard, Matt Mabrey, Nick Siegfried, Rachel Schreiber, Terry
Sajan); Cody and Cameron added 2026-09-09. Adrian added only Matt at first,
deliberately, pending the live sign-in confirmation below — awaiting his response to
add the rest of the group now that it has.
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.
**Closed 2026-09-09, live in production:** the redirect/callback URI
(`https://wp.controls.dev/api/auth/okta/callback`) is confirmed working, and so is the
OIDC claim mapping (`T10.3`) — `preferred_username` (the code's documented default,
never actually confirmed by name in Request 50649's thread) is correct, no
`OKTA_IDENTITY_CLAIM` override needed. Both settled by an actual live sign-in against
the real Okta tenant after `main` was merged (`cc64c88`) and deployed: Matt signed in
as himself, matched his existing pre-Okta admin account by `find_user()` rather than
JIT-provisioning a duplicate (the account already existed — this app has ~40 real
users, not the seeded test fixture), landed on `index.html` signed in, admin role and
project access untouched. One real deploy-time snag on the way, worth recording since
it's exactly the Case B scenario `DEPLOY-runbook-2026-09-03.md` anticipated: the first
redeploy left `OKTA_CLIENT_ID`/`OKTA_CLIENT_SECRET`/`OKTA_ISSUER` as empty rows in
Portainer (env var names added, values never filled in) — caught via
`is_configured()`'s all-four-required check failing closed (the 503 "Sign-in is
temporarily unavailable"), not silently. A second snag after filling those in:
`OKTA_ISSUER` was pasted without its `https://` scheme, which surfaced as
`httpx.UnsupportedProtocol` from Authlib's OIDC discovery fetch rather than anything
the app's own code raises deliberately — the exact case the runbook's Notes flagged as
having no startup-time confirmation (`okta_auth.describe()` still has no caller,
`BL-028`). Both fixed by correcting the env var values in Portainer and redeploying;
neither needed the backup or the database.

View File

@@ -60,62 +60,10 @@
.then(function () { window.location.replace('login.html'); }); .then(function () { window.location.replace('login.html'); });
}; };
// Change-password dialog (uses POST /api/auth/password, which requires the // window.wpChangePassword used to open a change-password dialog here. Removed in
// current password). Available from the top-right pill on any page. // T10.4 (D15/D16): there is no local password to change anymore — identity is
window.wpChangePassword = function () { // Okta's job. The "Password" item that called this is gone from wp-sidenav.js
if (document.getElementById('wp-pw-modal')) return; // too.
var ov = document.createElement('div');
ov.id = 'wp-pw-modal';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;' +
'justify-content:center;z-index:10002;padding:20px;font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;';
var inp = 'width:100%;padding:9px 10px;margin-bottom:12px;border:1px solid var(--cds-border-strong);border-radius:4px;font-size:14px;';
var lbl = 'display:block;font-size:12px;color:var(--cds-text-secondary);margin-bottom:4px;';
ov.innerHTML =
'<div style="background:var(--cds-layer);color:var(--cds-text-primary);border-radius:10px;max-width:380px;width:100%;box-shadow:0 12px 40px rgba(20,30,50,.3);overflow:hidden;">' +
'<div style="padding:14px 18px;border-bottom:1px solid var(--cds-border-subtle);font-weight:700;">Change password</div>' +
'<div style="padding:16px 18px;">' +
'<div id="wp-pw-msg" style="display:none;font-size:12.5px;padding:8px 10px;border-radius:6px;margin-bottom:12px;"></div>' +
'<label style="' + lbl + '">Current password</label>' +
'<input id="wp-pw-cur" type="password" autocomplete="current-password" style="' + inp + '">' +
'<label style="' + lbl + '">New password (at least 12 characters)</label>' +
'<input id="wp-pw-new" type="password" autocomplete="new-password" style="' + inp + '">' +
'<label style="' + lbl + '">Confirm new password</label>' +
'<input id="wp-pw-new2" type="password" autocomplete="new-password" style="' + inp + 'margin-bottom:0;">' +
'</div>' +
'<div style="padding:12px 18px;border-top:1px solid var(--cds-border-subtle);display:flex;gap:8px;justify-content:flex-end;">' +
'<button type="button" id="wp-pw-cancel" style="padding:8px 14px;border:1px solid var(--cds-border-strong);background:var(--cds-layer);border-radius:6px;cursor:pointer;font-weight:600;">Cancel</button>' +
'<button type="button" id="wp-pw-save" style="padding:8px 14px;border:none;background:var(--cds-interactive-01);color:var(--cds-text-on-color);border-radius:6px;cursor:pointer;font-weight:600;">Update password</button>' +
'</div>' +
'</div>';
function close() { var m = document.getElementById('wp-pw-modal'); if (m) m.remove(); }
function msg(text, ok) {
var el = document.getElementById('wp-pw-msg');
el.style.display = 'block'; el.textContent = text;
el.style.background = ok ? 'var(--wp-status-success-bg)' : 'var(--wp-status-error-bg)'; el.style.color = ok ? 'var(--wp-status-success-text)' : 'var(--cds-support-error)';
}
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
document.body.appendChild(ov);
document.getElementById('wp-pw-cancel').onclick = close;
document.getElementById('wp-pw-cur').focus();
document.getElementById('wp-pw-save').onclick = function () {
var cur = document.getElementById('wp-pw-cur').value;
var n1 = document.getElementById('wp-pw-new').value;
var n2 = document.getElementById('wp-pw-new2').value;
if (!cur || !n1) { msg('Please fill in every field.', false); return; }
if (n1.length < 12) { msg('New password must be at least 12 characters.', false); return; }
if (n1 !== n2) { msg('New passwords do not match.', false); return; }
fetch('/api/auth/password', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ current_password: cur, new_password: n1 })
})
.then(function (r) { return r.json().catch(function () { return null; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); })
.then(function (res) {
if (res.ok) { msg('Password updated.', true); setTimeout(close, 1200); }
else { msg((res.j && res.j.detail) || ('Could not update (HTTP ' + res.status + ').'), false); }
})
.catch(function () { msg('Could not reach the server.', false); });
};
};
// ── permissions helpers ──────────────────────────────────────────────────── // ── permissions helpers ────────────────────────────────────────────────────
// The server enforces all of this; these are for hiding controls the signed-in // The server enforces all of this; these are for hiding controls the signed-in
@@ -171,7 +119,8 @@
// Admin, Users and Sign out from the navigation drawer, and being one unbreakable // Admin, Users and Sign out from the navigation drawer, and being one unbreakable
// 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px // 412px run with an inline white-space:nowrap, it was what clipped the bar at 390px
// and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including // and cut "Sign out" in half — F2. wp-sidenav.js now carries all of it, including
// the two items that were only here: Language & time, and Password. // the item that was only here: Language & time. (Password was the other one; T10.4
// removed it along with the rest of local auth — D15/D16.)
// //
// Nothing replaces it. Every signed-in page mounts the drawer, so there is no page // Nothing replaces it. Every signed-in page mounts the drawer, so there is no page
// left that would need a floating fallback pill. // left that would need a floating fallback pill.

View File

@@ -31,34 +31,23 @@
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.brand img { height: 36px; width: auto; } .brand img { height: 36px; width: auto; }
.brand .name { font-weight: 700; font-size: 0.95rem; color: var(--cds-text-primary); }
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; } h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; } .sub { color: var(--cds-text-secondary); font-size: 0.875rem; margin-bottom: 1.75rem; }
label { display: block; font-size: 0.75rem; color: var(--cds-text-secondary); margin-bottom: 0.375rem; } .btn {
.field { margin-bottom: 1.25rem; } display: block;
input[type=text], input[type=password] {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
background: var(--cds-field);
border: none;
border-bottom: 1px solid var(--cds-border-strong);
outline: 2px solid transparent;
outline-offset: -2px;
}
input:focus { outline: 2px solid var(--cds-focus); background: var(--cds-field-hover); }
button {
width: 100%; width: 100%;
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
text-align: center;
text-decoration: none;
color: var(--cds-text-on-color); color: var(--cds-text-on-color);
background: var(--cds-button-primary); background: var(--cds-button-primary);
border: none; border: none;
transition: background 0.15s; transition: background 0.15s;
} }
button:hover:not(:disabled) { background: var(--cds-hover-primary); } .btn:hover { background: var(--cds-hover-primary); }
button:disabled { background: var(--cds-disabled-02); cursor: not-allowed; } .btn:focus-visible { outline: 2px solid var(--cds-focus); outline-offset: 2px; }
.error { .error {
display: none; display: none;
background: var(--wp-status-error-bg); background: var(--wp-status-error-bg);
@@ -69,7 +58,6 @@
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.error.show { display: block; } .error.show { display: block; }
.foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
.ok { .ok {
display: none; display: none;
background: var(--wp-status-success-bg); background: var(--wp-status-success-bg);
@@ -80,15 +68,7 @@
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.ok.show { display: block; } .ok.show { display: block; }
.note { .foot { margin-top: 1.5rem; font-size: 0.75rem; color: var(--cds-text-helper); text-align: center; }
font-size: 0.8125rem; color: var(--cds-text-secondary);
background: var(--cds-layer-accent); border-left: 3px solid var(--cds-link-primary);
padding: 0.75rem; margin-bottom: 1.25rem;
}
.hint { font-size: 0.75rem; color: var(--cds-text-helper); margin-top: -0.75rem; margin-bottom: 1.25rem; }
a.link { color: var(--cds-link-primary); text-decoration: none; font-size: 0.8125rem; }
a.link:hover { text-decoration: underline; }
.center { text-align: center; margin-top: 1.25rem; }
</style> </style>
</head> </head>
<body> <body>
@@ -99,61 +79,10 @@
<div id="error" class="error" role="alert"></div> <div id="error" class="error" role="alert"></div>
<div id="ok" class="ok" role="status"></div> <div id="ok" class="ok" role="status"></div>
<!-- SIGN IN --> <h1>Sign in</h1>
<section id="view-login"> <p class="sub">Work Package Suite uses your organization's Okta sign-in. Select the
<h1>Sign in</h1> button below and follow the prompts there.</p>
<p class="sub">Work Package Suite</p> <a id="okta-signin" class="btn" href="/api/auth/okta/login" autofocus>Sign in with Okta</a>
<form id="login-form" autocomplete="on">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
</div>
<button id="submit" type="submit">Sign in</button>
</form>
<p class="center"><a href="#" id="forgot-link" class="link">Forgot password?</a></p>
</section>
<!-- FORGOT PASSWORD (email reset) -->
<section id="view-forgot" style="display:none">
<h1>Reset password</h1>
<p class="sub">We'll email you a link to set a new one.</p>
<div id="forgot-unavailable" class="note" style="display:none">
Password reset by email isn't switched on yet. Contact your project admin and
they'll set a new password for you. Once you're signed in you can change it
yourself from the menu in the top-right corner.
</div>
<form id="forgot-form" autocomplete="on">
<div class="field">
<label for="forgot-username">Username or email</label>
<input id="forgot-username" type="text" autocomplete="username" required>
</div>
<button id="forgot-submit" type="submit">Email me a reset link</button>
</form>
<p class="center"><a href="#" id="back-to-login" class="link">← Back to sign in</a></p>
</section>
<!-- SET A NEW PASSWORD (arrived from the emailed link) -->
<section id="view-reset" style="display:none">
<h1>Set a new password</h1>
<p class="sub">Choose a password you don't use anywhere else.</p>
<form id="reset-form" autocomplete="on">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" autocomplete="new-password" autofocus required>
</div>
<div class="hint">At least 12 characters.</div>
<div class="field">
<label for="new-password2">Confirm new password</label>
<input id="new-password2" type="password" autocomplete="new-password" required>
</div>
<button id="reset-submit" type="submit">Set password &amp; sign in</button>
</form>
<p class="center"><a href="#" id="reset-to-login" class="link">← Back to sign in</a></p>
</section>
<p class="foot">Authorized use only · BTG / Pilot</p> <p class="foot">Authorized use only · BTG / Pilot</p>
</main> </main>

View File

@@ -1,26 +1,16 @@
/* Login page logic for the Work Package Suite. /* Login page logic for the Work Package Suite.
Three views on one page: One action: sign in with Okta. There is no local password anymore (D15/D16,
• sign in posts to /api/auth/login. On success the server sets an T10.4) — this page's only job is building the link to /api/auth/okta/login
HttpOnly session cookie (not readable here — that's the (carrying ?next=, if there was one) and showing a plain-language message for
point) and we redirect to ?next= or the home page. the failure states server/app.py's okta_callback() sends back here (T10.5). */
• forgot password posts to /api/auth/forgot-password, which emails a
single-use link. Only offered when the server reports
email is actually configured (/api/auth/reset-available);
otherwise we say to ask an admin.
• set a new password shown when the page is opened as login.html?reset=<token>
from that email. Posts to /api/auth/reset-password.
The reset token stays in the URL only until it's used; on success we strip it
from the address bar so it isn't left in history or copied out of the bar. */
(function () { (function () {
'use strict'; 'use strict';
var errorBox = document.getElementById('error'); var errorBox = document.getElementById('error');
var okBox = document.getElementById('ok'); var okBox = document.getElementById('ok');
var signinLink = document.getElementById('okta-signin');
function show(el) { if (el) el.style.display = ''; }
function hide(el) { if (el) el.style.display = 'none'; }
function byId(id) { return document.getElementById(id); } function byId(id) { return document.getElementById(id); }
function showError(msg) { function showError(msg) {
@@ -28,188 +18,38 @@
errorBox.textContent = msg; errorBox.textContent = msg;
errorBox.classList.add('show'); errorBox.classList.add('show');
} }
function showOk(msg) {
errorBox.classList.remove('show');
okBox.textContent = msg;
okBox.classList.add('show');
}
function clearBanners() {
errorBox.classList.remove('show');
okBox.classList.remove('show');
}
// Where to go after signing in: the ?next= param if it's a safe same-site // Same-site path only — mirrors the check server/app.py's _safe_next_path()
// path, otherwise the home page. (Reject absolute/scheme URLs to avoid an // makes again on the way back, so a crafted ?next= can't become an open
// open-redirect.) // redirect even if this client-side check were somehow bypassed.
function nextTarget() { function safeNext() {
try { try {
var next = new URLSearchParams(location.search).get('next') || ''; var next = new URLSearchParams(location.search).get('next') || '';
if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next; if (next && next.charAt(0) === '/' && next.charAt(1) !== '/') return next;
} catch (e) {} } catch (e) {}
return 'index.html'; return '';
} }
function resetToken() { if (signinLink) {
try { return new URLSearchParams(location.search).get('reset') || ''; } catch (e) { return ''; } var next = safeNext();
if (next) signinLink.href = '/api/auth/okta/login?next=' + encodeURIComponent(next);
} }
function postJson(url, payload) { var ERROR_MESSAGES = {
return fetch(url, { disabled: 'Your account has been disabled. Contact an administrator.',
method: 'POST', cancelled: 'Sign-in was not completed. Select the button below to try again.'
headers: { 'Content-Type': 'application/json' }, };
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().catch(function () { return null; }).then(function (j) {
return { status: r.status, ok: r.ok, json: j };
});
});
}
function detail(res, fallback) { (function showErrorFromQuery() {
var d = res && res.json && res.json.detail; try {
return (typeof d === 'string' && d) ? d : fallback; var code = new URLSearchParams(location.search).get('error') || '';
} if (!code) return;
showError(ERROR_MESSAGES[code] || 'Sign-in was not completed. Select the button below to try again.');
function view(which) { // Out of the address bar once shown — an error code has no reason to
clearBanners(); // survive a refresh or get copied along with the link.
['login', 'forgot', 'reset'].forEach(function (v) { var url = new URL(location.href);
(which === v ? show : hide)(byId('view-' + v)); url.searchParams.delete('error');
}); history.replaceState(null, '', url.pathname + url.search);
} } catch (e) {}
})();
// ── sign in ────────────────────────────────────────────────────────────────
var form = byId('login-form');
var submitBtn = byId('submit');
// Guarded because a cached older login.html may not have the reset views; an
// unguarded addEventListener on null would break sign-in itself.
if (!form || !submitBtn) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var username = byId('username').value.trim();
var password = byId('password').value;
if (!username || !password) { showError('Enter your username and password.'); return; }
submitBtn.disabled = true;
submitBtn.textContent = 'Signing in…';
postJson('/api/auth/login', { username: username, password: password })
.then(function (res) {
if (res.ok) { location.replace(nextTarget()); return; }
if (res.status === 401) showError('Invalid username or password.');
else if (res.status === 403) showError(detail(res, 'Your account is disabled.'));
else if (res.status === 429) showError(detail(res, 'Too many failed attempts. Try again later.'));
else showError(detail(res, 'Sign-in failed (HTTP ' + res.status + ').'));
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Sign in';
});
});
// ── forgot password ────────────────────────────────────────────────────────
var resetAvailable = null; // null = not checked yet
function checkResetAvailable() {
if (resetAvailable !== null) return Promise.resolve(resetAvailable);
return fetch('/api/auth/reset-available')
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { resetAvailable = !!(j && j.enabled); return resetAvailable; })
.catch(function () { resetAvailable = false; return false; });
}
(byId('forgot-link') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('forgot');
// Prefill from the sign-in box so nobody types their username twice.
var u = byId('username').value.trim();
if (u) byId('forgot-username').value = u;
checkResetAvailable().then(function (enabled) {
// With email off there's nothing to submit — say so and hide the form.
(enabled ? hide : show)(byId('forgot-unavailable'));
(enabled ? show : hide)(byId('forgot-form'));
if (enabled) byId('forgot-username').focus();
});
});
(byId('back-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var forgotForm = byId('forgot-form') || document.createElement('form');
var forgotBtn = byId('forgot-submit') || document.createElement('button');
forgotForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var who = byId('forgot-username').value.trim();
if (!who) { showError('Enter your username or email.'); return; }
forgotBtn.disabled = true;
forgotBtn.textContent = 'Sending…';
postJson('/api/auth/forgot-password', { username: who })
.then(function (res) {
if (res.status === 503) {
showError(detail(res, "Password reset by email isn't available. Ask an administrator."));
} else if (res.ok) {
// Deliberately the same message whether or not the account exists.
showOk('If that account exists, a reset link is on its way. The link expires in an hour.');
hide(forgotForm);
} else {
showError(detail(res, 'Could not send the reset email (HTTP ' + res.status + ').'));
}
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
forgotBtn.disabled = false;
forgotBtn.textContent = 'Email me a reset link';
});
});
// ── set a new password (from the emailed link) ──────────────────────────────
(byId('reset-to-login') || {addEventListener: function(){}}).addEventListener('click', function (e) {
e.preventDefault();
view('login');
});
var resetForm = byId('reset-form') || document.createElement('form');
var resetBtn = byId('reset-submit') || document.createElement('button');
resetForm.addEventListener('submit', function (e) {
e.preventDefault();
clearBanners();
var token = resetToken();
var pw = byId('new-password').value;
var pw2 = byId('new-password2').value;
if (!token) { showError('This reset link is incomplete. Request a new one.'); return; }
if (pw !== pw2) { showError('The two passwords do not match.'); return; }
if (pw.length < 12) { showError('Password must be at least 12 characters.'); return; }
resetBtn.disabled = true;
resetBtn.textContent = 'Saving…';
postJson('/api/auth/reset-password', { token: token, new_password: pw })
.then(function (res) {
if (res.ok) {
// Take the token out of the URL before anything else — it's spent.
try { history.replaceState(null, '', 'login.html'); } catch (err) {}
view('login');
showOk('Password updated. Sign in with your new password.');
byId('username').focus();
return;
}
showError(detail(res, 'Could not set your password (HTTP ' + res.status + ').'));
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
})
.catch(function () {
showError('Could not reach the server. Check your connection and try again.');
resetBtn.disabled = false;
resetBtn.textContent = 'Set password & sign in';
});
});
// Arriving from the reset email opens straight into the new-password view.
if (resetToken()) view('reset');
})(); })();

View File

@@ -26,9 +26,6 @@
room for "Assistant Project Manager" without pushing Actions off screen. */ room for "Assistant Project Manager" without pushing Actions off screen. */
#users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; } #users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); } #users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
/* The create form is a lot of fields; give the password one room to breathe and
let the project picker take a full row of its own. */
#nu-password{ flex:1 1 200px; }
#nu-projects{ margin-top:var(--s2); } #nu-projects{ margin-top:var(--s2); }
#nu-projects .pickrow{ padding:var(--s1) var(--s1); } #nu-projects .pickrow{ padding:var(--s1) var(--s1); }
/* A manager with one project doesn't need a scrolling picker; a manager with /* A manager with one project doesn't need a scrolling picker; a manager with
@@ -83,12 +80,12 @@
<h2>Add a user</h2> <h2>Add a user</h2>
<div class="sub" id="create-sub"></div> <div class="sub" id="create-sub"></div>
<div class="urow"> <div class="urow">
<input id="nu-username" placeholder="Username *" autocomplete="off"> <input id="nu-username" placeholder="Username *" autocomplete="off"
title="Must exactly match this person's Okta sign-in identity — that's how their first Okta sign-in finds this account instead of creating a second one.">
<input id="nu-fullname" placeholder="Full name" autocomplete="off"> <input id="nu-fullname" placeholder="Full name" autocomplete="off">
<input id="nu-email" placeholder="Email" autocomplete="off"> <input id="nu-email" placeholder="Email" autocomplete="off">
<select id="nu-role" title="Permissions — what this account may do"></select> <select id="nu-role" title="Permissions — what this account may do"></select>
<select id="nu-project-role" title="Job function on the project"></select> <select id="nu-project-role" title="Job function on the project"></select>
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
</div> </div>
<div id="nu-projects"> <div id="nu-projects">
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div> <div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>

View File

@@ -183,11 +183,9 @@ function managerRow(u){
: projRoleReadonly(u, can, why); : projRoleReadonly(u, can, why);
const actions = []; const actions = [];
if(can && !me) actions.push('<button class="mini" onclick="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+ if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
(u.is_active?'Disable':'Enable')+'</button>'); (u.is_active?'Disable':'Enable')+'</button>');
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>'); if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
if(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</button>');
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>'); if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+ return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
@@ -254,19 +252,6 @@ function projAccessCell(u){
// ── row actions ─────────────────────────────────────────────────────────────── // ── row actions ───────────────────────────────────────────────────────────────
// Each one reloads on failure so a control can never sit there showing a value the // Each one reloads on failure so a control can never sit there showing a value the
// server refused. // server refused.
async function resetPw(id, username){
// The min-12 rule was stated in the prompt label and enforced only by the
// server round-trip; the kit's validate() answers AT the input instead.
const pw = await wpPromptDialog({title:'Reset password',
message:'Set a new password for "'+username+'". Their existing sessions are signed out.',
label:'New password (min 12 characters)',
validate:v => (v && v.length >= 12) ? '' : 'At least 12 characters.'});
if(pw === null) return;
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
if(status === 200) toast('Password reset for '+username+'. Their existing sessions are signed out.');
else wpAlertDialog({title:'Reset failed', message:'Could not reset the password: '+apiError(status, json)});
}
async function toggleActive(id, makeActive){ async function toggleActive(id, makeActive){
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive}); const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
if(status === 200) loadUsers(); if(status === 200) loadUsers();
@@ -344,25 +329,27 @@ function renderCreateForm(){
async function createUser(){ async function createUser(){
const msg = document.getElementById('users-create-msg'); const msg = document.getElementById('users-create-msg');
const val = id => (document.getElementById(id)||{}).value || ''; const val = id => (document.getElementById(id)||{}).value || '';
// The username entered here MUST match what Okta's identity claim will send for
// this person exactly — this creates the account ahead of their first sign-in,
// and that's how a later Okta sign-in finds this row instead of provisioning a
// second one. See create_user()'s docstring in server/app.py.
const username = val('nu-username').trim(); const username = val('nu-username').trim();
const password = val('nu-password');
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')] const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
.map(c => c.value); .map(c => c.value);
const say = (color, text) => { msg.style.color = color; msg.textContent = text; }; const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
if(!username){ say('var(--red)','Username is required.'); return; } if(!username){ say('var(--red)','Username is required.'); return; }
if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; }
if(_scope.scope !== 'all' && !project_ids.length){ if(_scope.scope !== 'all' && !project_ids.length){
say('var(--red)','Pick at least one project — you administer users per project.'); return; say('var(--red)','Pick at least one project — you administer users per project.'); return;
} }
say('var(--muted)','Creating…'); say('var(--muted)','Creating…');
const { status, json } = await api('POST','/api/auth/users',{ const { status, json } = await api('POST','/api/auth/users',{
username, password, project_ids, username, project_ids,
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(), full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
role: val('nu-role'), project_role: val('nu-project-role'), role: val('nu-role'), project_role: val('nu-project-role'),
}); });
if(status === 200){ if(status === 200){
say('var(--green)','✓ Created '+username+'.'); say('var(--green)','✓ Created '+username+'.');
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = ''); ['nu-username','nu-fullname','nu-email'].forEach(id => document.getElementById(id).value = '');
loadUsers(); loadUsers();
} else { } else {
say('var(--red)','✕ '+apiError(status, json, 'Could not create the account')); say('var(--red)','✕ '+apiError(status, json, 'Could not create the account'));

View File

@@ -105,6 +105,18 @@
say(bits.join(''), problem); say(bits.join(''), problem);
} }
// A 500 answers plain text ("Internal Server Error"), and r.json() on that
// throws - which used to land in catch() and read as "could not reach the
// server" while the server was answering fine (found 2026-08-23, the
// production locations import). Read text, parse if it parses, keep status.
function readJson(r) {
return r.text().then(function (t) {
var j = null;
try { j = t ? JSON.parse(t) : null; } catch (e) { /* not JSON: a proxy or 500 page */ }
return { ok: r.ok, status: r.status, body: j };
});
}
function importText(dryRun) { function importText(dryRun) {
var text = (el(p + '-paste') || {}).value || ''; var text = (el(p + '-paste') || {}).value || '';
if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; } if (!text.trim()) { say('Paste some rows or choose a CSV file first.', true); return; }
@@ -114,7 +126,7 @@
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ text: text, dry_run: !!dryRun }), body: JSON.stringify({ text: text, dry_run: !!dryRun }),
}) })
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); }) .then(readJson)
.then(function (res) { .then(function (res) {
if (!res.ok) { if (!res.ok) {
say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true); say('⚠ Import refused — ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);
@@ -142,7 +154,7 @@
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(read.payload), body: JSON.stringify(read.payload),
}) })
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); }) .then(readJson)
.then(function (res) { .then(function (res) {
if (!res.ok) { if (!res.ok) {
setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')')); setAddError((res.body && res.body.detail) || ('Could not add it (HTTP ' + res.status + ')'));
@@ -161,7 +173,7 @@
method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(patchBody), body: JSON.stringify(patchBody),
}) })
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, status: r.status, body: j }; }); }) .then(readJson)
.then(function (res) { .then(function (res) {
if (!res.ok) { if (!res.ok) {
say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true); say('⚠ ' + esc((res.body && res.body.detail) || ('HTTP ' + res.status)), true);

View File

@@ -53,7 +53,6 @@
{ section: 'Account' }, { section: 'Account' },
{ action: 'wpPreferences', icon: '◷', label: 'Language & time', { action: 'wpPreferences', icon: '◷', label: 'Language & time',
sub: 'Dates, numbers and time zone' }, sub: 'Dates, numbers and time zone' },
{ action: 'wpChangePassword', icon: '⚿', label: 'Password', sub: 'Change your password' },
]; ];
function esc(v) { function esc(v) {

View File

@@ -12,15 +12,49 @@ DATABASE_URL=postgresql+psycopg://wpsuite:CHANGE_ME@localhost:5432/wpsuite
# CORS_ORIGINS=http://localhost:5500 # CORS_ORIGINS=http://localhost:5500
# ── Authentication ──────────────────────────────────────────────────────────── # ── Authentication ────────────────────────────────────────────────────────────
# Secret used to sign session cookies (JWTs). REQUIRED in production: if unset, # There is no local password (D15/D16) — Okta OIDC is the only way in. Sign-in
# the API falls back to a random per-process key, so logins reset on every # still ends the same way it always did: a signed JWT in an HttpOnly session
# restart and break across multiple gunicorn workers. Generate a strong one: # cookie, which is what the four vars right below this line are for. The five
# OKTA_* vars after that are what makes the actual sign-in possible; without
# them the API starts (this is not a hard failure like AUTH_SECRET_KEY), but
# describe()'s startup log line says so and nobody can sign in.
# Secret used to sign session cookies (JWTs), AFTER Okta has confirmed who
# someone is — this app still decides roles/authorization locally, unchanged
# by Okta (see server/okta_auth.py). REQUIRED in production: if unset, the API
# falls back to a random per-process key, so logins reset on every restart and
# break across multiple gunicorn workers. Generate a strong one:
# python -c "import secrets; print(secrets.token_urlsafe(48))" # python -c "import secrets; print(secrets.token_urlsafe(48))"
AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above AUTH_SECRET_KEY=CHANGE_ME_run_the_command_above
# How long a login lasts before re-authentication (hours). Default 12. # How long a login lasts before re-authentication (hours). Default 12.
# AUTH_SESSION_HOURS=12 # AUTH_SESSION_HOURS=12
# ── Okta OIDC (required — this is the only sign-in path) ───────────────────────
# The Okta *authorization server* issuer, e.g. https://yourorg.okta.com/oauth2/default
# or a custom authorization server URL. The API discovers the authorize/token/
# jwks endpoints from <OKTA_ISSUER>/.well-known/openid-configuration — nothing
# else about Okta's endpoints is hand-entered.
OKTA_ISSUER=https://your-org.okta.com/oauth2/default
# Client ID and secret from the Okta app integration (Sign-in method: OIDC -
# Authorization Code, Application type: Web Application). The secret is exactly
# that — treat it like AUTH_SECRET_KEY, never commit it.
OKTA_CLIENT_ID=CHANGE_ME
OKTA_CLIENT_SECRET=CHANGE_ME
# Must exactly match a "Sign-in redirect URI" registered on the Okta app
# integration, scheme and path included, e.g.:
# https://wp-suite.company.local/api/auth/okta/callback
OKTA_REDIRECT_URI=CHANGE_ME
# Which ID token claim carries this person's directory identity, matched
# against the local users.username column (server/app.py's okta_callback()).
# preferred_username is Okta's usual default for an AD-imported user; override
# it if your security team's Okta configuration uses a different claim (upn,
# a custom claim, …) — no code change needed, just this value.
# OKTA_IDENTITY_CLAIM=preferred_username
# ── Email notifications (optional) ───────────────────────────────────────────── # ── Email notifications (optional) ─────────────────────────────────────────────
# WP-assignment emails are OFF by default and are turned on from the Admin # WP-assignment emails are OFF by default and are turned on from the Admin
# console (Notifications & email card), where the SMTP host/port/from-address # console (Notifications & email card), where the SMTP host/port/from-address

View File

@@ -14,12 +14,12 @@ browser → NGINX ──serves──> static site (index.html, …)
| Method | Path | Purpose | | Method | Path | Purpose |
|--------|------|---------| |--------|------|---------|
| GET | `/api/health` | liveness check (unauthenticated) | | GET | `/api/health` | liveness check (unauthenticated) |
| POST | `/api/auth/login` | sign in (`{username, password}`) — sets the session cookie | | GET | `/api/auth/okta/login` | redirects the browser to Okta's authorize endpoint (`?next=` optional) |
| GET | `/api/auth/okta/callback` | Okta redirects back here with the auth code; signs the person in |
| POST | `/api/auth/logout` | clear the session cookie | | POST | `/api/auth/logout` | clear the session cookie |
| GET | `/api/auth/me` | the logged-in user | | GET | `/api/auth/me` | the logged-in user |
| POST | `/api/auth/password` | change your own password |
| GET | `/api/auth/users` | list accounts (**admin**) | | GET | `/api/auth/users` | list accounts (**admin**) |
| POST | `/api/auth/users` | create an account (**admin**) | | POST | `/api/auth/users` | pre-create an account by username (**admin**) |
| DELETE | `/api/auth/users/{id}` | delete an account (**admin**) | | DELETE | `/api/auth/users/{id}` | delete an account (**admin**) |
| POST | `/api/sops` | create/update a SOP (upsert by `id`) | | POST | `/api/sops` | create/update a SOP (upsert by `id`) |
| GET | `/api/sops` | list SOP summaries | | GET | `/api/sops` | list SOP summaries |
@@ -40,50 +40,81 @@ fields (name, number, status, …) are promoted to columns for listing/filtering
--- ---
## Login portal (user accounts) ## Sign-in (Okta)
The suite is gated by a username/password login. Sign-in issues a signed JWT There is no local password anywhere in this app (D15/D16) — Okta OIDC is the
that rides in an **HttpOnly, SameSite=Lax** cookie (`wp_session`); the cookie is only way in. `login.html` is a single "Sign in with Okta" button; the actual
marked **Secure** automatically whenever the request arrives over HTTPS (via exchange is `server/okta_auth.py` (the Okta client config) and the two routes
NGINX's `X-Forwarded-Proto`). There is no server-side session store — each in `server/app.py`: `okta_login()` sends the browser to Okta's authorize
request is validated by checking the cookie's signature and expiry. endpoint, `okta_callback()` exchanges the code, matches the ID token's identity
claim against `users.username`, and signs the person in.
Sign-in still ends the same way it always did: a signed JWT in an **HttpOnly,
SameSite=Lax** cookie (`wp_session`), marked **Secure** automatically whenever
the request arrives over HTTPS (via NGINX's `X-Forwarded-Proto`). There is no
server-side session store — each request is validated by checking the cookie's
signature and expiry. Okta only confirms *who* someone is; this app still
decides *what* they may do — roles, project membership, everything below stays
local and unchanged by Okta.
**The real security boundary is the API:** every `/api/` data route is refused **The real security boundary is the API:** every `/api/` data route is refused
with `401` unless a valid session cookie is present (see `auth_gate` in with `401` unless a valid session cookie is present (see `auth_gate` in
`app.py`). The static pages additionally include `auth-guard.js`, which redirects `app.py`). The static pages additionally include `auth-guard.js`, which redirects
to `login.html` when there's no session — that's for UX, not protection. to `login.html` when there's no session — that's for UX, not protection.
Passwords are stored only as **bcrypt** hashes (`server/auth.py`). Roles are **Access gating is Okta's job, not this app's.** Only accounts assigned to the
`admin` (may manage users) and `user`. app integration in Okta can complete the sign-in flow at all, so there is no
required-group or claim check layered on top here. Once Okta lets someone
through, this app decides their role — see below.
### Set the signing secret Roles are `admin`, `project_super_user`, `project_admin`, `project_user`
(`html/users.js`, enforced server-side).
Add `AUTH_SECRET_KEY` to `.env` (see `.env.example`). **Required in production** ### Set the signing secret and the Okta app integration
without it the API uses a random per-process key, so logins reset on restart.
Add `AUTH_SECRET_KEY` and the five `OKTA_*` variables to `.env` — see
`.env.example` for what each one is and where it comes from. `AUTH_SECRET_KEY`
is **required in production**: without it the API uses a random per-process
key, so logins reset on restart. The `OKTA_*` variables are not a hard-fail the
same way — the API starts without them, it just refuses every sign-in and says
so in the startup log (`okta_auth.describe()`).
```bash ```bash
python -c "import secrets; print(secrets.token_urlsafe(48))" python -c "import secrets; print(secrets.token_urlsafe(48))"
``` ```
The Okta app integration itself (sign-in method OIDC, Application type Web
Application) needs its **Sign-in redirect URI** set to exactly
`OKTA_REDIRECT_URI`'s value, and the people who should have access assigned to
it — that assignment IS the access control (see above).
### Create the first admin ### Create the first admin
The `/api/auth/users` endpoint needs an existing admin, so bootstrap one from a There's no `create-admin` command anymore — creating an account from scratch
shell (run from the **project root**, like uvicorn): by hand-typed username risks a second, orphaned row if it doesn't exactly match
what Okta actually sends (see `OKTA_IDENTITY_CLAIM` in `.env.example`). Instead,
have the first admin **sign in through Okta once** — they land as an ordinary
`project_user`, JIT-provisioned — then promote that existing row from a shell
(run from the **project root**, like uvicorn):
```bash ```bash
python -m server.manage_users create-admin alice --name "Alice Smith" python -m server.manage_users promote alice --role admin
# prompts for a password (min 8 chars)
``` ```
In Docker: In Docker:
```bash ```bash
docker compose exec api python -m server.manage_users create-admin alice --name "Alice Smith" docker compose exec api python -m server.manage_users promote alice --role admin
``` ```
Other commands: `create <user> --role user`, `list`, `reset-password <user>`, Other commands: `list`, `disable <user>`, `enable <user>`. After the first
`disable <user>`, `enable <user>`. After that, admins can add users through the admin exists, they can promote others through the User Directory page (or keep
API (or you can keep using the CLI). using the CLI) — no shell access needed for anyone after the first.
**No break-glass path.** If Okta is unreachable or misconfigured, the app is
unreachable for everyone, including admins, until Okta is restored (D16) — this
is a deliberate choice, the same one the abandoned LDAPS design made, not an
oversight.
--- ---
@@ -291,25 +322,25 @@ docker compose down -v
## Quick test ## Quick test
`/api/health` is open; data routes now require a session, so log in first and `/api/health` is open; every other `/api/` route needs a session cookie:
reuse the cookie jar:
```bash ```bash
curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed curl http://127.0.0.1:8000/api/health # {"ok":true} — no auth needed
# Sign in, saving the session cookie to a jar
curl -c jar.txt -X POST http://127.0.0.1:8000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"<password>"}'
# Reuse the cookie on protected routes
curl -b jar.txt http://127.0.0.1:8000/api/comments
``` ```
Without the cookie, protected routes return `401 {"detail":"Not authenticated"}`. Without a session cookie, protected routes return `401 {"detail":"Not authenticated"}`.
Or via the nginx proxy (replace with your hostname): Or via the nginx proxy (replace with your hostname):
```bash ```bash
curl https://wp-suite.company.local/api/health curl https://wp-suite.company.local/api/health
``` ```
There's no `curl`-able login anymore — Okta requires a real browser to
complete, which is what `login.html`'s "Sign in with Okta" button is for. To
exercise a protected route from a script instead, use `server/smoketest.py`'s
own technique (mint a session with `auth.create_token()` and set it as the
`wp_session` cookie, the same thing `okta_callback()` does after Okta hands
back an identity) rather than reaching for curl by hand — see that script's
own AUTHENTICATION section for the exact steps, and why it has to run
somewhere that shares the target server's `AUTH_SECRET_KEY` and database.

View File

@@ -52,6 +52,13 @@ def run_migrations_online() -> None:
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
compare_type=True, compare_type=True,
# Each migration commits on its own. One transaction for the WHOLE
# run meant a crash at step N rolled back steps 1..N-1 while their
# "Running upgrade" lines stayed on screen claiming they ran - the
# 2026-08-21 outage's stamp-to-head repair trusted those lines and
# left production missing two tables (found 2026-08-23 when the
# locations import 500'd on a table that "had been created").
transaction_per_migration=True,
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()

View File

@@ -0,0 +1,29 @@
"""drop local password (T10.4, wave 10 / D15 / D16)
Real deletion, not a toggle: no local password exists anymore, only Okta OIDC.
`password_hash` was NOT NULL at the database level since the baseline schema, so
downgrade re-adds it with server_default='' rather than leaving existing rows
without a value — the same pattern used for the locale/timezone drop-precedent
columns, applied in reverse.
Revision ID: 1d60a608bb51
Revises: a1b8c6d4e2f9
Create Date: 2026-09-03 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1d60a608bb51'
down_revision = 'a1b8c6d4e2f9'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column('users', 'password_hash')
def downgrade() -> None:
op.add_column('users', sa.Column('password_hash', sa.String(length=200), nullable=False, server_default=''))

View File

@@ -9,24 +9,27 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
Interactive docs: http://<host>/api/docs Interactive docs: http://<host>/api/docs
""" """
import base64 import base64
import logging
import os import os
import re import re
import uuid import uuid
from datetime import timedelta, timezone
from time import monotonic
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from authlib.integrations.base_client import OAuthError
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select, delete, func from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from starlette.middleware.sessions import SessionMiddleware
from .db import Base, engine, get_db from .db import Base, engine, get_db
from . import models, auth, notify, assets_db from . import models, auth, notify, assets_db, okta_auth, okta_fake
log = logging.getLogger("wpsuite.app")
# Schema management: # Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run. # • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -56,6 +59,22 @@ if _origins:
allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"], allow_methods=["*"], allow_headers=["*"], expose_headers=["X-Total-Count"],
) )
# Authlib's Okta client needs request.session to carry the OIDC state/nonce (and,
# below, our own post-login redirect target) across the round trip to Okta and
# back — it raises an AssertionError without this. Bug found in T10.2 (those
# routes never crashed in testing because every prior check mocked
# authorize_redirect/authorize_access_token directly, bypassing Authlib's real
# implementation); fixed here rather than reworking already-shipped T10.2 code.
#
# This is NOT the app's session cookie — wp_session (auth.py) still carries the
# actual signed-in identity, unchanged. This cookie holds nothing but ephemeral,
# per-attempt OAuth state, so it gets a short lifetime and a plain secret reuse
# (auth.SECRET_KEY) rather than its own required config knob.
app.add_middleware(
SessionMiddleware, secret_key=auth.SECRET_KEY, session_cookie="wp_oauth_state",
same_site="lax", https_only=False, max_age=600,
)
# ── Authentication gate ──────────────────────────────────────────────────────── # ── Authentication gate ────────────────────────────────────────────────────────
# Every /api/ data route requires a valid session cookie. Login, health, and the # Every /api/ data route requires a valid session cookie. Login, health, and the
@@ -605,14 +624,8 @@ def health():
# ── Authentication ───────────────────────────────────────────────────────────── # ── Authentication ─────────────────────────────────────────────────────────────
class LoginIn(BaseModel):
username: str
password: str
class NewUserIn(BaseModel): class NewUserIn(BaseModel):
username: str username: str
password: str
full_name: str = "" full_name: str = ""
email: str = "" email: str = ""
role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES role: str = auth.ROLE_PROJECT_USER # permissions role — see auth.ROLES
@@ -634,24 +647,6 @@ class PreferencesIn(BaseModel):
timezone: Optional[str] = None timezone: Optional[str] = None
class ForgotPasswordIn(BaseModel):
username: str = "" # username or email
class ResetPasswordIn(BaseModel):
token: str
new_password: str
class PasswordChangeIn(BaseModel):
current_password: str
new_password: str
class AdminPasswordIn(BaseModel):
new_password: str
class ActiveIn(BaseModel): class ActiveIn(BaseModel):
is_active: bool is_active: bool
@@ -674,160 +669,143 @@ class AutoAddIn(BaseModel):
role: str = "" role: str = ""
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
LOGIN_LOCKOUT_MINUTES = int(os.getenv("AUTH_LOCKOUT_MINUTES", "15"))
@app.post("/api/auth/login")
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
"""Verify credentials and, on success, set the HttpOnly session cookie.
Throttles online password guessing: after LOGIN_MAX_ATTEMPTS consecutive
failures an account is locked for LOGIN_LOCKOUT_MINUTES."""
user = auth.find_user(db, body.username)
now = models.utcnow()
# Always run the hash comparison first — even for missing or locked accounts —
# so response timing doesn't leak which usernames exist. verify_password
# tolerates an empty hash.
valid = auth.verify_password(body.password, user.password_hash if user else "")
locked = user.locked_until if user else None
if locked is not None and locked.tzinfo is None:
locked = locked.replace(tzinfo=timezone.utc) # SQLite returns naive datetimes; normalize to UTC
if locked is not None and locked > now:
raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.")
if not user or not valid:
if user:
user.failed_attempts = (user.failed_attempts or 0) + 1
if user.failed_attempts >= LOGIN_MAX_ATTEMPTS:
user.locked_until = now + timedelta(minutes=LOGIN_LOCKOUT_MINUTES)
user.failed_attempts = 0
log_event(db, user.username, "login_locked", "user", user.id, summary=user.username,
detail={"minutes": LOGIN_LOCKOUT_MINUTES})
db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
if not user.is_active:
raise HTTPException(status_code=403, detail="Account is disabled")
user.failed_attempts = 0
user.locked_until = None
user.last_login_at = now
db.commit()
token = auth.create_token(user)
auth.set_session_cookie(response, request, token)
return {"user": user.to_dict()}
@app.post("/api/auth/logout") @app.post("/api/auth/logout")
def logout(response: Response): def logout(response: Response):
auth.clear_session_cookie(response) auth.clear_session_cookie(response)
return {"ok": True} return {"ok": True}
# ── Self-service password reset (needs email switched on) ────────────────────── # ── Okta OIDC sign-in (T10.2, wave 10 / D15) ────────────────────────────────────
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120")) # Access gating is Okta's job: only accounts assigned to this app integration in Okta
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to # can complete authorize_redirect at all. No app-side group/claim check is layered on
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on # top here — see okta_auth.py's docstring and wave-10.md T10.2 for why.
# restart — deliberately simple; the token expiry is the real control.
_reset_last: dict[str, float] = {} def _safe_next_path(raw: str) -> str:
"""A same-site path only — same rule login.js's own nextTarget() enforces
client-side. Rejects absolute/scheme URLs ('//evil.com', 'https://evil.com')
so a crafted ?next= can't turn a real Okta sign-in into an open redirect."""
raw = (raw or "").strip()
if raw and raw.startswith("/") and not raw.startswith("//"):
return raw
return ""
def _reset_throttled(request: Request, username: str) -> bool: @app.get("/api/auth/okta/login")
now = monotonic() async def okta_login(request: Request):
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}" """Send the browser to Okta's authorize endpoint. Where to land afterward
prev = _reset_last.get(key) (?next=, e.g. from a deep link an assignment email carried — X1/CR-011/CR-014)
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS: rides in the OAuth-state session cookie alongside Authlib's own state/nonce,
return True since nothing else survives the round trip to Okta and back."""
_reset_last[key] = now if not okta_auth.oauth:
if len(_reset_last) > 5000: # bound the dict on a long-lived worker raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
cutoff = now - RESET_COOLDOWN_SECONDS next_path = _safe_next_path(request.query_params.get("next", ""))
for k in [k for k, t in _reset_last.items() if t < cutoff]: if next_path:
_reset_last.pop(k, None) request.session["post_login_redirect"] = next_path
return False return await okta_auth.oauth.okta.authorize_redirect(request, okta_auth.REDIRECT_URI)
def reset_body(user: "models.User", link: str, minutes: int) -> str: @app.get("/api/auth/okta/callback")
# No account detail beyond the username, and no customer data — same rule as async def okta_callback(request: Request, db: Session = Depends(get_db)):
# the assignment mail. The link is the only sensitive thing in here. """Exchange the authorization code for tokens, validate the ID token, and sign the
who = user.full_name or user.username person in. T10.3: matches the identity claim to a local account, or JIT-provisions
return ( one, then issues the same session cookie login() does today.
f"Hi {who},\n\n"
f"A password reset was requested for your Work Package Suite account "
f"({user.username}).\n\n"
f"Set a new password:\n{link}\n\n"
f"The link expires in {minutes} minutes and can only be used once. "
f"If you didn't request this, you can ignore this email — your current "
f"password still works.\n"
)
Failure paths land back on the login page with a plain-language ?error= instead
of a raw HTTPException — this route is reached by a full-page browser navigation
from Okta, not a fetch() call, so a JSON error body is just a broken-looking page
to whoever is sitting at the keyboard (T10.5)."""
if not okta_auth.oauth:
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
try:
token = await okta_auth.oauth.okta.authorize_access_token(request)
except OAuthError as exc:
log.warning("Okta callback rejected: %s", exc)
return RedirectResponse(url="/login.html?error=cancelled", status_code=303)
claims = token.get("userinfo") or {}
identity = (claims.get(okta_auth.IDENTITY_CLAIM) or "").strip()
if not identity:
log.error("Okta ID token had no %r claim — check OKTA_IDENTITY_CLAIM", okta_auth.IDENTITY_CLAIM)
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
@app.get("/api/auth/reset-available") user = auth.find_user(db, identity)
def reset_available(db: Session = Depends(get_db)): if user is None:
"""Whether the login page should offer 'Forgot password'. Self-service reset # JIT provisioning (D15). Okta is the only gate on WHO can reach this route
depends entirely on outbound email, so it's off unless email is enabled AND # at all — this app still decides what a first-time sign-in may do. A new
SMTP is configured — otherwise the only route is an admin reset.""" # account gets the lowest-privilege role and no project membership; an admin
s = notify.get_settings(db) # or project super user grants access afterward, same as any account created
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)} # by hand today (create_user() above). No password field exists at all —
# Okta is the only credential (D15/D16, real deletion as of T10.4).
user = models.User(
@app.post("/api/auth/forgot-password") id=gen_id("user"),
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)): username=identity,
"""Email a reset link. Always returns the same 200 response whether or not the email=(claims.get("email") or "").strip(),
account exists — this endpoint is unauthenticated, so it must not become a full_name=(claims.get("name") or "").strip(),
username/email oracle. Failures are recorded in the audit log instead.""" role=auth.ROLE_PROJECT_USER,
s = notify.get_settings(db)
if not (s.get("email_enabled") and notify.smtp_ready(s)):
raise HTTPException(
status_code=503,
detail="Password reset by email isn't available. Ask an administrator to reset it for you.",
) )
if _reset_throttled(request, body.username): db.add(user)
# Same shape as the success response — no oracle, no mail bomb. db.flush()
return {"ok": True, "message": "If that account exists, a reset link is on its way."} log_event(db, user.username, "user_created", "user", user.id, summary=user.username,
user = auth.find_user(db, body.username) detail={"role": user.role, "via": "okta_jit"})
if user and user.is_active and user.email: elif not user.is_active:
base = (s.get("app_base_url") or "").rstrip("/") # Deprovisioning stays local (D15's "roles stay local"): Okta letting someone
token = auth.create_reset_token(user) # through does not override an account this app has disabled. Same rule
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}" # login() enforced today, now surfaced as a login-page banner (T10.5)
sent = notify.send_now( # instead of a raw 403 body, for the reason in this route's docstring.
db, user.email, return RedirectResponse(url="/login.html?error=disabled", status_code=303)
"Work Package Suite — reset your password",
reset_body(user, link, auth.RESET_MINUTES),
)
log_event(db, user.username, "password_reset_requested", "user", user.id,
summary=user.username, detail={"emailed": bool(sent)})
db.commit()
else:
# Log the miss for the admin's benefit; the caller can't tell the difference.
log_event(db, "(anonymous)", "password_reset_miss", "user", "",
summary=(body.username or "")[:200],
detail={"reason": "no account, inactive, or no email on file"})
db.commit()
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
@app.post("/api/auth/reset-password")
def reset_password(body: ResetPasswordIn, db: Session = Depends(get_db)):
"""Complete a reset using the emailed token. The token carries the user's
token_version, and finishing a reset bumps it — so the link is single-use and
every existing session for that account is signed out."""
claims = auth.decode_reset_token(body.token or "")
if not claims:
raise HTTPException(status_code=400, detail="This reset link is invalid or has expired. Request a new one.")
user = db.get(models.User, claims.get("sub"))
if not user or not user.is_active:
raise HTTPException(status_code=400, detail="This reset link is no longer valid.")
if (claims.get("ver", 0) or 0) != (user.token_version or 0):
raise HTTPException(status_code=400, detail="This reset link has already been used. Request a new one.")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # burns the link + all sessions
# A completed reset also clears any login lockout — the person has proven
# control of the mailbox, so there's nothing left to throttle.
user.failed_attempts = 0 user.failed_attempts = 0
user.locked_until = None user.locked_until = None
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username) user.last_login_at = models.utcnow()
db.commit() db.commit()
return {"ok": True} db.refresh(user)
tok = auth.create_token(user)
target = _safe_next_path(request.session.pop("post_login_redirect", "")) or "/index.html"
redirect = RedirectResponse(url=target, status_code=303)
auth.set_session_cookie(redirect, request, tok)
return redirect
# ── Fake Okta test seam (T10.7) ──────────────────────────────────────────────
# Registered ONLY when the fake is active — checked once, at import time, same
# timing okta_auth.oauth itself is built at. In production these two routes do
# not exist at all, not merely refuse a request: see server/okta_fake.py's
# docstring for why that distinction matters given D16 leaves no other way in.
if okta_fake.is_active():
@app.get("/api/auth/okta/_fake_provider")
async def okta_fake_provider(request: Request):
"""Stands in for Okta's own sign-in screen. A plain list of the
identities WP_OKTA_FAKE_DIRECTORY defines, so a browser check drives a
real page through a real round trip rather than skipping it."""
state = request.query_params.get("state", "")
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
from fastapi.responses import HTMLResponse
return HTMLResponse(okta_fake.picker_page(state, redirect_uri))
@app.get("/api/auth/okta/_fake_provider/consent")
async def okta_fake_consent(request: Request):
"""What clicking an identity (or Deny) on the fake picker does: hands
back an authorization code (or an error) at okta_callback, exactly the
shape a real Okta redirect would carry. Everything after this — the
state check, JIT provisioning, the disabled-account and open-redirect
guards — is the real okta_callback() above, unmodified."""
state = request.query_params.get("state", "")
redirect_uri = request.query_params.get("redirect_uri") or "/api/auth/okta/callback"
if request.query_params.get("deny"):
return RedirectResponse(
url=f"{redirect_uri}?error=access_denied&error_description=denied+by+fake+user&state={state}",
status_code=303)
username = request.query_params.get("username", "")
entry = okta_fake.directory().get(username)
if not isinstance(entry, dict):
return RedirectResponse(
url=f"{redirect_uri}?error=invalid_request&error_description=unknown+fake+identity&state={state}",
status_code=303)
claims = {okta_auth.IDENTITY_CLAIM: username,
"email": entry.get("email", ""), "name": entry.get("name", "")}
code = okta_fake.new_code(claims)
return RedirectResponse(url=f"{redirect_uri}?code={code}&state={state}", status_code=303)
@app.get("/api/auth/me") @app.get("/api/auth/me")
@@ -883,22 +861,6 @@ def set_preferences(body: PreferencesIn, user: models.User = Depends(auth.get_cu
return {"user": user.to_dict()} return {"user": user.to_dict()}
@app.post("/api/auth/password")
def change_password(body: PasswordChangeIn, request: Request, response: Response, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
if not auth.verify_password(body.current_password, user.password_hash):
raise HTTPException(status_code=400, detail="Current password is incorrect")
problem = auth.password_problem(body.new_password, user.username, user.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
user.password_hash = auth.hash_password(body.new_password)
user.token_version = (user.token_version or 0) + 1 # invalidate all OTHER existing sessions
db.commit()
db.refresh(user)
# Keep this session logged in by re-issuing a cookie carrying the new version.
auth.set_session_cookie(response, request, auth.create_token(user))
return {"ok": True}
# ── User administration ───────────────────────────────────────────────────────── # ── User administration ─────────────────────────────────────────────────────────
# Two kinds of caller reach these routes: an app admin, who manages every account, # Two kinds of caller reach these routes: an app admin, who manages every account,
# and a Project Super User, who manages the accounts on the projects they administer. # and a Project Super User, who manages the accounts on the projects they administer.
@@ -986,9 +948,14 @@ def user_scope(user: models.User = Depends(auth.get_current_user), db: Session =
@app.post("/api/auth/users") @app.post("/api/auth/users")
def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
problem = auth.password_problem(body.password, body.username, body.email) """Create an account ahead of its first Okta sign-in — e.g. to put it on
if problem: projects or hand it a role before anyone has ever signed in as them.
raise HTTPException(status_code=400, detail=problem)
`body.username` MUST match what Okta's identity claim will send for this
person exactly (see OKTA_IDENTITY_CLAIM, server/okta_auth.py) — auth.find_user()
is how a later Okta sign-in locates this row (T10.3). A mismatch doesn't
fail loudly; it silently produces a second, JIT-provisioned account instead
of signing this person into the one just created here."""
allowed = grantable_roles(actor) allowed = grantable_roles(actor)
if body.role not in allowed: if body.role not in allowed:
raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}") raise HTTPException(status_code=400, detail=f"role must be one of {', '.join(allowed)}")
@@ -1020,7 +987,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
username=body.username.strip(), username=body.username.strip(),
email=body.email.strip(), email=body.email.strip(),
full_name=body.full_name.strip(), full_name=body.full_name.strip(),
password_hash=auth.hash_password(body.password),
role=body.role, role=body.role,
project_role=body.project_role.strip()[:120], project_role=body.project_role.strip()[:120],
) )
@@ -1047,24 +1013,6 @@ def create_user(body: NewUserIn, actor: models.User = Depends(require_user_manag
return directory_entry(db, u, actor) return directory_entry(db, u, actor)
@app.post("/api/auth/users/{user_id}/password")
def admin_reset_password(user_id: str, body: AdminPasswordIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id)
require_see_user(db, actor, u)
require_manage_user(db, actor, u)
problem = auth.password_problem(body.new_password, u.username, u.email)
if problem:
raise HTTPException(status_code=400, detail=problem)
u.password_hash = auth.hash_password(body.new_password)
u.token_version = (u.token_version or 0) + 1 # revoke the user's existing sessions
# An administrative password reset was the one user-account change that left no
# trace; it is the most impersonation-adjacent thing on this page, so it logs.
log_event(db, actor, "password_reset", "user", u.id, summary=u.username,
detail={"by": "administrator"})
db.commit()
return {"ok": True}
@app.post("/api/auth/users/{user_id}/active") @app.post("/api/auth/users/{user_id}/active")
def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)): def set_user_active(user_id: str, body: ActiveIn, actor: models.User = Depends(require_user_manager), db: Session = Depends(get_db)):
u = load_target_user(db, user_id) u = load_target_user(db, user_id)
@@ -2388,6 +2336,24 @@ def parse_location_rows(text: str) -> tuple[list[tuple[int, list[str]]], list[di
rejected.append({"line": i, "text": line, rejected.append({"line": i, "text": line,
"reason": "no letters or digits to make a code from"}) "reason": "no letters or digits to make a code from"})
continue continue
# Postgres enforces VARCHAR lengths and refuses NUL/control bytes;
# SQLite shrugs at both - which is how ONE bad CSV line 500'd the whole
# production import (2026-08-23, BL-027's class again) instead of coming
# back as a rejection with its line number. Validate per row, here,
# so every dialect answers the same way: with a reason.
if any(any(ord(ch) < 32 for ch in p) for p in parts):
rejected.append({"line": i, "text": line[:120],
"reason": "contains control characters — re-save the file as plain CSV (UTF-8)"})
continue
long_p = next((p for p in parts if len(p) > 200), None)
if long_p is not None:
rejected.append({"line": i, "text": line[:120],
"reason": "a name is longer than 200 characters (%d)" % len(long_p)})
continue
if any(len(location_slug(p)) > 60 for p in parts):
rejected.append({"line": i, "text": line[:120],
"reason": "a code would be longer than 60 characters"})
continue
rows.append((i, parts)) rows.append((i, parts))
return rows, rejected return rows, rejected
@@ -2454,6 +2420,7 @@ def import_locations(project_id: str, body: LocationImportIn,
require_project_writable(db, user, project_id, "The location list cannot be changed") require_project_writable(db, user, project_id, "The location list cannot be changed")
rows, rejected = parse_location_rows(body.text) rows, rejected = parse_location_rows(body.text)
read_total = len(rows) + len(rejected)
existing = {n.path: n for n in db.scalars( existing = {n.path: n for n in db.scalars(
select(models.LocationNode).where(models.LocationNode.project_id == project_id) select(models.LocationNode).where(models.LocationNode.project_id == project_id)
@@ -2468,6 +2435,10 @@ def import_locations(project_id: str, body: LocationImportIn,
for line_no, parts in rows: for line_no, parts in rows:
segs = [location_slug(p) for p in parts] segs = [location_slug(p) for p in parts]
full = "/".join(segs) full = "/".join(segs)
if len(full) > 200:
rejected.append({"line": line_no, "text": "/".join(parts)[:120],
"reason": "the combined path is longer than 200 characters"})
continue
if full in seen_in_file: if full in seen_in_file:
duplicates.append({"line": line_no, "path": full, "names": parts, duplicates.append({"line": line_no, "path": full, "names": parts,
"reason": "already on line %d of this import" % seen_in_file[full]}) "reason": "already on line %d of this import" % seen_in_file[full]})
@@ -2510,7 +2481,7 @@ def import_locations(project_id: str, body: LocationImportIn,
result = { result = {
"project_id": project_id, "dry_run": bool(body.dry_run), "project_id": project_id, "dry_run": bool(body.dry_run),
"read": len(rows) + len(rejected), "read": read_total,
"created": created, "duplicates": duplicates, "created": created, "duplicates": duplicates,
"reactivated": reactivated, "rejected": rejected, "reactivated": reactivated, "rejected": rejected,
} }
@@ -3007,6 +2978,18 @@ def parse_material_rows(text: str):
rejected.append({"line": i, "text": raw.strip()[:120], rejected.append({"line": i, "text": raw.strip()[:120],
"reason": "more than three columns - description, unit, code is the whole shape"}) "reason": "more than three columns - description, unit, code is the whole shape"})
continue continue
# Same guard as parse_location_rows: reject what Postgres would refuse
# (VARCHAR limits, control bytes) with the line number, never a 500.
if any(any(ord(ch) < 32 for ch in p) for p in parts):
rejected.append({"line": i, "text": raw.strip()[:120],
"reason": "contains control characters - re-save the file as plain CSV (UTF-8)"})
continue
caps = ((300, "description"), (20, "unit"), (80, "code"))
long_col = next((("%s is longer than %d characters (%d)" % (label, cap, len(p)))
for (cap, label), p in zip(caps, parts) if len(p) > cap), None)
if long_col:
rejected.append({"line": i, "text": raw.strip()[:120], "reason": long_col})
continue
rows.append((i, parts)) rows.append((i, parts))
return rows, rejected return rows, rejected

View File

@@ -1,10 +1,11 @@
"""Authentication for the Work Package Suite. """Authentication for the Work Package Suite.
A self-contained username/password login. Passwords are stored only as bcrypt Identity is confirmed by Okta (OIDC authorization-code flow, see server/okta_auth.py
hashes; a successful login issues a signed JWT that rides in an HttpOnly cookie and the routes in server/app.py); there is no local password anywhere in this app
(`wp_session`). Because the token is signed and self-validating, there is no (D15, D16 — T10.4 removed the last of it). A successful sign-in issues a signed JWT
server-side session store — every request is checked by verifying the cookie's that rides in an HttpOnly cookie (`wp_session`). Because the token is signed and
signature and expiry (see `auth_gate` and `get_current_user`). self-validating, there is no server-side session store — every request is checked by
verifying the cookie's signature and expiry (see `auth_gate` and `get_current_user`).
Security model: Security model:
• The real boundary is `auth_gate` (middleware in app.py): every /api/ data • The real boundary is `auth_gate` (middleware in app.py): every /api/ data
@@ -17,7 +18,9 @@ Security model:
warning and invalidates every session on restart) so dev still works. warning and invalidates every session on restart) so dev still works.
Permissions roles (`User.role`) — distinct from a person's job function on the Permissions roles (`User.role`) — distinct from a person's job function on the
project, which lives in `User.project_role` and grants nothing: project, which lives in `User.project_role` and grants nothing. Decided entirely
locally: Okta gates WHO can authenticate at all, this app decides what an
authenticated account may do — see D15/D16.
• admin application administrator: user administration, app settings, • admin application administrator: user administration, app settings,
and implicit access to every project. and implicit access to every project.
• project_super_user • project_super_user
@@ -30,15 +33,13 @@ project, which lives in `User.project_role` and grants nothing:
modify a SOP after it has been completed, and delete projects. modify a SOP after it has been completed, and delete projects.
• project_user normal member: creates and edits work packages, authors a SOP • project_user normal member: creates and edits work packages, authors a SOP
up to completion. May NOT delete WPs or change a completed SOP. up to completion. May NOT delete WPs or change a completed SOP.
Also where Okta JIT provisioning (T10.3) lands a brand-new
account — the lowest-privilege role, promoted locally from
there by an admin (see manage_users.py for the bootstrap case).
The user-administration SCOPE of a super user is worked out in server/app.py The user-administration SCOPE of a super user is worked out in server/app.py
(`managed_project_ids`, `manage_user_problem`), because it depends on project (`managed_project_ids`, `manage_user_problem`), because it depends on project
membership rows — this module only decides which roles carry the power at all. membership rows — this module only decides which roles carry the power at all.
Password reset: a short-lived signed token (see `create_reset_token`) is emailed
to the account's address. It is single-use by construction — it embeds the user's
`token_version`, which is bumped when the password changes, so a used or
superseded link stops validating.
""" """
import os import os
import secrets import secrets
@@ -46,7 +47,6 @@ import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import bcrypt
import jwt import jwt
from fastapi import Depends, HTTPException, Request, Response, status from fastapi import Depends, HTTPException, Request, Response, status
from sqlalchemy import select, func from sqlalchemy import select, func
@@ -59,10 +59,8 @@ log = logging.getLogger("wpsuite.auth")
COOKIE_NAME = "wp_session" COOKIE_NAME = "wp_session"
JWT_ALG = "HS256" JWT_ALG = "HS256"
# How long a login lasts before the user must sign in again. # How long a session lasts before the person must sign in again.
SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12")) SESSION_HOURS = int(os.getenv("AUTH_SESSION_HOURS", "12"))
# How long an emailed password-reset link stays valid.
RESET_MINUTES = int(os.getenv("AUTH_RESET_MINUTES", "60"))
# ── permissions roles ───────────────────────────────────────────────────────── # ── permissions roles ─────────────────────────────────────────────────────────
ROLE_ADMIN = "admin" ROLE_ADMIN = "admin"
@@ -121,30 +119,7 @@ def is_project_admin(user: "models.User") -> bool:
# same question used to exist here and silently disagreed with the scoped one, which # same question used to exist here and silently disagreed with the scoped one, which
# locked per-project super users out of the routes they were entitled to. # locked per-project super users out of the routes they were entitled to.
# Password policy (shared by the API and the CLI). # Paths under /api that do NOT require a session (the Okta routes themselves, health, docs).
MIN_PASSWORD_LEN = int(os.getenv("AUTH_MIN_PASSWORD_LEN", "12"))
_COMMON_PASSWORDS = {
"password", "password1", "password123", "passw0rd", "12345678", "123456789",
"1234567890", "qwerty123", "letmein123", "changeme", "admin123", "welcome123",
"iloveyou1", "abc12345", "qwertyuiop",
}
def password_problem(pw: str, username: str = "", email: str = "") -> Optional[str]:
"""Return a human-readable reason the password is unacceptable, or None if OK.
Shared by the API endpoints and the CLI so the policy is enforced everywhere."""
if len(pw) < MIN_PASSWORD_LEN:
return f"Password must be at least {MIN_PASSWORD_LEN} characters."
low = pw.lower()
if username and low == username.strip().lower():
return "Password must not be the same as the username."
if email and low == email.strip().lower():
return "Password must not be the same as the email."
if low in _COMMON_PASSWORDS:
return "That password is too common — choose something less guessable."
return None
# Paths under /api that do NOT require a session (login itself, health, docs).
_EXEMPT_PREFIXES = ("/api/auth/",) _EXEMPT_PREFIXES = ("/api/auth/",)
_EXEMPT_EXACT = { _EXEMPT_EXACT = {
"/api/health", "/api/health",
@@ -184,22 +159,6 @@ def _load_secret() -> str:
SECRET_KEY = _load_secret() SECRET_KEY = _load_secret()
# ── password hashing ──────────────────────────────────────────────────────────
def hash_password(plain: str) -> str:
# bcrypt operates on at most 72 bytes; longer inputs are truncated by the
# algorithm. Encode explicitly so non-ASCII passwords hash consistently.
return bcrypt.hashpw(plain.encode("utf-8")[:72], bcrypt.gensalt()).decode("ascii")
def verify_password(plain: str, hashed: str) -> bool:
if not hashed:
return False
try:
return bcrypt.checkpw(plain.encode("utf-8")[:72], hashed.encode("ascii"))
except (ValueError, TypeError):
return False
# ── tokens ────────────────────────────────────────────────────────────────── # ── tokens ──────────────────────────────────────────────────────────────────
def create_token(user: "models.User") -> str: def create_token(user: "models.User") -> str:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -221,39 +180,15 @@ def decode_token(token: str) -> Optional[dict]:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG]) claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError: except jwt.PyJWTError:
return None return None
# A password-reset token must never be usable as a session cookie. # No route issues a `typ`-carrying token anymore (that was the password-reset
# token, removed in T10.4), but a session token still must never validate as
# one — kept as a defensive check, cheap insurance against a future token type
# riding the same cookie.
if claims.get("typ"): if claims.get("typ"):
return None return None
return claims return claims
def create_reset_token(user: "models.User") -> str:
"""Short-lived, single-use token for an emailed password-reset link.
Single-use falls out of `ver`: completing a reset bumps the user's
token_version, so the link (and any older link) no longer validates."""
now = datetime.now(timezone.utc)
payload = {
"typ": "pwreset",
"sub": user.id,
"ver": user.token_version or 0,
"iat": now,
"exp": now + timedelta(minutes=RESET_MINUTES),
}
return jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALG)
def decode_reset_token(token: str) -> Optional[dict]:
"""Claims for a valid, unexpired reset token, else None."""
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALG])
except jwt.PyJWTError:
return None
if claims.get("typ") != "pwreset":
return None
return claims
# ── cookie helpers ──────────────────────────────────────────────────────────── # ── cookie helpers ────────────────────────────────────────────────────────────
def _is_https(request: Request) -> bool: def _is_https(request: Request) -> bool:
# Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP, # Behind NGINX, TLS is terminated at the proxy and forwarded as plain HTTP,

View File

@@ -1,24 +1,28 @@
"""Command-line user management for the Work Package Suite. """Command-line user management for the Work Package Suite.
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an There is no local password (D15) and no local account creation from here anymore
existing admin, so you have to bootstrap one here), and for occasional account (D16, T10.4) — accounts are created by signing in through Okta, which JIT-
provisions a row at the lowest-privilege role (see server/okta_auth.py,
server/app.py's okta_callback(), wave-10.md T10.3). This tool's job is narrower
now: change the role on an account that already exists, and do routine account
maintenance from a shell on the server. maintenance from a shell on the server.
That narrower job is still how the very first admin gets named (D16): have that
person sign in through Okta once — they land as project_user — then promote them
from here. Promoting an existing row, rather than creating one blind, matters
because it never has to guess the exact string Okta will send as the identity
claim; a hand-typed username that doesn't match it exactly would just produce a
second, orphaned account instead of the one you meant to promote.
Run from the PROJECT ROOT (same place you run uvicorn), so the package imports Run from the PROJECT ROOT (same place you run uvicorn), so the package imports
and .env resolve the same way the API does: and .env resolve the same way the API does:
python -m server.manage_users create-admin alice --name "Alice Smith" python -m server.manage_users promote alice --role admin
python -m server.manage_users create bob --role user --name "Bob Jones"
python -m server.manage_users list python -m server.manage_users list
python -m server.manage_users reset-password alice
python -m server.manage_users disable bob python -m server.manage_users disable bob
python -m server.manage_users enable bob python -m server.manage_users enable bob
If --password is omitted you'll be prompted (input is hidden). Passwords must be
at least 8 characters.
""" """
import argparse import argparse
import getpass
import sys import sys
import uuid import uuid
@@ -26,53 +30,48 @@ from .db import SessionLocal, Base, engine
from . import models, auth from . import models, auth
def _gen_id() -> str: def cmd_promote(args) -> None:
return f"user_{uuid.uuid4().hex[:12]}" role = args.role
# 'user' is the pre-roles spelling of 'project_user', accepted here so a
# documented one-liner from before this rework keeps working.
def _prompt_password(provided: str | None, username: str = "") -> str:
pw = provided
if not pw:
pw = getpass.getpass("New password: ")
confirm = getpass.getpass("Confirm password: ")
if pw != confirm:
sys.exit("Passwords do not match.")
problem = auth.password_problem(pw, username)
if problem:
sys.exit(problem)
return pw
def cmd_create(args, role: str | None = None) -> None:
role = role or args.role
# 'user' is the pre-roles spelling of 'project_user' and is still accepted so the
# documented one-liners keep working; anything else has to be a current role.
if role == "user": if role == "user":
role = auth.ROLE_PROJECT_USER role = auth.ROLE_PROJECT_USER
if role not in auth.ROLES: if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}") sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db: with SessionLocal() as db:
if auth.find_user(db, args.username): u = auth.find_user(db, args.username)
sys.exit(f"A user named '{args.username}' already exists.") if not u:
u = models.User( sys.exit(
id=_gen_id(), f"No user named '{args.username}'. This promotes an existing account, it "
username=args.username.strip(), f"doesn't create one — they need to sign in through Okta at least once first."
full_name=(args.name or "").strip(), )
email=(args.email or "").strip(), old_role = u.role
password_hash=auth.hash_password(pw), u.role = role
role=role, # Audited the same way a role change from the web Admin Console already is
) # (server/app.py's set_user_role() -> log_event(), action "role_changed") —
db.add(u) # this command changes the same field and previously left no record of who
# ran it or what it changed (T10.10). "actor" can't name a real person here:
# a container shell exec carries no signed-in identity to attribute it to,
# so it's tagged as the tool itself rather than guessing. "via" mirrors JIT
# provisioning's own tag on user_created events.
db.add(models.AuditLog(
id=f"ev_{uuid.uuid4().hex[:12]}",
actor="cli:manage_users",
action="role_changed",
entity_type="user",
entity_id=u.id,
summary=u.username,
detail={"from": old_role, "to": role, "via": "cli"},
))
db.commit() db.commit()
print(f"Created {role}: {u.username} (id={u.id})") print(f"{u.username} is now {auth.ROLE_LABELS.get(role, role)}.")
def cmd_list(args) -> None: def cmd_list(args) -> None:
with SessionLocal() as db: with SessionLocal() as db:
rows = db.query(models.User).order_by(models.User.username).all() rows = db.query(models.User).order_by(models.User.username).all()
if not rows: if not rows:
print("No users yet. Create one with: create-admin <username>") print("No users yet. Accounts appear here once someone signs in through Okta.")
return return
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}") print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
for u in rows: for u in rows:
@@ -80,17 +79,6 @@ def cmd_list(args) -> None:
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}") f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
def cmd_reset_password(args) -> None:
pw = _prompt_password(getattr(args, "password", None), args.username)
with SessionLocal() as db:
u = auth.find_user(db, args.username)
if not u:
sys.exit(f"No user named '{args.username}'.")
u.password_hash = auth.hash_password(pw)
db.commit()
print(f"Password reset for {u.username}.")
def _set_active(username: str, active: bool) -> None: def _set_active(username: str, active: bool) -> None:
with SessionLocal() as db: with SessionLocal() as db:
u = auth.find_user(db, username) u = auth.find_user(db, username)
@@ -108,39 +96,23 @@ def main() -> None:
p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management") p = argparse.ArgumentParser(prog="manage_users", description="Work Package Suite user management")
sub = p.add_subparsers(dest="cmd", required=True) sub = p.add_subparsers(dest="cmd", required=True)
def add_create(name, help_): pr = sub.add_parser("promote", help="change an existing account's role (e.g. name the first admin)")
sp = sub.add_parser(name, help=help_) pr.add_argument("username")
sp.add_argument("username") pr.add_argument("--role", required=True, choices=list(auth.ROLES) + ["user"],
sp.add_argument("--password", help="set non-interactively (otherwise prompted)") help="permissions role ('user' is the legacy name for project_user)")
sp.add_argument("--name", default="", help="full name")
sp.add_argument("--email", default="")
return sp
add_create("create-admin", "create an admin account")
c = add_create("create", "create an account")
c.add_argument("--role", choices=list(auth.ROLES) + ["user"], default=auth.ROLE_PROJECT_USER,
help="permissions role ('user' is the legacy name for project_user)")
sub.add_parser("list", help="list all accounts") sub.add_parser("list", help="list all accounts")
rp = sub.add_parser("reset-password", help="reset a user's password") dp = sub.add_parser("disable", help="disable an account (blocks sign-in)")
rp.add_argument("username")
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
dp = sub.add_parser("disable", help="disable an account (blocks login)")
dp.add_argument("username") dp.add_argument("username")
ep = sub.add_parser("enable", help="re-enable an account") ep = sub.add_parser("enable", help="re-enable an account")
ep.add_argument("username") ep.add_argument("username")
args = p.parse_args() args = p.parse_args()
if args.cmd == "create-admin": if args.cmd == "promote":
cmd_create(args, role="admin") cmd_promote(args)
elif args.cmd == "create":
cmd_create(args)
elif args.cmd == "list": elif args.cmd == "list":
cmd_list(args) cmd_list(args)
elif args.cmd == "reset-password":
cmd_reset_password(args)
elif args.cmd == "disable": elif args.cmd == "disable":
_set_active(args.username, False) _set_active(args.username, False)
elif args.cmd == "enable": elif args.cmd == "enable":

View File

@@ -142,8 +142,10 @@ class WorkPackage(Base):
class User(Base): class User(Base):
"""A login account. Passwords are never stored in the clear — only a bcrypt """A login account. No password is stored here or anywhere else — identity is
hash (see server/auth.py). `username` is what people sign in with. confirmed by Okta (OIDC), this app only decides what the account may do once
Okta has vouched for it (see server/okta_auth.py, server/auth.py, D15/D16).
`username` is what Okta's identity claim resolves to.
Two independent notions of "role", deliberately separate: Two independent notions of "role", deliberately separate:
• role the PERMISSIONS role — what the account may do in the app. • role the PERMISSIONS role — what the account may do in the app.
@@ -159,7 +161,6 @@ class User(Base):
username: Mapped[str] = mapped_column(String(120), unique=True, index=True) username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
email: Mapped[str] = mapped_column(String(200), default="") email: Mapped[str] = mapped_column(String(200), default="")
full_name: Mapped[str] = mapped_column(String(200), default="") full_name: Mapped[str] = mapped_column(String(200), default="")
password_hash: Mapped[str] = mapped_column(String(200), default="")
role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role role: Mapped[str] = mapped_column(String(20), default="project_user") # permissions role
# Job function on the project — free text, offered from a suggested list. # Job function on the project — free text, offered from a suggested list.
project_role: Mapped[str] = mapped_column(String(120), default="") project_role: Mapped[str] = mapped_column(String(120), default="")

109
server/okta_auth.py Normal file
View File

@@ -0,0 +1,109 @@
"""Okta OIDC client configuration — T10.1, wave 10 (D15).
This module owns the conversation with Okta and nothing else: it does not issue this
app's own session cookie, does not touch the database, and does not decide who may sign
in. `server/auth.py` keeps doing all of that, unchanged — a session is still a signed JWT
in an HttpOnly cookie, roles are still local, `get_current_user` still re-reads the
account on every request. Only how a person's identity gets confirmed changes: an Okta
authorization-code flow, in place of the local username/password check.
Access gating is Okta's job, not this module's. Only accounts assigned to this app
integration in Okta ever complete the flow at all, so there is no required-group or
claim check layered on top here — see D15 and wave-10.md for why that is a deliberate
difference from D13's design, not an oversight.
Unconfigured is a first-class state, same discipline as the LDAPS module this replaces:
with any of the four settings below missing, `is_configured()` is False and `describe()`
says so in the startup log, rather than the app discovering it later at the login button.
"""
import logging
import os
from typing import Optional
log = logging.getLogger("wpsuite.okta")
try:
from authlib.integrations.starlette_client import OAuth
HAVE_AUTHLIB = True
except ImportError: # pragma: no cover
HAVE_AUTHLIB = False
OAuth = None # type: ignore[assignment]
# ── configuration ─────────────────────────────────────────────────────────────
# The Okta *authorization server* issuer, e.g. https://primecontrols.okta.com/oauth2/default
# or a custom authorization server URL. Authlib discovers the rest (authorize/token/
# jwks endpoints) from `<issuer>/.well-known/openid-configuration` — nothing below is
# hand-entered except this base URL, the client credentials, and our own callback.
ISSUER = os.getenv("OKTA_ISSUER", "")
CLIENT_ID = os.getenv("OKTA_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("OKTA_CLIENT_SECRET", "")
# Must exactly match a Sign-in redirect URI registered on the Okta app integration.
# e.g. https://wp.controls.dev/api/auth/okta/callback
REDIRECT_URI = os.getenv("OKTA_REDIRECT_URI", "")
# Standard OIDC identity scopes only. No group/role scopes: T10.2's note above explains
# why access gating and permissions both stay out of the token.
SCOPES = "openid profile email"
# Which claim in the ID token carries this person's AD sAMAccountName equivalent, for
# matching against the local `users` table (T10.3). Not yet confirmed by security —
# `preferred_username` is Okta's usual default for an AD-imported user, used here as a
# documented placeholder, NOT a verified answer. Override via env once security replies
# so the real value can drop in without a code change.
IDENTITY_CLAIM = os.getenv("OKTA_IDENTITY_CLAIM", "preferred_username")
def is_configured() -> bool:
"""Whether an OIDC flow could even be attempted. Deliberately does not touch the
network — that would be a `selftest()`, added when T10.2 needs one."""
return bool(HAVE_AUTHLIB and ISSUER and CLIENT_ID and CLIENT_SECRET and REDIRECT_URI)
def describe() -> str:
"""One line for the startup log, matching the LDAPS module's discipline: an
unconfigured deploy must be visible in `docker compose logs api`, not discovered at
the login button."""
from . import okta_fake
if okta_fake.is_active():
return (f"*** FAKE OKTA PROVIDER ACTIVE — identities come from "
f"{okta_fake.ENV_VAR}, NOT from Okta. Tests only. ***")
if not HAVE_AUTHLIB:
return "Okta auth DISABLED — authlib is not installed. No one can sign in."
missing = [name for name, val in (
("OKTA_ISSUER", ISSUER), ("OKTA_CLIENT_ID", CLIENT_ID),
("OKTA_CLIENT_SECRET", CLIENT_SECRET), ("OKTA_REDIRECT_URI", REDIRECT_URI),
) if not val]
if missing:
return f"Okta auth DISABLED — missing {', '.join(missing)}. No one can sign in."
return (f"Okta auth enabled — issuer {ISSUER}, redirect {REDIRECT_URI}, "
f"identity claim {IDENTITY_CLAIM!r}")
def _build_oauth():
"""Register the Okta client. Returns None when unconfigured so the caller (T10.2's
routes) can fail loudly instead of Authlib raising deep inside a request.
Checked before `is_configured()`: the fake (T10.7) needs none of the four real
Okta settings, and must win whenever it is legitimately active so a test run
never has to also fill in placeholder OKTA_ISSUER/CLIENT_ID/etc. `is_active()`
already refuses outside a SQLite-backed test database — see okta_fake.py."""
from . import okta_fake
if okta_fake.is_active():
log.warning("*** FAKE OKTA PROVIDER ACTIVE (%s) — tests only ***", okta_fake.ENV_VAR)
return okta_fake.build()
if not is_configured():
return None
oauth = OAuth()
oauth.register(
name="okta",
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
server_metadata_url=f"{ISSUER.rstrip('/')}/.well-known/openid-configuration",
client_kwargs={"scope": SCOPES},
)
return oauth
# Built once at import time, same as `SECRET_KEY` in auth.py — a missing/bad config is a
# deploy problem to catch at startup via `describe()`, not a per-request surprise.
oauth = _build_oauth()

190
server/okta_fake.py Normal file
View File

@@ -0,0 +1,190 @@
"""A fake Okta, for tests only — T10.7.
`server/okta_auth.py` normally hands the browser off to a real Okta authorize
endpoint and exchanges the code with Okta's token endpoint over the network
(Authlib discovers both from `<issuer>/.well-known/openid-configuration`). Tests
cannot reach any of that: there is no live Okta tenant in CI, and the browser
checks launch the app as a SUBPROCESS (`start_server` in tests/browser_check.py),
so a monkeypatch in the test process would never reach the code doing the
authenticating. The seam has to be configurable from the ENVIRONMENT — same
discipline as `server/ldap_fake.py`, D13/T10.7's LDAP predecessor.
Set `WP_OKTA_FAKE_DIRECTORY` to a JSON object and this module stands in for the
whole round trip — the authorize redirect, a stand-in "sign in at Okta" screen,
and the token exchange — with no network call anywhere:
{"root": {"email": "root@example.test", "name": "Root Person"}}
The key is the identity value a real Okta ID token would carry in whichever
claim `OKTA_IDENTITY_CLAIM` names (default `preferred_username`) — the fake
reads `okta_auth.IDENTITY_CLAIM` at request time, so it exercises whatever claim
name is actually configured rather than a hard-coded one.
WHAT THIS DOES AND DOES NOT REPLACE
Only the OAuth-protocol plumbing that talks to Okta over the network is faked —
`authorize_redirect` and `authorize_access_token`, both owned by Authlib, a
third-party library, not this app's security logic. Everything this app itself
decides stays real and untouched in `app.py`'s `okta_login()`/`okta_callback()`:
the `?next=` open-redirect guard (`_safe_next_path`), the disabled-account
check, JIT provisioning, and which claim carries identity. A fake run exercises
the actual code for all of that, not a re-implementation of it — the same
boundary `ldap_fake.py` drew around the anonymous-bind guard.
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
An environment variable that lets anyone "sign in" as any identity by visiting a
picker page is exactly the kind of thing that must never be reachable outside a
test process — D16 leaves no local password fallback and no break-glass, so a
fake provider silently active in production would be a total authentication
bypass with a friendlier UI than most. `is_active()` refuses whenever a real
database is configured, using the same test `auth._load_secret` and
`ldap_fake.is_active()` already use: a non-SQLite `DATABASE_URL` means
production, full stop. `okta_auth.describe()` also shouts when the fake is
live, and `app.py` registers the picker/consent routes only when the fake is
active at import time — in production they do not exist, not merely refuse.
"""
import json
import logging
import os
import secrets
import time
from html import escape
from typing import Optional
from urllib.parse import quote
from starlette.responses import RedirectResponse
log = logging.getLogger("wpsuite.okta.fake")
ENV_VAR = "WP_OKTA_FAKE_DIRECTORY"
# One-time authorization codes, in-process only. The login and the callback that
# redeems the code both happen inside the SAME uvicorn process within one test
# run, so this needs no more durability than that — the server restart every
# check does between runs clears it for free. Not a cache: entries are popped on
# first use (below) and expire on their own otherwise.
_CODE_TTL_SECONDS = 120
_PENDING_CODES: dict = {}
def _raw() -> str:
return os.getenv(ENV_VAR, "").strip()
def is_active() -> bool:
"""Whether the fake should answer. False in anything resembling production."""
if not _raw():
return False
# Imported lazily: server.db reads DATABASE_URL at import, and this module is
# imported from okta_auth (and app.py), which must stay importable on its own.
from .db import DATABASE_URL
if not str(DATABASE_URL).startswith("sqlite"):
log.error(
"%s is set but a non-SQLite DATABASE_URL is configured. REFUSING to use "
"the fake Okta provider — this looks like production, and D16 leaves no "
"other way in, so honouring it would be an authentication bypass. "
"Unset %s.", ENV_VAR, ENV_VAR)
return False
return True
def directory() -> dict:
try:
data = json.loads(_raw())
if not isinstance(data, dict):
raise ValueError("top level must be an object")
return data
except Exception as exc: # noqa: BLE001 — a malformed fake must not look auth-shaped
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
return {}
def new_code(claims: dict) -> str:
code = secrets.token_urlsafe(24)
_PENDING_CODES[code] = {"claims": claims, "expires": time.time() + _CODE_TTL_SECONDS}
return code
def consume_code(code: str) -> Optional[dict]:
"""Pop and return the claims for a code, or None if unknown/expired/reused.
Popping makes the code single-use, matching a real authorization code."""
entry = _PENDING_CODES.pop(code, None)
if not entry or entry["expires"] < time.time():
return None
return entry["claims"]
def picker_page(state: str, redirect_uri: str) -> str:
"""The fake's stand-in for Okta's own sign-in screen — a plain list of the
identities `WP_OKTA_FAKE_DIRECTORY` defines, so a browser check can click
through a real page rather than skip the round trip with a minted cookie.
Deliberately plain: nothing here is styled to resemble a real Okta page."""
from . import okta_auth
rows = []
for username in directory():
href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
f"&redirect_uri={quote(redirect_uri, safe='')}&username={quote(username)}")
rows.append(
f'<li><a id="okta-fake-identity-{escape(username)}" href="{escape(href, quote=True)}">'
f'Continue as {escape(username)}</a></li>')
deny_href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
f"&redirect_uri={quote(redirect_uri, safe='')}&deny=1")
deny_href = escape(deny_href, quote=True)
return (
"<!doctype html><title>FAKE Okta — tests only</title>"
"<h1>*** FAKE OKTA PROVIDER — TESTS ONLY ***</h1>"
f"<p>Identity claim in use: <code>{escape(okta_auth.IDENTITY_CLAIM)}</code></p>"
"<ul>" + "".join(rows) + "</ul>"
f'<p><a id="okta-fake-deny" href="{deny_href}">Deny access</a></p>'
)
class _FakeOktaClient:
"""Stands in for Authlib's `oauth.okta` — the same two methods `app.py`
calls, the same async signatures, zero network calls."""
async def authorize_redirect(self, request, redirect_uri):
state = secrets.token_urlsafe(24)
# The only session write this fake makes. authorize_access_token below is
# the only read — mirrors exactly what real Authlib does with `state`,
# which is what T10.2's missing-SessionMiddleware bug was about: this
# round trip is a genuine test of the same plumbing.
request.session["_okta_fake_state"] = state
target = redirect_uri or "/api/auth/okta/callback"
url = (f"/api/auth/okta/_fake_provider?state={quote(state)}"
f"&redirect_uri={quote(target, safe='')}")
return RedirectResponse(url=url, status_code=302)
async def authorize_access_token(self, request):
from authlib.integrations.base_client import OAuthError
expected = request.session.pop("_okta_fake_state", None)
given = request.query_params.get("state", "")
if not expected or given != expected:
raise OAuthError(
error="invalid_state",
description="fake Okta: state did not match the session (T10.7 seam)")
err = request.query_params.get("error")
if err:
raise OAuthError(
error=err,
description=request.query_params.get("error_description", "denied"))
code = request.query_params.get("code", "")
claims = consume_code(code)
if claims is None:
raise OAuthError(error="invalid_grant",
description="fake Okta: unknown or expired code")
return {"userinfo": claims}
class FakeOAuth:
"""Stands in for Authlib's `OAuth()` registry. The real one exposes each
registered client as an attribute by name; `app.py` only ever touches
`.okta`, so that is the only attribute this needs."""
def __init__(self):
self.okta = _FakeOktaClient()
def build() -> "FakeOAuth":
return FakeOAuth()

View File

@@ -17,6 +17,9 @@ pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server # MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
pydantic==2.13.4 pydantic==2.13.4
python-dotenv==1.2.2 python-dotenv==1.2.2
bcrypt==5.0.0 # password hashing
PyJWT==2.13.0 # signed session tokens PyJWT==2.13.0 # signed session tokens
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant) starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
Authlib==1.7.2 # Okta OIDC authorization-code flow (T10.1, wave 10 / D15)
httpx==0.28.1 # Authlib's OIDC client needs an HTTP client; explicit, not transitive
itsdangerous==2.2.0 # signs the OAuth-state cookie SessionMiddleware sets — required by
# Authlib's authorize_redirect/authorize_access_token, not optional

View File

@@ -9,15 +9,17 @@ and to have data to inspect.
Every /api/ route except /api/health requires a session, so this signs in first and Every /api/ route except /api/health requires a session, so this signs in first and
keeps the session cookie for the rest of the run — the same way server/smoketest.py keeps the session cookie for the rest of the run — the same way server/smoketest.py
does, reusing its opener rather than growing a second implementation of it. does, reusing its opener AND its session-minting (not a second implementation).
Credentials come from the environment so the password never has to appear in a
command line or shell history: There is no local password anymore (D15/D16, T10.4) — see smoketest.py's own
AUTHENTICATION section for why and what that means: this needs to run where it can
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under test,
and the account must already exist (this seeds a project, not a user).
export WP_SEED_USER=<admin-account> # or WP_SMOKE_USER, which is reused export WP_SEED_USER=<admin-account> # or WP_SMOKE_USER, which is reused
export WP_SEED_PASSWORD='' # or WP_SMOKE_PASSWORD
…or pass --user / --password. Use an admin account: seeding creates a project, and …or pass --user. Use an admin account: seeding creates a project, and --clean
--clean deletes one, which needs Project Admin on it. deletes one, which needs Project Admin on it.
USAGE USAGE
python3 server/seed_demo.py https://wp-suite.company.local --insecure python3 server/seed_demo.py https://wp-suite.company.local --insecure
@@ -48,16 +50,19 @@ import urllib.error
import urllib.request import urllib.request
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# So `from server import auth, models` / `from server.db import SessionLocal` also
# resolve (needed to mint a session — see AUTHENTICATION above).
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# The session handling is smoketest.py's, imported rather than copied: one cookie # The session handling is smoketest.py's, imported rather than copied: one cookie
# jar implementation, one login flow, one place to fix. Importing is safe — that # jar implementation, one session-minting flow, one place to fix. Importing is
# module does its work under `if __name__ == "__main__"`. # safe — that module does its work under `if __name__ == "__main__"`.
from smoketest import build_opener # noqa: E402 from smoketest import build_opener, seed_session_cookie # noqa: E402
BASE = "" BASE = ""
CTX = None CTX = None
# Carries the cookie jar holding the session issued by /api/auth/login. This # Carries the cookie jar holding the minted session (see AUTHENTICATION above).
# script used to call urllib.request.urlopen() directly, which has no cookie # This script used to call urllib.request.urlopen() directly, which has no cookie
# support, so the session was dropped and every data route answered 401 (S13). # support, so the session was dropped and every data route answered 401 (S13).
OPENER = None OPENER = None
DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data DEMO_NUMBER = "DEMO-001" # project number prefix used to find/clean demo data
@@ -116,29 +121,22 @@ def main():
ap.add_argument("--insecure", action="store_true", help="skip TLS verification") ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit") ap.add_argument("--clean", action="store_true", help="delete existing DEMO-* projects and exit")
ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""), ap.add_argument("--user", default=os.getenv("WP_SEED_USER", "") or os.getenv("WP_SMOKE_USER", ""),
help="account to sign in as (default: $WP_SEED_USER, then $WP_SMOKE_USER). " help="existing account to sign in as (default: $WP_SEED_USER, then "
"Use an admin account.") "$WP_SMOKE_USER). Use an admin account.")
ap.add_argument("--password",
default=os.getenv("WP_SEED_PASSWORD", "") or os.getenv("WP_SMOKE_PASSWORD", ""),
help="its password (default: $WP_SEED_PASSWORD, then $WP_SMOKE_PASSWORD — "
"preferred, so it stays out of shell history)")
args = ap.parse_args() args = ap.parse_args()
BASE = args.base_url.rstrip("/") BASE = args.base_url.rstrip("/")
if args.insecure: if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
OPENER = build_opener(CTX) OPENER = build_opener(CTX)
if not args.user or not args.password: if not args.user:
missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user),
("WP_SEED_PASSWORD", args.password)) if not v)
return abort( return abort(
f"no credentials — {missing} not set.", "no account — $WP_SEED_USER not set.",
" Every /api/ route except /api/health needs a session, so there is nothing\n" " Every /api/ route except /api/health needs a session, so there is nothing\n"
" this can seed without one. Set them and re-run:\n\n" " this can seed without one. Set it and re-run:\n\n"
" export WP_SEED_USER=<admin-account>\n" " export WP_SEED_USER=<admin-account>\n\n"
" export WP_SEED_PASSWORD=''\n\n" " Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n"
" Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n" " this and smoketest.py.")
" too, so one set of credentials serves this and smoketest.py.")
# health gate # health gate
try: try:
@@ -148,18 +146,25 @@ def main():
if st != 200: if st != 200:
print(f"ABORT: /api/health returned {st}"); return 1 print(f"ABORT: /api/health returned {st}"); return 1
# Sign in. The cookie the response sets is held by OPENER's jar and rides every # "Sign in" — mint a session directly (see AUTHENTICATION above) and seed it
# request after this one. # into OPENER's jar, so it rides every request after this one.
st, body = call("POST", "/api/auth/login", try:
{"username": args.user, "password": args.password}) from server import auth as srv_auth
if st != 200: from server.db import SessionLocal
detail = body.get("detail") if isinstance(body, dict) else body except ImportError as e:
hint = (" The account may be locked: the API locks an account for a while after a\n" return abort(f"cannot import the server package to mint a session: {e}",
" few consecutive failures, so retrying with the wrong password makes this\n" " This needs to run where server/ is importable and AUTH_SECRET_KEY /\n"
" worse. Check the password, then wait out the lockout window." " DATABASE_URL match the target server's — see AUTHENTICATION above.")
if st in (401, 403, 423, 429) else with SessionLocal() as db:
" Unexpected status from the login endpoint — check the API logs.") user = srv_auth.find_user(db, args.user)
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint) if not user:
return abort(f"no account named '{args.user}'.",
" This signs in as an existing account, it doesn't create one — sign in\n"
" through Okta once first, or create it from the admin console.")
if not user.is_active:
return abort(f"'{args.user}' is disabled.", "")
token = srv_auth.create_token(user)
seed_session_cookie(token, BASE)
logged_in = True logged_in = True
print(f"Signed in as {args.user}.") print(f"Signed in as {args.user}.")

View File

@@ -6,16 +6,23 @@ NGINX → FastAPI → PostgreSQL all work and that the Python logic (the AWP
release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq. release gate, metrics, cascade delete) behaves. Stdlib only — no pip, no jq.
AUTHENTICATION AUTHENTICATION
Every /api/ route except /api/health requires a session (auth_gate in There is no local password anymore (D15/D16, T10.4) — identity is Okta's job,
server/app.py), so the script signs in first and keeps the session cookie for and Okta requires a real browser to complete, which this stdlib script cannot
the rest of the run. Credentials come from the environment by preference, so a do. So instead of signing in over HTTP the way the front end does, this script
password never has to appear in a command line or shell history: mints a session the same way server/app.py's okta_callback() does after Okta
hands back an identity: auth.create_token() for an existing account, seeded
straight into the cookie jar. That means it needs to run somewhere that can
read the SAME AUTH_SECRET_KEY and reach the SAME database as the server under
test — inside the api container, or locally against your dev DB. It can no
longer sign in to an arbitrary remote URL from an unrelated workstation; if
the target is remote, run it on that host or inside that container instead.
export WP_SMOKE_USER=smoketest export WP_SMOKE_USER=smoketest
export WP_SMOKE_PASSWORD=''
python3 server/smoketest.py https://wp-suite.company.local python3 server/smoketest.py https://wp-suite.company.local
…or pass --user / --password explicitly. …or pass --user explicitly. The account must already exist — sign it in
through Okta once first (or create it from the admin console) if it doesn't;
this script promotes no one and provisions nothing.
Use an ADMIN account. The script creates a project and deletes it again at the Use an ADMIN account. The script creates a project and deletes it again at the
end, and deleting one takes Project Admin on that project (require_project_admin); end, and deleting one takes Project Admin on that project (require_project_admin);
@@ -24,26 +31,29 @@ AUTHENTICATION
discover it in the cleanup step. discover it in the cleanup step.
USAGE USAGE
# Against the deployed site (through the NGINX proxy): # From inside the api container (has AUTH_SECRET_KEY and DATABASE_URL; hits
python3 server/smoketest.py https://wp-suite.company.local # FastAPI directly):
docker compose exec -e WP_SMOKE_USER api \
# Self-signed / internal TLS cert? skip verification:
python3 server/smoketest.py https://wp-suite.company.local --insecure
# From inside the api container (hits FastAPI directly):
docker compose exec -e WP_SMOKE_USER -e WP_SMOKE_PASSWORD api \
python /app/server/smoketest.py http://localhost:8000 python /app/server/smoketest.py http://localhost:8000
# Local dev, against the app you're running yourself:
export AUTH_SECRET_KEY=... DATABASE_URL=... WP_SMOKE_USER=smoketest
python3 server/smoketest.py http://localhost:8000
# Self-signed / internal TLS cert on the HTTP side? skip verification:
python3 server/smoketest.py https://wp-suite.company.local --insecure
# Leave the demo project in the database so you can open it in the UI: # Leave the demo project in the database so you can open it in the UI:
python3 server/smoketest.py https://wp-suite.company.local --keep python3 server/smoketest.py http://localhost:8000 --keep
The base URL is the SITE root (no /api). Default: http://localhost:8000 The base URL is the SITE root (no /api). Default: http://localhost:8000
Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run Exit codes: 0 = all checks passed · 1 = one or more checks failed · 2 = the run
could not start (unreachable host, missing or rejected credentials). 2 is kept could not start (unreachable host, missing credentials, or no account by that
distinct on purpose: "I could not test this" is not the same answer as "this is username). 2 is kept distinct on purpose: "I could not test this" is not the
broken", and conflating them is what made an unauthenticated version of this same answer as "this is broken", and conflating them is what made an
script report a wall of failures against a perfectly healthy stack. unauthenticated version of this script report a wall of failures against a
perfectly healthy stack.
""" """
import argparse import argparse
import http.cookiejar import http.cookiejar
@@ -53,6 +63,12 @@ import ssl
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
from urllib.parse import urlparse
# So `from server import auth, models` / `from server.db import SessionLocal` resolve
# when this file is run directly (`python3 server/smoketest.py`) rather than as
# `python -m server.smoketest` — same reasoning as the sys.path lines in tests/*.py.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ── tiny colored reporter ───────────────────────────────────────────────────── # ── tiny colored reporter ─────────────────────────────────────────────────────
_PASS, _FAIL = [], [] _PASS, _FAIL = [], []
@@ -66,19 +82,40 @@ def check(name, cond, detail=""):
BASE = "" BASE = ""
CTX = None CTX = None
# One opener for the whole run, carrying the cookie jar that holds the session # One opener for the whole run, carrying the cookie jar that holds the session.
# issued by /api/auth/login. urlopen() has no cookie support, which is why the # urlopen() has no cookie support, which is why the session used to be dropped on
# session used to be dropped on the floor and every data route answered 401. # the floor and every data route answered 401.
OPENER = None OPENER = None
COOKIE_JAR = None
def build_opener(ctx=None): def build_opener(ctx=None):
handlers = [urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())] global COOKIE_JAR
COOKIE_JAR = http.cookiejar.CookieJar()
handlers = [urllib.request.HTTPCookieProcessor(COOKIE_JAR)]
if ctx is not None: if ctx is not None:
handlers.append(urllib.request.HTTPSHandler(context=ctx)) handlers.append(urllib.request.HTTPSHandler(context=ctx))
return urllib.request.build_opener(*handlers) return urllib.request.build_opener(*handlers)
def seed_session_cookie(token: str, base: str) -> None:
"""Put a minted session into the jar directly, the same shape a Set-Cookie
response from the old /api/auth/login would have produced — so the logout
check below (which relies on the jar honoring logout()'s Set-Cookie that
expires it) keeps working unchanged. `base` is explicit rather than read off
this module's own BASE global, so seed_demo.py (which imports this function
but has its own BASE) seeds the cookie for the host it's actually targeting."""
host = urlparse(base).hostname or "localhost"
COOKIE_JAR.set_cookie(http.cookiejar.Cookie(
version=0, name="wp_session", value=token,
port=None, port_specified=False,
domain=host, domain_specified=True, domain_initial_dot=False,
path="/", path_specified=True,
secure=False, expires=None, discard=True,
comment=None, comment_url=None, rest={"HttpOnly": None},
))
def call(method, path, body=None): def call(method, path, body=None):
"""Returns (status_code, parsed_body). Never raises on HTTP status.""" """Returns (status_code, parsed_body). Never raises on HTTP status."""
url = BASE + path url = BASE + path
@@ -116,10 +153,7 @@ def main():
ap.add_argument("--insecure", action="store_true", help="skip TLS verification") ap.add_argument("--insecure", action="store_true", help="skip TLS verification")
ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)") ap.add_argument("--keep", action="store_true", help="keep the demo project (don't delete)")
ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""), ap.add_argument("--user", default=os.getenv("WP_SMOKE_USER", ""),
help="account to sign in as (default: $WP_SMOKE_USER). Use an admin account.") help="existing account to sign in as (default: $WP_SMOKE_USER). Use an admin account.")
ap.add_argument("--password", default=os.getenv("WP_SMOKE_PASSWORD", ""),
help="its password (default: $WP_SMOKE_PASSWORD — preferred, "
"so it stays out of shell history)")
args = ap.parse_args() args = ap.parse_args()
BASE = args.base_url.rstrip("/") BASE = args.base_url.rstrip("/")
if args.insecure: if args.insecure:
@@ -128,17 +162,14 @@ def main():
print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n") print(f"\nWork Package Suite — API smoke test\nTarget: {BASE}\n")
# Refuse to start without credentials rather than running headlong into 401s. # Refuse to start without a username rather than running headlong into 401s.
if not args.user or not args.password: if not args.user:
missing = " and ".join(
n for n, v in (("WP_SMOKE_USER", args.user), ("WP_SMOKE_PASSWORD", args.password)) if not v)
return abort( return abort(
f"no credentials — {missing} not set.", "no account — $WP_SMOKE_USER not set.",
" Every /api/ route except /api/health needs a session, so there is nothing\n" " Every /api/ route except /api/health needs a session, so there is nothing\n"
" meaningful to test without one. Set them and re-run:\n\n" " meaningful to test without one. Set it and re-run:\n\n"
" export WP_SMOKE_USER=<admin-account>\n" " export WP_SMOKE_USER=<admin-account>\n\n"
" export WP_SMOKE_PASSWORD=''\n\n" " Or pass --user. Use an admin account: the run creates a project\n"
" Or pass --user/--password. Use an admin account: the run creates a project\n"
" and deletes it again, and the delete needs Project Admin on it.") " and deletes it again, and the delete needs Project Admin on it.")
project_id = None project_id = None
@@ -157,21 +188,29 @@ def main():
check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True, check("health endpoint returns ok", st == 200 and isinstance(body, dict) and body.get("ok") is True,
f"status={st} body={body}") f"status={st} body={body}")
# 2) Sign in. The cookie the response sets is held by OPENER's jar and rides # 2) "Sign in" — mint a session directly (see AUTHENTICATION above) and seed
# every request after this one. # it into OPENER's jar, so it rides every request after this one exactly the
st, body = call("POST", "/api/auth/login", # way a real Set-Cookie response would have.
{"username": args.user, "password": args.password}) try:
if st != 200: from server import auth as srv_auth
detail = body.get("detail") if isinstance(body, dict) else body from server.db import SessionLocal
hint = (" The account may be locked: the API locks an account for a while after\n" except ImportError as e:
" a few consecutive failures (AUTH_MAX_ATTEMPTS / AUTH_LOCKOUT_MINUTES),\n" return abort(f"cannot import the server package to mint a session: {e}",
" so re-running with the wrong password makes this worse, not better.\n" " This script now needs to run where server/ is importable and\n"
" Check the password, then wait out the lockout window." " AUTH_SECRET_KEY / DATABASE_URL match the target server's — see\n"
if st in (401, 403, 423, 429) else " AUTHENTICATION above.")
" Unexpected status from the login endpoint — check the API logs.") with SessionLocal() as db:
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint) user = srv_auth.find_user(db, args.user)
if not user:
return abort(f"no account named '{args.user}'.",
" This script signs in as an existing account, it doesn't create one —\n"
" sign in through Okta once first, or create it from the admin console.")
if not user.is_active:
return abort(f"'{args.user}' is disabled.", "")
token = srv_auth.create_token(user)
seed_session_cookie(token, BASE)
logged_in = True logged_in = True
check("login issues a session", st == 200) check("session cookie seeded", bool(token))
# 3) Prove the session actually travels — this is the check whose absence let # 3) Prove the session actually travels — this is the check whose absence let
# an unauthenticated version of this script look like a broken stack. # an unauthenticated version of this script look like a broken stack.

View File

@@ -26,6 +26,7 @@ browser found, or the server would not start). 2 is distinct on purpose: "I coul
not test this" is not the same answer as "this is broken". not test this" is not the same answer as "this is broken".
""" """
import argparse import argparse
import json
import os import os
import subprocess import subprocess
import sys import sys
@@ -39,7 +40,6 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
PW = "CorrectHorseBattery9"
_PASS, _FAIL = [], [] _PASS, _FAIL = [], []
@@ -86,7 +86,7 @@ def seed(db_path):
def mk(username, role): def mk(username, role):
db.add(models.User(id="user_" + username, username=username, db.add(models.User(id="user_" + username, username=username,
email=f"{username}@example.test", full_name=username.title(), email=f"{username}@example.test", full_name=username.title(),
password_hash=auth.hash_password(PW), role=role)) role=role))
mk("root", auth.ROLE_ADMIN) mk("root", auth.ROLE_ADMIN)
mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A mk("sue", auth.ROLE_PROJECT_SUPER) # super user on Job A
@@ -150,10 +150,29 @@ def seed(db_path):
for u in db.query(models.User).all()} for u in db.query(models.User).all()}
def start_server(port, db_path): # T10.7. The app authenticates through Okta, which no test can reach, and
# start_server launches it as a SUBPROCESS — so a monkeypatch here would never
# reach the code doing the authenticating. server/okta_fake.py reads this
# instead, and refuses to work against a non-SQLite database.
#
# Almost no check ever signs in (seed() mints tokens with auth.create_token and
# sets the cookie directly), so this matters only where the sign-in ROUND TRIP
# is driven — url_state_check's deep-link case. It is set for every server here
# anyway so that a test which starts signing in later does not fail
# mysteriously — the same reasoning the LDAP predecessor (D13/T10.7) used.
FAKE_DIRECTORY = json.dumps({
u: {"email": f"{u}@example.test", "name": u.title()}
for u in ("root", "sue", "pat", "mix", "bob", "sam", "legacy", "new")
})
def start_server(port, db_path, extra_env=None):
env = dict(os.environ) env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/") env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production") env.setdefault("AUTH_SECRET_KEY", "browser-check-secret-not-for-production")
env["WP_OKTA_FAKE_DIRECTORY"] = FAKE_DIRECTORY
if extra_env:
env.update(extra_env)
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1", [sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"], "--port", str(port), "--log-level", "warning"],
@@ -436,7 +455,10 @@ def main():
finally: finally:
if args.keep_server: if args.keep_server:
print(f"\n --keep-server: still up at {base}, database at {db_path}") print(f"\n --keep-server: still up at {base}, database at {db_path}")
print(" Sign in as root / " + PW) # No local password exists (D15/D16) — there's nothing to type into a login
# form. Set the session cookie directly, the same way this script's own
# fixture does, from the browser console on that origin:
print(f" document.cookie = 'wp_session={tok['root']}; path=/'")
else: else:
if server: if server:
# Wait for it to actually exit before deleting the database out from # Wait for it to actually exit before deleting the database out from

View File

@@ -8,9 +8,17 @@ They now go through `wp-dialog.js` — the T7.9 kit extracted as a shared,
self-injecting component (guarded so the creator's inline copy still wins on self-injecting component (guarded so the creator's inline copy still wins on
its own page). its own page).
Static half greps the counts; browser half drives the password-reset prompt on Static half greps the counts; browser half drives the users console's dialogs
the users console with natives poisoned, and proves validate() answers AT the with natives poisoned and proves they still work end to end.
input while the server round-trip completes end to end.
Used to drive this via the password-reset prompt specifically, because it was
the one place on this page exercising wp-dialog.js's PROMPT variant (text input
+ client-side validate()) rather than its confirm variant. T10.4 (D15/D16)
removed admin password reset entirely — there is no password to reset anymore
— so that coverage moved with it. The prompt-with-validate() pattern itself is
still exercised, just not on this page: see creator_dialogs_check.py for
wp-creation-app.js's own wpPromptDialog() call sites. If users.html ever grows
a new prompt-style dialog, it belongs back in this file.
Boots its own throwaway SQLite + uvicorn + headless browser; run it alone. Boots its own throwaway SQLite + uvicorn + headless browser; run it alone.
Exit 0 all passed, 1 a failure, 2 could not run. Exit 0 all passed, 1 a failure, 2 could not run.
@@ -91,31 +99,6 @@ def main():
chk("the console booted with a user table", chk("the console booted with a user table",
page.eval("!!document.querySelector('table')")) page.eval("!!document.querySelector('table')"))
page.eval("void resetPw('user_pat','pat')")
time.sleep(0.4)
chk("the reset prompt is the kit's modal, open, focused at the input",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return !!o && o.classList.contains('open')"
" && document.activeElement.id==='wp-dlg-input'; })()"))
page.eval("document.getElementById('wp-dlg-input').value='short';"
"document.getElementById('wp-dlg-ok').click()")
chk("a short password is refused AT the input - dialog stays, error says why",
page.eval("(() => { const o=document.getElementById('wp-dlg-overlay');"
" return o.classList.contains('open')"
" && /12 characters/.test(document.getElementById('wp-dlg-err').textContent); })()"))
page.eval("document.getElementById('wp-dlg-input').value='CorrectHorseBattery10';"
"document.getElementById('wp-dlg-ok').click()")
time.sleep(1.2)
chk("a good answer closes the dialog and the server accepts it",
page.eval("!document.getElementById('wp-dlg-overlay').classList.contains('open')"))
chk("...announced through the kit's toast (role=status)",
page.eval("(() => { const t=document.getElementById('toast');"
" return !!t && t.getAttribute('role')==='status'"
" && /Password reset for pat/.test(t.textContent); })()"))
st, _ = api(base, "/api/auth/login", "x", "POST",
{"username": "pat", "password": "CorrectHorseBattery10"})
chk("...and the new password actually works", st == 200, st)
print("\n3. destroy needs a real yes") print("\n3. destroy needs a real yes")
page.eval("void deleteUser('user_bob','bob')") page.eval("void deleteUser('user_bob','bob')")
time.sleep(0.4) time.sleep(0.4)

View File

@@ -31,7 +31,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
STUB = """ STUB = """
window.__dialogs = []; window.__dialogs = [];
@@ -55,8 +55,7 @@ def seed_empty(db_path):
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
db.add(models.User(id="user_new", username="new", email="new@example.test", db.add(models.User(id="user_new", username="new", email="new@example.test",
full_name="New Starter", password_hash=auth.hash_password(PW), full_name="New Starter", role=auth.ROLE_ADMIN))
role=auth.ROLE_ADMIN))
db.commit() db.commit()
return {u.username: auth.create_token(u) for u in db.query(models.User).all()} return {u.username: auth.create_token(u) for u in db.query(models.User).all()}

View File

@@ -323,6 +323,13 @@ def run(page, base, tok):
{"text": "Probe Building One,Probe Level 1,Probe Sector B"}) {"text": "Probe Building One,Probe Level 1,Probe Sector B"})
chk("the import reports it as reactivated, not created or duplicate", chk("the import reports it as reactivated, not created or duplicate",
len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"]) len(again["body"]["reactivated"]) == 1 and not again["body"]["created"], again["body"])
# The 2026-08-23 production 500, pinned (locations side): Postgres-refused
# values reject by line, on every dialect, never crash the request.
hz = api(page, "POST", "/api/projects/projA/locations/import",
{"text": "Probe Building One," + "Y" * 220 + ",S1", "dry_run": True})
chk("an over-long name is a line rejection, not a 500",
hz["status"] == 200 and hz["body"]["rejected"]
and "200 characters" in hz["body"]["rejected"][0]["reason"], hz["body"])
same = [n for n in api(page, "GET", same = [n for n in api(page, "GET",
"/api/projects/projA/locations?include_inactive=true")["body"]["nodes"] "/api/projects/projA/locations?include_inactive=true")["body"]["nodes"]
if n["path"] == sec["path"]] if n["path"] == sec["path"]]

View File

@@ -105,6 +105,20 @@ def main():
_, listing = api(base, "/api/projects/projA/materials", root) _, listing = api(base, "/api/projects/projA/materials", root)
chk("...and nothing was written", listing["items"] == []) chk("...and nothing was written", listing["items"] == [])
# The 2026-08-23 production 500, pinned: what Postgres refuses (VARCHAR
# overflow, control bytes) must come back as a per-line rejection - on
# EVERY dialect - never crash the request.
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
{"text": "Sample " + "x" * 300 + ",EA", "dry_run": True})
chk("an over-long description is a line rejection, not a 500",
code == 200 and rep["rejected"] and "300 characters" in rep["rejected"][0]["reason"]
and not rep["created"], ascii_(rep))
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
{"text": "Sample widget\u0000,EA", "dry_run": True})
chk("a control byte is a line rejection, not a 500",
code == 200 and rep["rejected"]
and "control characters" in rep["rejected"][0]["reason"], ascii_(rep))
code, rep = api(base, "/api/projects/projA/materials/import", root, "POST", code, rep = api(base, "/api/projects/projA/materials/import", root, "POST",
{"text": text, "dry_run": False}) {"text": text, "dry_run": False})
_, listing = api(base, "/api/projects/projA/materials", root) _, listing = api(base, "/api/projects/projA/materials", root)

327
tests/okta_auth_check.py Normal file
View File

@@ -0,0 +1,327 @@
#!/usr/bin/env python3
"""Does Okta sign-in hold its guarantees? — D15/D16 / T10.7.
The Okta equivalent of tests/ldap_auth_check.py (D13's predecessor). Covers the
things that would still let the app *look* fine while quietly failing:
1. The fake provider must be impossible to select against a real database.
2. A one-time authorization code cannot be redeemed twice (replay).
3. An unsolicited hit on the callback (no real login ever started) is refused,
not a 500 — and creates no account.
4. A denied ("Cancel") consent is refused cleanly (?error=cancelled) and
creates no account.
5. An unknown fake identity at consent is refused and creates no account.
6. A correct sign-in works, and ?next= carries through to the real target —
but only when it is a same-site path; an off-site next= is ignored.
7. A disabled local account is refused even though Okta itself approved it —
deprovisioning stays local (D15).
8. An unrecognized identity is JIT-provisioned at the lowest role.
9. An existing admin signs in and is STILL an admin, with their locally-set
name intact — Okta never overwrites what this app already knows.
10. OKTA_IDENTITY_CLAIM is genuinely configurable: sign-in still works with a
non-default claim name, proving T10.3's "no hard-coded claim" promise.
Self-contained: throwaway SQLite + its own uvicorn. No browser, no live Okta —
server/okta_fake.py stands in for the provider. Two layers, like its LDAP
predecessor: guards that need no server (direct calls into okta_fake), then
sign-in checks against a real running app.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from http.cookiejar import CookieJar
from urllib.parse import quote, urlparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cdp import free_port # noqa: E402
_PASS, _FAIL = [], []
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def chk(label, ok, detail=""):
(_PASS if ok else _FAIL).append(label)
print(f" {'PASS' if ok else 'FAIL'} {label}" + ("" if ok else f" {detail}"))
def users_in(db_path):
"""Read the users table straight out of the given file. Deliberately NOT
via server.db.SessionLocal — that engine binds from DATABASE_URL at import,
so setting the env var later keeps reading whichever file came first. The
LDAP predecessor lost two assertions to exactly this before it was noticed."""
import sqlite3
con = sqlite3.connect(db_path)
try:
try:
return {r[0]: (r[1], r[2], bool(r[3]))
for r in con.execute(
"select username, role, full_name, is_active from users")}
except sqlite3.OperationalError:
return {}
finally:
con.close()
def start(port, db_path, fake, identity_claim=None):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env["AUTH_SECRET_KEY"] = "okta-auth-check-not-for-production"
env["WP_OKTA_FAKE_DIRECTORY"] = json.dumps(fake)
if identity_claim:
env["OKTA_IDENTITY_CLAIM"] = identity_claim
else:
env.pop("OKTA_IDENTITY_CLAIM", None)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=ROOT)
for _ in range(160):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1):
return proc
except Exception:
if proc.poll() is not None:
return None
time.sleep(0.25)
proc.kill()
return None
def opener():
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(CookieJar()))
def fetch(op, url):
"""GET, auto-following redirects (urllib's default) — matches what a real
browser does across the login -> fake-provider -> callback -> target chain.
Returns (final_url, status, body)."""
try:
with op.open(url, timeout=10) as r:
return r.geturl(), r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.geturl(), e.code, e.read().decode("utf-8", "replace")
def sign_in(base, op, username, next_path=None):
"""Drive the real round trip: /api/auth/okta/login -> the fake provider's
picker page -> consent as `username` -> okta_callback(). Every hop except
the fake provider itself is the app's own unmodified code."""
login_url = base + "/api/auth/okta/login"
if next_path:
login_url += "?next=" + quote(next_path, safe="")
final_url, status, body = fetch(op, login_url)
if "_fake_provider" not in final_url:
return final_url, status, body # never reached the fake at all
m = re.search(
r'id="okta-fake-identity-%s" href="([^"]+)"' % re.escape(username), body)
if not m:
return final_url, status, body # identity not offered
consent_url = base + m.group(1).replace("&amp;", "&")
return fetch(op, consent_url)
def deny(base, op):
_, _, body = fetch(op, base + "/api/auth/okta/login")
m = re.search(r'id="okta-fake-deny" href="([^"]+)"', body)
if not m:
return None, None, body
return fetch(op, base + m.group(1).replace("&amp;", "&"))
def consent_as(base, op, username, state_override=None):
"""Reach the consent endpoint directly with an arbitrary `username` (which
need not be one the picker actually offered) and, optionally, a `state`
that does not match the one the login step stashed in the session — so
'unknown identity' and 'tampered state' can be tested as the server's own
refusal, not merely as absence from the picker's list."""
_, _, body = fetch(op, base + "/api/auth/okta/login")
m = re.search(r'id="okta-fake-deny" href="([^"]+)"', body)
if not m:
return None, None, body
href = m.group(1).replace("&amp;", "&").replace("deny=1", "username=" + quote(username))
if state_override is not None:
href = re.sub(r"state=[^&]*", "state=" + quote(state_override), href)
return fetch(op, base + href)
def main():
print("1. the guards that do not need a server")
os.environ["DATABASE_URL"] = "sqlite:///./_oktacheck_unit.db"
os.environ["WP_OKTA_FAKE_DIRECTORY"] = json.dumps({"root": {"email": "r@x.test", "name": "R"}})
from server import okta_fake
chk("the fake is active against a SQLite database", okta_fake.is_active())
out = subprocess.run(
[sys.executable, "-c",
"from server import okta_fake; print(okta_fake.is_active())"],
cwd=ROOT, capture_output=True, text=True,
env={**os.environ,
"DATABASE_URL": "postgresql+psycopg://u:p@localhost:5432/db",
"AUTH_SECRET_KEY": "x",
"WP_OKTA_FAKE_DIRECTORY": json.dumps({"root": {"email": "r@x.test"}})})
chk("the fake provider REFUSES to work against a non-SQLite database",
out.stdout.strip() == "False", out.stdout.strip() or out.stderr[-200:])
code = okta_fake.new_code({"preferred_username": "root"})
first = okta_fake.consume_code(code)
second = okta_fake.consume_code(code)
chk("a fresh code redeems once", first == {"preferred_username": "root"}, first)
chk("...and a REPLAYED code is refused the second time", second is None, second)
print("\n2. sign-in, against a server")
db_fd, db_path = tempfile.mkstemp(suffix=".db"); os.close(db_fd)
port = free_port()
fake = {"root": {"email": "root@example.test", "name": "Root Person"},
"newperson": {"email": "newperson@example.test", "name": "New Person"}}
server = start(port, db_path, fake)
if server is None:
print("the test server would not start.")
return 2
base = f"http://127.0.0.1:{port}"
try:
# Seed one pre-existing admin and one pre-existing but disabled account,
# the same way manage_users.py / the admin console would have left them.
import sqlalchemy as _sa
from server.db import Base
from server import models # noqa: F401
eng = _sa.create_engine("sqlite:///" + db_path.replace("\\", "/"))
Base.metadata.create_all(bind=eng)
with eng.begin() as con:
con.execute(_sa.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
"('user_root','root','','Set By Hand','admin',1,0,0,'','','',0,'',"
"datetime('now'),datetime('now'))"))
con.execute(_sa.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
"('user_shelved','shelved','','Shelved Person','project_user',0,0,0,"
"'','','',0,'',datetime('now'),datetime('now'))"))
eng.dispose()
fake["shelved"] = {"email": "shelved@example.test", "name": "Shelved Person"}
# Restart so the running process picks up the augmented directory.
server.kill(); server.wait(timeout=10)
server = start(port, db_path, fake)
if server is None:
print("the test server would not restart with the augmented directory.")
return 2
final_url, status, _ = sign_in(base, opener(), "root")
chk("a seeded identity signs in", status == 200 and urlparse(final_url).path == "/index.html",
(final_url, status))
chk("...and the existing admin is STILL an admin",
users_in(db_path).get("root", ("", "", None))[0] == "admin", users_in(db_path))
chk("...with their locally-set name untouched by Okta",
users_in(db_path).get("root", (None, None))[1] == "Set By Hand", users_in(db_path))
before = set(users_in(db_path))
final_url, status, _ = sign_in(base, opener(), "newperson")
chk("an unrecognized identity is JIT-provisioned", status == 200, (final_url, status))
chk("...at the lowest role", users_in(db_path).get("newperson", ("", "", None))[0]
== "project_user", users_in(db_path).get("newperson"))
chk("...and only one new row appeared",
set(users_in(db_path)) - before == {"newperson"}, set(users_in(db_path)) - before)
final_url, status, _ = sign_in(base, opener(), "shelved")
chk("a disabled local account is refused despite Okta approving it",
"error=disabled" in final_url, final_url)
before = set(users_in(db_path))
final_url, status, _ = consent_as(base, opener(), "nobody-such-identity")
# okta_callback()'s `except OAuthError` is deliberately generic (T10.5:
# a plain-language ?error= for whatever Authlib/the provider rejected,
# not a code-by-code breakdown) — so this lands on the same ?error=
# cancelled as every other refusal, not a distinct "invalid_request".
# The thing actually under test is the SERVER-side refusal, verified by
# checking no account got created — not the display string.
chk("consenting as an identity the fake never offered is refused BY THE SERVER"
" (not just absent from the picker)",
"login.html" in final_url and "error=cancelled" in final_url, final_url)
chk("...and creates nothing", set(users_in(db_path)) == before,
set(users_in(db_path)) - before)
before = set(users_in(db_path))
final_url, status, _ = consent_as(base, opener(), "root", state_override="tampered-state")
chk("a consent hit whose state does not match the session is refused",
"login.html" in final_url and "error=cancelled" in final_url, final_url)
chk("...and creates nothing", set(users_in(db_path)) == before,
set(users_in(db_path)) - before)
before = set(users_in(db_path))
final_url, status, _ = deny(base, opener())
chk("denying consent lands back on login with a plain message",
"login.html" in final_url and "error=cancelled" in final_url, final_url)
chk("...and creates nothing", set(users_in(db_path)) == before,
set(users_in(db_path)) - before)
cold = opener()
final_url, status, _ = fetch(
cold, base + "/api/auth/okta/callback?code=forged&state=forged")
chk("an unsolicited hit on the callback (no login ever started) is refused, not a 500",
status == 200 and "login.html" in final_url and "error=cancelled" in final_url,
(final_url, status))
final_url, status, _ = sign_in(base, opener(), "root", next_path="/wp-creation-index.html?wp=x")
chk("a same-site ?next= survives the round trip",
urlparse(final_url).path == "/wp-creation-index.html"
and "wp=x" in urlparse(final_url).query, final_url)
final_url, status, _ = sign_in(base, opener(), "root", next_path="https://evil.example.com/phish")
chk("an off-site ?next= is ignored, not honoured",
urlparse(final_url).path == "/index.html", final_url)
finally:
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n3. the identity claim name is genuinely configurable (T10.3)")
db_fd2, db2 = tempfile.mkstemp(suffix=".db"); os.close(db_fd2)
port2 = free_port()
server2 = start(port2, db2, {"root": {"email": "root@example.test", "name": "Root"}},
identity_claim="upn")
if server2 is None:
print("the identity-claim test server would not start.")
return 2
try:
base2 = f"http://127.0.0.1:{port2}"
final_url, status, _ = sign_in(base2, opener(), "root")
chk("sign-in works with a non-default OKTA_IDENTITY_CLAIM (upn)",
status == 200 and urlparse(final_url).path == "/index.html", (final_url, status))
chk("...and JIT-provisioned the account under that identity",
users_in(db2).get("root", ("", "", None))[0] == "project_user", users_in(db2))
finally:
server2.kill()
try:
server2.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n" + "-" * 54)
print(f"{len(_PASS)}/{len(_PASS) + len(_FAIL)} checks passed.")
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(f"could not run: {type(exc).__name__}: {exc}")
sys.exit(2)

View File

@@ -33,7 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \ READY = "!!document.querySelector('#pipeline-strip .pipe-cell, #pipeline-strip .pipe-empty, " \
"#pipeline-strip .pipe-error')" "#pipeline-strip .pipe-error')"

View File

@@ -47,7 +47,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
from browser_check import seed, start_server, PW # noqa: E402,F401 from browser_check import seed, start_server # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HTML = os.path.join(ROOT, "html") HTML = os.path.join(ROOT, "html")

View File

@@ -7,7 +7,13 @@ the suite, so no work package had an address. This checks the promise those emai
will rest on. will rest on.
1. a URL identifying a work package opens that work package 1. a URL identifying a work package opens that work package
2. the same URL works for a SIGNED-OUT user, via login, landing on the target 2. the same URL works for a SIGNED-OUT user, via the real Okta sign-in round
trip (T10.7's fake provider stands in for Okta itself — see
server/okta_fake.py — but the app's own login.html, the redirect to
/api/auth/okta/login, the state round trip through SessionMiddleware, and
okta_callback()'s handling of ?next= are all real, unmodified code).
Landing on the requested target, not the home page, doubles as setup for
scenarios 3-6 below.
3. refresh preserves project, package, tab and view 3. refresh preserves project, package, tab and view
4. Back and Forward move through states without a reload or a broken view 4. Back and Forward move through states without a reload or a broken view
5. the URL survives being copied to a second browsing context 5. the URL survives being copied to a second browsing context
@@ -27,7 +33,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cdp # noqa: E402 import cdp # noqa: E402
from browser_check import seed, start_server, chk, _PASS, _FAIL, _c, PW # noqa: E402 from browser_check import seed, start_server, chk, _PASS, _FAIL, _c # noqa: E402
def settle(page, seconds=1.4): def settle(page, seconds=1.4):
@@ -168,7 +174,7 @@ def main():
finally: finally:
page2.close() page2.close()
print("\n2. the same URL works for a signed-out user, via login") print("\n2. the same URL works for a signed-out user, via the real Okta round trip")
page.clear_cookies() page.clear_cookies()
page.goto(deep) page.goto(deep)
settle(page, 1.6) settle(page, 1.6)
@@ -177,19 +183,44 @@ def main():
nxt = page.eval("new URLSearchParams(location.search).get('next')||''") nxt = page.eval("new URLSearchParams(location.search).get('next')||''")
chk("...carrying the requested target, package id and all", chk("...carrying the requested target, package id and all",
"wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt) "wp-creation-index.html" in nxt and "wp=wpA1" in nxt, "next=%r" % nxt)
page.eval("document.getElementById('username').value=%r" % "root")
page.eval("document.getElementById('password').value=%r" % PW) # Drive the actual button, not a shortcut to it — its href already
page.eval("document.querySelector('form').requestSubmit" # carries ?next= (login.js's safeNext()); this is the same click a
"? document.querySelector('form').requestSubmit()" # person makes.
": document.querySelector('form').submit()") signin_href = page.eval(
for _ in range(40): "(document.getElementById('okta-signin')||{}).getAttribute('href')||''")
if "wp-creation-index.html" in page.eval("location.href"): chk("the sign-in link itself carries ?next=", "next=" in signin_href, signin_href)
page.goto(base + signin_href)
for _ in range(30):
if "_fake_provider" in page.eval("location.href"):
break break
time.sleep(0.3) time.sleep(0.3)
settle(page, 1.2) chk("the app hands off to the (fake) Okta provider",
"_fake_provider" in page.eval("location.href"), page.eval("location.href"))
# The fake provider's own picker page — a real page, not a shortcut.
# See server/okta_fake.py: only the network-touching Authlib calls are
# faked, not app.py's own login/callback/JIT/guard code.
identity_href = page.eval(
"(document.getElementById('okta-fake-identity-root')||{}).getAttribute('href')||''")
chk("the fake provider offers the seeded 'root' identity", bool(identity_href),
page.eval("document.body.innerHTML"))
page.goto(base + identity_href)
for _ in range(30):
href = page.eval("location.href")
if "login.html" not in href and "_fake_provider" not in href:
break
time.sleep(0.3)
settle(page, 1.0)
# NOT `"wp-creation-index.html" in location.href` alone — that string
# is in the ?next= parameter too, so this would pass while still
# sitting on login.html with the sign-in rejected (the same mistake
# D13/T10.7's LDAP predecessor caught and fixed here). Assert we
# actually LEFT the login page.
href = page.eval("location.href")
chk("signing in continues to the requested page, not the home page", chk("signing in continues to the requested page, not the home page",
"wp-creation-index.html" in page.eval("location.href"), "login.html" not in href and "wp-creation-index.html" in href
page.eval("location.href")) and "wp=wpA1" in href, href)
for _ in range(30): for _ in range(30):
if page.eval("!!window.wpCreatorReady"): if page.eval("!!window.wpCreatorReady"):
break break