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:
@@ -1,78 +1,59 @@
|
||||
"""Command-line user management for the Work Package Suite.
|
||||
|
||||
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
||||
existing admin, so you have to bootstrap one here), and for occasional account
|
||||
There is no local password (D15) and no local account creation from here anymore
|
||||
(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.
|
||||
|
||||
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
|
||||
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 create bob --role user --name "Bob Jones"
|
||||
python -m server.manage_users promote alice --role admin
|
||||
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 enable bob
|
||||
|
||||
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
||||
at least 8 characters.
|
||||
"""
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from .db import SessionLocal, Base, engine
|
||||
from . import models, auth
|
||||
|
||||
|
||||
def _gen_id() -> str:
|
||||
return f"user_{uuid.uuid4().hex[:12]}"
|
||||
|
||||
|
||||
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.
|
||||
def cmd_promote(args) -> None:
|
||||
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.
|
||||
if role == "user":
|
||||
role = auth.ROLE_PROJECT_USER
|
||||
if role not in 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:
|
||||
if auth.find_user(db, args.username):
|
||||
sys.exit(f"A user named '{args.username}' already exists.")
|
||||
u = models.User(
|
||||
id=_gen_id(),
|
||||
username=args.username.strip(),
|
||||
full_name=(args.name or "").strip(),
|
||||
email=(args.email or "").strip(),
|
||||
password_hash=auth.hash_password(pw),
|
||||
role=role,
|
||||
)
|
||||
db.add(u)
|
||||
u = auth.find_user(db, args.username)
|
||||
if not u:
|
||||
sys.exit(
|
||||
f"No user named '{args.username}'. This promotes an existing account, it "
|
||||
f"doesn't create one — they need to sign in through Okta at least once first."
|
||||
)
|
||||
u.role = role
|
||||
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:
|
||||
with SessionLocal() as db:
|
||||
rows = db.query(models.User).order_by(models.User.username).all()
|
||||
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
|
||||
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
|
||||
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}")
|
||||
|
||||
|
||||
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:
|
||||
with SessionLocal() as db:
|
||||
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")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
def add_create(name, help_):
|
||||
sp = sub.add_parser(name, help=help_)
|
||||
sp.add_argument("username")
|
||||
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
||||
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)")
|
||||
pr = sub.add_parser("promote", help="change an existing account's role (e.g. name the first admin)")
|
||||
pr.add_argument("username")
|
||||
pr.add_argument("--role", required=True, choices=list(auth.ROLES) + ["user"],
|
||||
help="permissions role ('user' is the legacy name for project_user)")
|
||||
|
||||
sub.add_parser("list", help="list all accounts")
|
||||
|
||||
rp = sub.add_parser("reset-password", help="reset a user's password")
|
||||
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 = sub.add_parser("disable", help="disable an account (blocks sign-in)")
|
||||
dp.add_argument("username")
|
||||
ep = sub.add_parser("enable", help="re-enable an account")
|
||||
ep.add_argument("username")
|
||||
|
||||
args = p.parse_args()
|
||||
if args.cmd == "create-admin":
|
||||
cmd_create(args, role="admin")
|
||||
elif args.cmd == "create":
|
||||
cmd_create(args)
|
||||
if args.cmd == "promote":
|
||||
cmd_promote(args)
|
||||
elif args.cmd == "list":
|
||||
cmd_list(args)
|
||||
elif args.cmd == "reset-password":
|
||||
cmd_reset_password(args)
|
||||
elif args.cmd == "disable":
|
||||
_set_active(args.username, False)
|
||||
elif args.cmd == "enable":
|
||||
|
||||
Reference in New Issue
Block a user