feat(auth): operator admin/break-glass CLI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Operator-account admin CLI — the bootstrap and the break-glass. Run inside the
|
||||
terra-view container against the live DB. Temp/raw passwords are printed ONCE; only
|
||||
hashes persist.
|
||||
|
||||
# first superadmin (before any UI is reachable) — prompts for a password, or --generate
|
||||
python3 backend/operator_admin.py create-superadmin --email you@x.com --name "Brian"
|
||||
|
||||
# a parent's account — generates a temp password, must-change on first login
|
||||
python3 backend/operator_admin.py create-user --email dad@x.com --name "Dad" --role admin
|
||||
|
||||
python3 backend/operator_admin.py reset-password --email dad@x.com
|
||||
python3 backend/operator_admin.py list
|
||||
python3 backend/operator_admin.py disable --email dad@x.com
|
||||
python3 backend/operator_admin.py enable --email dad@x.com
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import getpass
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import OperatorUser
|
||||
from backend.operator_auth import (
|
||||
create_operator, reset_operator_password, set_operator_active, _norm_email,
|
||||
)
|
||||
|
||||
|
||||
def _get(db, email):
|
||||
u = db.query(OperatorUser).filter_by(email=_norm_email(email)).first()
|
||||
if not u:
|
||||
sys.exit(f"No operator with email '{email}'.")
|
||||
return u
|
||||
|
||||
|
||||
def cmd_create_superadmin(email, name, password=None, generate=False):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
if password is None and not generate:
|
||||
password = getpass.getpass("Password for new superadmin: ")
|
||||
if not password or len(password) < 8:
|
||||
sys.exit("Password must be at least 8 characters.")
|
||||
user, raw = create_operator(db, email, name, "superadmin",
|
||||
password=None if generate else password)
|
||||
if generate:
|
||||
print(f"✓ Superadmin {user.email} created. Temp password (shown once): {raw}")
|
||||
else:
|
||||
print(f"✓ Superadmin {user.email} created.")
|
||||
except ValueError as e:
|
||||
sys.exit(str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_create_user(email, name, role="admin"):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user, raw = create_operator(db, email, name, role)
|
||||
print(f"✓ {role} {user.email} created. Temp password (shown once): {raw}")
|
||||
print(" They'll be required to change it on first login.")
|
||||
except ValueError as e:
|
||||
sys.exit(str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_reset_password(email):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = _get(db, email)
|
||||
raw = reset_operator_password(db, user)
|
||||
print(f"✓ Reset {user.email}. Temp password (shown once): {raw}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_set_active(email, active):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
user = _get(db, email)
|
||||
set_operator_active(db, user, active)
|
||||
print(f"✓ {user.email} {'enabled' if active else 'disabled'}.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_list():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
users = db.query(OperatorUser).order_by(OperatorUser.display_name).all()
|
||||
if not users:
|
||||
print("No operators yet. Run create-superadmin first.")
|
||||
return
|
||||
for u in users:
|
||||
locked = " [LOCKED]" if (u.locked_until and u.locked_until > datetime.utcnow()) else ""
|
||||
state = "active" if u.active else "DISABLED"
|
||||
last = u.last_login_at.strftime("%Y-%m-%d %H:%M") if u.last_login_at else "never"
|
||||
print(f" {u.display_name:<12} {u.email:<28} {u.role:<11} {state}{locked} last: {last}")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Operator-account admin")
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p = sub.add_parser("create-superadmin")
|
||||
p.add_argument("--email", required=True); p.add_argument("--name", required=True)
|
||||
p.add_argument("--generate", action="store_true", help="generate a temp password instead of prompting")
|
||||
p.set_defaults(fn=lambda a: cmd_create_superadmin(a.email, a.name, generate=a.generate))
|
||||
|
||||
p = sub.add_parser("create-user")
|
||||
p.add_argument("--email", required=True); p.add_argument("--name", required=True)
|
||||
p.add_argument("--role", default="admin", choices=["admin", "superadmin"])
|
||||
p.set_defaults(fn=lambda a: cmd_create_user(a.email, a.name, a.role))
|
||||
|
||||
p = sub.add_parser("reset-password")
|
||||
p.add_argument("--email", required=True)
|
||||
p.set_defaults(fn=lambda a: cmd_reset_password(a.email))
|
||||
|
||||
p = sub.add_parser("disable"); p.add_argument("--email", required=True)
|
||||
p.set_defaults(fn=lambda a: cmd_set_active(a.email, False))
|
||||
|
||||
p = sub.add_parser("enable"); p.add_argument("--email", required=True)
|
||||
p.set_defaults(fn=lambda a: cmd_set_active(a.email, True))
|
||||
|
||||
p = sub.add_parser("list"); p.set_defaults(fn=lambda a: cmd_list())
|
||||
|
||||
args = ap.parse_args()
|
||||
args.fn(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,44 @@
|
||||
# tests/test_operator_admin_cli.py
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.models import OperatorUser
|
||||
from backend.auth_passwords import verify_password
|
||||
import backend.operator_admin as cli
|
||||
|
||||
|
||||
def _maker(db_session):
|
||||
return sessionmaker(bind=db_session.get_bind(), autocommit=False, autoflush=False)
|
||||
|
||||
|
||||
def test_seed_superadmin(db_session, monkeypatch):
|
||||
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
|
||||
cli.cmd_create_superadmin(email="brian@x.com", name="Brian", password="chosen-pw-1")
|
||||
u = db_session.query(OperatorUser).filter_by(email="brian@x.com").first()
|
||||
assert u.role == "superadmin"
|
||||
assert u.must_change_password is False
|
||||
assert verify_password("chosen-pw-1", u.password_hash)
|
||||
|
||||
|
||||
def test_create_user_generates_temp(db_session, monkeypatch, capsys):
|
||||
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
|
||||
cli.cmd_create_user(email="dad@x.com", name="Dad", role="admin")
|
||||
u = db_session.query(OperatorUser).filter_by(email="dad@x.com").first()
|
||||
assert u.role == "admin" and u.must_change_password is True
|
||||
assert "dad@x.com" in capsys.readouterr().out # prints the temp once
|
||||
|
||||
|
||||
def test_reset_password_cli(db_session, monkeypatch):
|
||||
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
|
||||
cli.cmd_create_user(email="r@x.com", name="R", role="admin")
|
||||
before = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash
|
||||
cli.cmd_reset_password(email="r@x.com")
|
||||
after = db_session.query(OperatorUser).filter_by(email="r@x.com").first().password_hash
|
||||
assert before != after
|
||||
|
||||
|
||||
def test_disable_enable_cli(db_session, monkeypatch):
|
||||
monkeypatch.setattr(cli, "SessionLocal", _maker(db_session), raising=False)
|
||||
cli.cmd_create_user(email="d@x.com", name="D", role="admin")
|
||||
cli.cmd_set_active(email="d@x.com", active=False)
|
||||
assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is False
|
||||
cli.cmd_set_active(email="d@x.com", active=True)
|
||||
assert db_session.query(OperatorUser).filter_by(email="d@x.com").first().active is True
|
||||
Reference in New Issue
Block a user