Files
Project-SDE-WP-Suite/server/manage_users.py
Cody Schaefer 8d49fb9248 T10.9 fix - the CLI's auth failure said nothing useful
"Authentication failed." and no more. I had carried the generic-message
reasoning across from /api/auth/login, where one indistinguishable failure is
correct because an unauthenticated caller must not be able to enumerate
accounts. That reasoning does not transfer to a local CLI: the operator IS the
account holder, there is nobody to leak to, and withholding AD's own sub-code
just makes the failure undiagnosable. It cost a round trip to find that out.

Now prints the reason, the AD error-49 sub-code, the exact bind string
attempted, the server, and the password length - enough to tell apart a wrong
password (52e), a locked account (775), an expired one (532), and the case that
looks like a bad password but is not: 525, no such user, which means the BIND
NAME is wrong rather than the credential.

That last one matters here. The bind is <sAMAccountName>@LDAP_DOMAIN, and
LDAP_DOMAIN defaults to the AD DNS name (prime.local). If an estate's UPN
suffix is the mail domain instead, that string is not a valid bind name and AD
answers 525 - which reads as "bad password" to anyone not looking at sub-codes.
The hint says so, and says to set LDAP_DOMAIN to the UPN suffix.

The password is never echoed or logged; only its length, which distinguishes
"getpass read nothing" from "getpass read the wrong thing".

/api/auth/login is untouched. Its generic 401 is still correct.

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

243 lines
11 KiB
Python

"""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 <your-sAMAccountName>` 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 <sAMAccountName>@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()