Files
Project-SDE-WP-Suite/server/manage_users.py
Cody Schaefer 8cfb4c1008 T10.3 D13 - drop users.password_hash and every path that touched it
The irreversible one. The suite no longer stores a credential of any kind.

Removed from app.py: /api/auth/forgot-password, /api/auth/reset-password,
/api/auth/reset-available, /api/auth/password, /api/auth/users/{id}/password,
the four password-bearing input models, the reset throttle and mail body, and
the password arguments to create_user. Removed from auth.py: hash_password,
verify_password, password_problem, MIN_PASSWORD_LEN, _COMMON_PASSWORDS,
create_reset_token, decode_reset_token, RESET_MINUTES, and the bcrypt import.
Removed from notify.py: the password_reset_enabled feature flag. Removed from
manage_users.py: the password prompt and the reset-password command.

token_version STAYS. Password changes no longer exist, but a role change or a
deactivation still has to invalidate sessions that are already issued.

/api/auth/users/{id}/role stays, which is D13 criterion 4 - granting admin to
an existing account must keep working, and it does.

Migration b7e4f1a20c93 uses batch_alter_table because SQLite has no DROP COLUMN
before 3.35 and local dev runs on SQLite while production runs on Postgres.
downgrade() recreates the column NULLABLE rather than NOT NULL as the baseline
declared it: there are no hashes to put back, and a NOT NULL column with no
server default refuses to add itself to a table with rows. The docstring says
plainly that the downgrade does not restore the old login - it exists so the
revision is well-formed, not because stepping back is a recovery path.

Verified:

  upgrade head from empty      -> password_hash absent from users
  downgrade -1                 -> column back, nullable (notnull=0)
  upgrade head again           -> absent again
  remaining /api/auth routes   -> no password or reset route left
  create-admin                 -> works with no password prompt
  grep for the removed symbols -> nothing outside the migration and one
                                  docstring that names the dropped column

NOT verified, and it is a done-when box left open rather than ticked: the
migration has only been round-tripped on SQLite. No Postgres is available here.
batch_alter_table takes the direct ALTER path on Postgres, which is the simpler
of the two, but "simpler" is not "tested".

notify.send_now is now orphaned - its only caller was forgot_password. Logged as
BL-026 rather than deleted in passing, because an immediate unqueued send is a
reasonable primitive to keep and that decision does not belong in an auth task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 15:26:43 -05:00

124 lines
4.5 KiB
Python

"""Command-line user management for the Work Package Suite.
Use this to bootstrap the FIRST admin (the /api/auth/users endpoint needs an
existing admin, so one has to be made here) and for occasional account maintenance
from a shell on the server.
NO PASSWORDS. D13 moved authentication to an LDAPS bind against the domain, so
this tool never sets or resets a credential — it only creates accounts and assigns
roles. People sign in with their Windows password; the login page sends anyone who
has forgotten it to Okta.
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 list
python -m server.manage_users disable bob
python -m server.manage_users enable bob
`username` must be the person's sAMAccountName, because that is what the bind and
the account match use. Creating an account is optional: anyone who authenticates
against the domain and is not already here is provisioned on first sign-in, at
project_user with no project access.
"""
import argparse
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 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":
role = auth.ROLE_PROJECT_USER
if role not in auth.ROLES:
sys.exit(f"role must be one of {', '.join(auth.ROLES)}")
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(),
role=role,
)
db.add(u)
db.commit()
print(f"Created {role}: {u.username} (id={u.id})")
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>")
return
print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'NAME'}")
for u in rows:
print(f"{u.username:<24}{auth.normalize_role(u.role):<20}"
f"{('yes' if u.is_active else 'no'):<8}{u.full_name}")
def _set_active(username: str, active: bool) -> None:
with SessionLocal() as db:
u = auth.find_user(db, username)
if not u:
sys.exit(f"No user named '{username}'.")
u.is_active = active
db.commit()
print(f"{u.username} is now {'enabled' if active else 'disabled'}.")
def main() -> None:
# Ensure the users table exists even on a fresh database.
Base.metadata.create_all(bind=engine)
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", help="the person's sAMAccountName")
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")
dp = sub.add_parser("disable", help="disable an account (blocks login)")
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)
elif args.cmd == "list":
cmd_list(args)
elif args.cmd == "disable":
_set_active(args.username, False)
elif args.cmd == "enable":
_set_active(args.username, True)
if __name__ == "__main__":
main()