"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>
243 lines
11 KiB
Python
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()
|