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