Files
Project-SDE-WP-Suite/server/manage_users.py
Matt Mabrey cc64c88c3e T10.10: audit manage_users.py promote
cmd_promote() changed a user's role with no audit trail, unlike the
identical change from the web Admin Console (set_user_role() ->
log_event(), action "role_changed"). Not a new privilege - anyone
with container-exec access already has DB access directly, D16's own
trust-tier reasoning - but there was no record of who ran it or what
changed.

Now writes an AuditLog row matching set_user_role()'s shape, tagged
via:cli (mirrors JIT provisioning's via:okta_jit) since a container
shell exec carries no signed-in identity to attribute the change to.

Verified against a scratch SQLite db: audit row lands correctly, role
change persists, the no-such-user refusal still exits 1 clean.
2026-09-03 16:27:13 -07:00

124 lines
5.0 KiB
Python

"""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()