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

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

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

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

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

@@ -13,8 +13,6 @@ 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
@@ -609,14 +607,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
@@ -638,24 +630,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
@@ -678,47 +652,6 @@ 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)
@@ -762,14 +695,13 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)):
# at all — this app still decides what a first-time sign-in may do. A new # at all — this app still decides what a first-time sign-in may do. A new
# account gets the lowest-privilege role and no project membership; an admin # account gets the lowest-privilege role and no project membership; an admin
# or project super user grants access afterward, same as any account created # or project super user grants access afterward, same as any account created
# by hand today (create_user() above). No password is ever set — Okta is the # by hand today (create_user() above). No password field exists at all —
# only credential (D15's "no stored password"). # Okta is the only credential (D15/D16, real deletion as of T10.4).
user = models.User( user = models.User(
id=gen_id("user"), id=gen_id("user"),
username=identity, username=identity,
email=(claims.get("email") or "").strip(), email=(claims.get("email") or "").strip(),
full_name=(claims.get("name") or "").strip(), full_name=(claims.get("name") or "").strip(),
password_hash="",
role=auth.ROLE_PROJECT_USER, role=auth.ROLE_PROJECT_USER,
) )
db.add(user) db.add(user)
@@ -794,113 +726,6 @@ async def okta_callback(request: Request, db: Session = Depends(get_db)):
return redirect return redirect
# ── Self-service password reset (needs email switched on) ──────────────────────
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to
# stop someone using the form to spam a colleague's inbox. Per-worker and lost on
# restart — deliberately simple; the token expiry is the real control.
_reset_last: dict[str, float] = {}
def _reset_throttled(request: Request, username: str) -> bool:
now = monotonic()
key = f"{(username or '').strip().lower()}|{request.client.host if request.client else ''}"
prev = _reset_last.get(key)
if prev is not None and (now - prev) < RESET_COOLDOWN_SECONDS:
return True
_reset_last[key] = now
if len(_reset_last) > 5000: # bound the dict on a long-lived worker
cutoff = now - RESET_COOLDOWN_SECONDS
for k in [k for k, t in _reset_last.items() if t < cutoff]:
_reset_last.pop(k, None)
return False
def reset_body(user: "models.User", link: str, minutes: int) -> str:
# No account detail beyond the username, and no customer data — same rule as
# the assignment mail. The link is the only sensitive thing in here.
who = user.full_name or user.username
return (
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"
)
@app.get("/api/auth/reset-available")
def reset_available(db: Session = Depends(get_db)):
"""Whether the login page should offer 'Forgot password'. Self-service reset
depends entirely on outbound email, so it's off unless email is enabled AND
SMTP is configured — otherwise the only route is an admin reset."""
s = notify.get_settings(db)
return {"enabled": bool(s.get("email_enabled")) and notify.smtp_ready(s)}
@app.post("/api/auth/forgot-password")
def forgot_password(body: ForgotPasswordIn, request: Request, db: Session = Depends(get_db)):
"""Email a reset link. Always returns the same 200 response whether or not the
account exists — this endpoint is unauthenticated, so it must not become a
username/email oracle. Failures are recorded in the audit log instead."""
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):
# Same shape as the success response — no oracle, no mail bomb.
return {"ok": True, "message": "If that account exists, a reset link is on its way."}
user = auth.find_user(db, body.username)
if user and user.is_active and user.email:
base = (s.get("app_base_url") or "").rstrip("/")
token = auth.create_reset_token(user)
link = f"{base}/login.html?reset={token}" if base else f"/login.html?reset={token}"
sent = notify.send_now(
db, user.email,
"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.locked_until = None
log_event(db, user.username, "password_reset", "user", user.id, summary=user.username)
db.commit()
return {"ok": True}
@app.get("/api/auth/me") @app.get("/api/auth/me")
@@ -956,22 +781,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.
@@ -1059,9 +868,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)}")
@@ -1093,7 +907,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],
) )
@@ -1120,24 +933,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)

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,78 +1,59 @@
"""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
from .db import SessionLocal, Base, engine 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(), u.role = role
password_hash=auth.hash_password(pw),
role=role,
)
db.add(u)
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 +61,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 +78,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="")

View File

@@ -17,8 +17,6 @@ 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 — still used until T10.4 removes the local
# password path; do not drop this before that task lands.
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) Authlib==1.7.2 # Okta OIDC authorization-code flow (T10.1, wave 10 / D15)

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

@@ -39,7 +39,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 +85,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
@@ -436,7 +435,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

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

@@ -8,6 +8,10 @@ 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 login, landing on the target
— SKIPPED as of T10.4: local login is gone (D15/D16), and the redirect-
through-Okta replacement doesn't exist until T10.5 rebuilds login.html.
Re-test this once that lands. Signing in via a minted token stands in as
setup only, so scenarios 3-6 below still get a signed-in page to run on.
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 +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
def settle(page, seconds=1.4): def settle(page, seconds=1.4):
@@ -169,6 +173,8 @@ def main():
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 login")
print(" SKIPPED: local login is gone (D15/D16); the Okta-redirect replacement")
print(" doesn't exist until T10.5. Re-test the next= round trip once it lands.")
page.clear_cookies() page.clear_cookies()
page.goto(deep) page.goto(deep)
settle(page, 1.6) settle(page, 1.6)
@@ -177,19 +183,14 @@ 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") # Setup only for scenarios 3-6 below, NOT a re-test of "signing in continues
page.eval("document.getElementById('password').value=%r" % PW) # to the requested page" — that promise is specific to the login FORM this
page.eval("document.querySelector('form').requestSubmit" # task removed, and can't be honestly re-proven until T10.5 rebuilds it as an
"? document.querySelector('form').requestSubmit()" # Okta redirect. A minted-token cookie gets `page` to the same signed-in,
": document.querySelector('form').submit()") # on-target state those later scenarios need, without claiming to have
for _ in range(40): # exercised the (currently nonexistent) sign-in flow itself.
if "wp-creation-index.html" in page.eval("location.href"): page.set_cookie("wp_session", tok["root"])
break page.goto(deep)
time.sleep(0.3)
settle(page, 1.2)
chk("signing in continues to the requested page, not the home page",
"wp-creation-index.html" in page.eval("location.href"),
page.eval("location.href"))
for _ in range(30): for _ in range(30):
if page.eval("!!window.wpCreatorReady"): if page.eval("!!window.wpCreatorReady"):
break break