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.
124 lines
5.0 KiB
Python
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()
|