"""Command-line user management for the Work Package Suite. Accounts are not created here any more. D13 provisions them on first successful sign-in, so this tool exists to do the one thing the directory cannot decide: assign the app's PERMISSIONS role. The directory supplies identity; this supplies authorization. python -m server.manage_users list python -m server.manage_users promote alice # -> admin python -m server.manage_users promote bob --role project_admin python -m server.manage_users demote alice # -> project_user python -m server.manage_users disable bob python -m server.manage_users enable bob Run from the PROJECT ROOT (same place you run uvicorn) so the package imports and .env resolve the way the API does. `create-admin` and `create` are GONE (D14). They were redundant once accounts provision themselves, and removing them closes a whole class of problem: every row now originates from a successful bind, so a username can no longer be typed in wrong and end up orphaned from the directory identity it was meant to match. BOOTSTRAPPING THE FIRST ADMIN is therefore two steps, in this order: 1. Sign in to the app once. That provisions your account at project_user. 2. Run `promote ` here. EVERY COMMAND THAT CHANGES ANYTHING REQUIRES A DOMAIN BIND (D14). Shell access alone is no longer enough to mint an admin. Be clear about what that is and is not worth: anyone with a shell on this container can still write to the `users` table directly with psql or sqlite3, so this is defence in depth and — mostly — ACCOUNTABILITY. Before D14 every role change made from a shell was invisible in the audit trail while the same change through the console was recorded. Now both are recorded, and both name a person. The bind here deliberately does NOT apply the login group gate. If a mistyped required group locks everyone out of the console, this tool has to still work — otherwise the only way to fix the lockout is the only thing the lockout prevents. `list` needs no credential, so an outage stays diagnosable. """ import argparse import getpass import sys import uuid from .db import SessionLocal, Base, engine from . import models, auth, ldap_auth def _gen_id(prefix: str = "user") -> str: return f"{prefix}_{uuid.uuid4().hex[:12]}" def _audit(db, actor: str, action: str, user: "models.User", detail: dict) -> None: """Append an audit row in the caller's transaction. Written by hand rather than via app.py's log_event: importing that would drag FastAPI and the entire application into a CLI startup for one INSERT. """ db.add(models.AuditLog( id=_gen_id("ev"), actor=actor, action=action, entity_type="user", entity_id=user.id, summary=user.username, detail=detail, )) def authenticate_operator() -> str: """Prompt for a domain credential, bind, and return the operator's sAMAccountName. Exits on failure — a command that changes a role must not proceed unauthenticated. The password is only ever read from a hidden prompt: there is no --password flag, because that would put a live domain password into shell history and into the output of `ps` for every other user on the box. """ if not ldap_auth.is_configured(): sys.exit(f"Cannot authenticate: {ldap_auth.describe()}\n" f"This command needs a domain bind. Fix the LDAP configuration first.") who = input("Your domain username: ").strip() if not who: sys.exit("Cancelled.") pw = getpass.getpass("Your domain password: ") if not pw: sys.exit("Cancelled.") # required_group="" on purpose: see the module docstring. The login group must # not be able to lock an operator out of the tool that fixes the login group. result = ldap_auth.verify(who, pw, required_group="") if not result.ok: # SAY WHY. The /api/auth/login endpoint deliberately returns one generic # message so an unauthenticated caller cannot enumerate accounts; that # reasoning does NOT transfer here. This is a local tool, the operator is # the account holder, and there is nobody to leak to — so withholding the # AD sub-code only makes a failure undiagnosable. An earlier version of # this function printed "Authentication failed." and nothing else. print(f"Authentication failed: {result.reason} — {result.detail}", file=sys.stderr) print(f" bind attempted as : {ldap_auth.normalize_username(who)}@{ldap_auth.DOMAIN}", file=sys.stderr) print(f" server : ldaps://{ldap_auth.HOST}:{ldap_auth.PORT}", file=sys.stderr) print(f" password length : {len(pw)} characters", file=sys.stderr) if result.reason == ldap_auth.BAD_CREDENTIALS: print(" The sub-code above is AD's own reason: 52e = wrong password, " "775 = account locked out, 532 = password expired, " "533 = account disabled, 525 = no such user.", file=sys.stderr) print(" A 525 with a password you know is correct means the BIND NAME is " "wrong, not the password. This binds as @LDAP_DOMAIN, " "which only works where that matches your real UPN suffix — set " "LDAP_DOMAIN to the UPN suffix if yours differs from the AD DNS name.", file=sys.stderr) sys.exit(1) print(f"Authenticated as {result.sam}.") return result.sam def _load(db, username: str) -> "models.User": u = auth.find_user(db, username) if not u: sys.exit(f"No account named '{username}'. Accounts are created on first " f"sign-in — has this person signed in yet? `list` shows who exists.") return u def cmd_list(args) -> None: """Read-only, and deliberately needs no credential: during an outage this is how you find out what the app thinks the world looks like.""" with SessionLocal() as db: rows = db.query(models.User).order_by(models.User.username).all() if not rows: print("No accounts yet. They are created on first successful sign-in.") return print(f"{'USERNAME':<24}{'ROLE':<20}{'ACTIVE':<8}{'LAST LOGIN':<22}{'NAME'}") for u in rows: last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never" print(f"{u.username:<24}{auth.normalize_role(u.role):<20}" f"{('yes' if u.is_active else 'no'):<8}{last:<22}{u.full_name}") def _set_role(username: str, role: str) -> None: if role not in auth.ROLES: sys.exit(f"role must be one of {', '.join(auth.ROLES)}") operator = authenticate_operator() with SessionLocal() as db: u = _load(db, username) old = auth.normalize_role(u.role) if old == role: print(f"{u.username} is already {role}. Nothing to do.") return # The last-admin guard, matching set_user_role in app.py: an app with no # admin cannot be administered, and there is no password login left to # recover through. if old == auth.ROLE_ADMIN and role != auth.ROLE_ADMIN: others = db.query(models.User).filter( models.User.role == auth.ROLE_ADMIN, models.User.id != u.id, models.User.is_active.is_(True), ).count() if not others: sys.exit("Refusing: that is the last active admin account. Promote " "someone else first.") detail = {"from": old, "to": role, "via": "manage_users"} u.role = role # Mirrors set_user_role: an admin already reaches every project, so the # default-member flag would sit there doing nothing and spring back to life # on demotion. if role == auth.ROLE_ADMIN and (u.auto_add_projects or u.auto_add_role): u.auto_add_projects = False u.auto_add_role = "" detail["auto_add_cleared"] = True # NOTE: unlike the API, this permits changing your OWN role. The endpoint # forbids it to stop an admin locking themselves out of the console; here it # is the entire bootstrap path — sign in, then promote yourself. if u.username.lower() == operator.lower(): detail["self"] = True _audit(db, operator, "role_changed", u, detail) db.commit() print(f"{u.username}: {old} -> {role}") def cmd_promote(args) -> None: _set_role(args.username, args.role) def cmd_demote(args) -> None: _set_role(args.username, auth.ROLE_PROJECT_USER) def _set_active(username: str, active: bool) -> None: operator = authenticate_operator() with SessionLocal() as db: u = _load(db, username) if bool(u.is_active) == active: print(f"{u.username} is already {'enabled' if active else 'disabled'}.") return u.is_active = active # Disabling has to take effect on sessions already issued, and role reads go # through the database on every request — but token_version is what get_current_user # checks, so bump it to sign them out now rather than at session expiry. u.token_version = (u.token_version or 0) + 1 _audit(db, operator, "user_active_changed", u, {"is_active": active, "via": "manage_users"}) db.commit() print(f"{u.username} is now {'enabled' if active else 'disabled'}.") def main() -> None: # Ensure tables exist on a fresh local database (SQLite dev). Production owns # its schema through alembic. Base.metadata.create_all(bind=engine) p = argparse.ArgumentParser( prog="manage_users", description="Work Package Suite user management. Accounts are created on " "first sign-in (D13); this assigns roles.") sub = p.add_subparsers(dest="cmd", required=True) sub.add_parser("list", help="list all accounts (no credential needed)") pr = sub.add_parser("promote", help="raise an account's permissions role (needs a domain bind)") pr.add_argument("username", help="the person's sAMAccountName") pr.add_argument("--role", default=auth.ROLE_ADMIN, choices=list(auth.ROLES), help="target role (default: admin)") dm = sub.add_parser("demote", help=f"set an account back to {auth.ROLE_PROJECT_USER}") dm.add_argument("username", help="the person's sAMAccountName") 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 == "list": cmd_list(args) elif args.cmd == "promote": cmd_promote(args) elif args.cmd == "demote": cmd_demote(args) elif args.cmd == "disable": _set_active(args.username, False) elif args.cmd == "enable": _set_active(args.username, True) if __name__ == "__main__": main()