"""Command-line user management for the Work Package Suite. 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 promote alice --role admin python -m server.manage_users list python -m server.manage_users disable bob python -m server.manage_users enable bob """ import argparse import sys import uuid from .db import SessionLocal, Base, engine from . import models, auth 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)}") with SessionLocal() as db: 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." ) old_role = u.role u.role = role # Audited the same way a role change from the web Admin Console already is # (server/app.py's set_user_role() -> log_event(), action "role_changed") — # this command changes the same field and previously left no record of who # ran it or what it changed (T10.10). "actor" can't name a real person here: # a container shell exec carries no signed-in identity to attribute it to, # so it's tagged as the tool itself rather than guessing. "via" mirrors JIT # provisioning's own tag on user_created events. db.add(models.AuditLog( id=f"ev_{uuid.uuid4().hex[:12]}", actor="cli:manage_users", action="role_changed", entity_type="user", entity_id=u.id, summary=u.username, detail={"from": old_role, "to": role, "via": "cli"}, )) db.commit() 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. Accounts appear here once someone signs in through Okta.") 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) 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") 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 == "promote": cmd_promote(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()