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
This commit is contained in:
2026-09-03 10:57:50 -07:00
parent c74289aa0d
commit 73da684b99
18 changed files with 275 additions and 602 deletions

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
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.
Credentials come from the environment so the password never has to appear in a
command line or shell history:
does, reusing its opener AND its session-minting (not a second implementation).
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_PASSWORD='' # or WP_SMOKE_PASSWORD
…or pass --user / --password. Use an admin account: seeding creates a project, and
--clean deletes one, which needs Project Admin on it.
…or pass --user. Use an admin account: seeding creates a project, and --clean
deletes one, which needs Project Admin on it.
USAGE
python3 server/seed_demo.py https://wp-suite.company.local --insecure
@@ -48,16 +50,19 @@ import urllib.error
import urllib.request
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
# jar implementation, one login flow, one place to fix. Importing is safe — that
# module does its work under `if __name__ == "__main__"`.
from smoketest import build_opener # noqa: E402
# jar implementation, one session-minting flow, one place to fix. Importing is
# safe — that module does its work under `if __name__ == "__main__"`.
from smoketest import build_opener, seed_session_cookie # noqa: E402
BASE = ""
CTX = None
# Carries the cookie jar holding the session issued by /api/auth/login. This
# script used to call urllib.request.urlopen() directly, which has no cookie
# Carries the cookie jar holding the minted session (see AUTHENTICATION above).
# 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).
OPENER = None
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("--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", ""),
help="account to sign in as (default: $WP_SEED_USER, then $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)")
help="existing account to sign in as (default: $WP_SEED_USER, then "
"$WP_SMOKE_USER). Use an admin account.")
args = ap.parse_args()
BASE = args.base_url.rstrip("/")
if args.insecure:
CTX = ssl.create_default_context(); CTX.check_hostname = False; CTX.verify_mode = ssl.CERT_NONE
OPENER = build_opener(CTX)
if not args.user or not args.password:
missing = " and ".join(n for n, v in (("WP_SEED_USER", args.user),
("WP_SEED_PASSWORD", args.password)) if not v)
if not args.user:
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"
" this can seed without one. Set them and re-run:\n\n"
" export WP_SEED_USER=<admin-account>\n"
" export WP_SEED_PASSWORD=''\n\n"
" Or pass --user/--password. WP_SMOKE_USER / WP_SMOKE_PASSWORD are accepted\n"
" too, so one set of credentials serves this and smoketest.py.")
" this can seed without one. Set it and re-run:\n\n"
" export WP_SEED_USER=<admin-account>\n\n"
" Or pass --user. WP_SMOKE_USER is accepted too, so one account name serves\n"
" this and smoketest.py.")
# health gate
try:
@@ -148,18 +146,25 @@ def main():
if st != 200:
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
# request after this one.
st, body = call("POST", "/api/auth/login",
{"username": args.user, "password": args.password})
if st != 200:
detail = body.get("detail") if isinstance(body, dict) else body
hint = (" The account may be locked: the API locks an account for a while after a\n"
" few consecutive failures, so retrying with the wrong password makes this\n"
" worse. Check the password, then wait out the lockout window."
if st in (401, 403, 423, 429) else
" Unexpected status from the login endpoint — check the API logs.")
return abort(f"could not sign in as '{args.user}' (HTTP {st}): {detail}", hint)
# "Sign in" — mint a session directly (see AUTHENTICATION above) and seed it
# into OPENER's jar, so it rides every request after this one.
try:
from server import auth as srv_auth
from server.db import SessionLocal
except ImportError as e:
return abort(f"cannot import the server package to mint a session: {e}",
" This needs to run where server/ is importable and AUTH_SECRET_KEY /\n"
" DATABASE_URL match the target server's — see AUTHENTICATION above.")
with SessionLocal() as db:
user = srv_auth.find_user(db, args.user)
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
print(f"Signed in as {args.user}.")