Brings the Work Package Suite from a browser-local prototype to a multi-tenant, SQL-backed deployment hardened for customer IP. Auth & access control - Local username/password login (bcrypt + JWT in an HttpOnly cookie), admin-managed users, per-project membership, and project-scoped API access. - Admin console: change user roles, view the audit trail, manage settings. Security hardening - CSP / HSTS / X-Frame-Options / nosniff headers in nginx; Secure cookie via X-Forwarded-Proto; CSRF Origin check; attribute-safe output escaping. - Login lockout, token_version session revocation, stronger password policy, fail-closed secret loading, encrypted (AES-256) database backups. Persistence & schema - SOPs and Work Packages are now DB-backed and shared across users, written through a durable client sync outbox that queues offline edits. - Alembic migrations applied automatically on container start. New capabilities - Phase 2 dashboard (progress, gating, pagination, archive). - Phase 3 PWA "Field View" with offline caching and auth fallback. - WP owner assignment with OPTIONAL email notifications, OFF by default and toggled from the admin console. SMTP password is read only from the SMTP_PASSWORD env var (never stored); emails carry a WP number + deep link, never customer IP. Also: IBM Carbon restyle, Help section, and DEPLOYMENT.md brought up to date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
146 lines
4.9 KiB
Python
146 lines
4.9 KiB
Python
"""Command-line user management for the Work Package Suite.
|
|
|
|
Use this to create the FIRST admin account (the /api/auth/users endpoint needs an
|
|
existing admin, so you have to bootstrap one here), and for occasional account
|
|
maintenance from a shell on the server.
|
|
|
|
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 create-admin alice --name "Alice Smith"
|
|
python -m server.manage_users create bob --role user --name "Bob Jones"
|
|
python -m server.manage_users list
|
|
python -m server.manage_users reset-password alice
|
|
python -m server.manage_users disable bob
|
|
python -m server.manage_users enable bob
|
|
|
|
If --password is omitted you'll be prompted (input is hidden). Passwords must be
|
|
at least 8 characters.
|
|
"""
|
|
import argparse
|
|
import getpass
|
|
import sys
|
|
import uuid
|
|
|
|
from .db import SessionLocal, Base, engine
|
|
from . import models, auth
|
|
|
|
|
|
def _gen_id() -> str:
|
|
return f"user_{uuid.uuid4().hex[:12]}"
|
|
|
|
|
|
def _prompt_password(provided: str | None, username: str = "") -> str:
|
|
pw = provided
|
|
if not pw:
|
|
pw = getpass.getpass("New password: ")
|
|
confirm = getpass.getpass("Confirm password: ")
|
|
if pw != confirm:
|
|
sys.exit("Passwords do not match.")
|
|
problem = auth.password_problem(pw, username)
|
|
if problem:
|
|
sys.exit(problem)
|
|
return pw
|
|
|
|
|
|
def cmd_create(args, role: str | None = None) -> None:
|
|
role = role or args.role
|
|
if role not in ("admin", "user"):
|
|
sys.exit("role must be 'admin' or 'user'")
|
|
pw = _prompt_password(getattr(args, "password", None), args.username)
|
|
with SessionLocal() as db:
|
|
if auth.find_user(db, args.username):
|
|
sys.exit(f"A user named '{args.username}' already exists.")
|
|
u = models.User(
|
|
id=_gen_id(),
|
|
username=args.username.strip(),
|
|
full_name=(args.name or "").strip(),
|
|
email=(args.email or "").strip(),
|
|
password_hash=auth.hash_password(pw),
|
|
role=role,
|
|
)
|
|
db.add(u)
|
|
db.commit()
|
|
print(f"Created {role}: {u.username} (id={u.id})")
|
|
|
|
|
|
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. Create one with: create-admin <username>")
|
|
return
|
|
print(f"{'USERNAME':<24}{'ROLE':<8}{'ACTIVE':<8}{'NAME'}")
|
|
for u in rows:
|
|
print(f"{u.username:<24}{u.role:<8}{('yes' if u.is_active else 'no'):<8}{u.full_name}")
|
|
|
|
|
|
def cmd_reset_password(args) -> None:
|
|
pw = _prompt_password(getattr(args, "password", None), args.username)
|
|
with SessionLocal() as db:
|
|
u = auth.find_user(db, args.username)
|
|
if not u:
|
|
sys.exit(f"No user named '{args.username}'.")
|
|
u.password_hash = auth.hash_password(pw)
|
|
db.commit()
|
|
print(f"Password reset for {u.username}.")
|
|
|
|
|
|
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)
|
|
|
|
def add_create(name, help_):
|
|
sp = sub.add_parser(name, help=help_)
|
|
sp.add_argument("username")
|
|
sp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
|
sp.add_argument("--name", default="", help="full name")
|
|
sp.add_argument("--email", default="")
|
|
return sp
|
|
|
|
add_create("create-admin", "create an admin account")
|
|
c = add_create("create", "create an account")
|
|
c.add_argument("--role", choices=["admin", "user"], default="user")
|
|
|
|
sub.add_parser("list", help="list all accounts")
|
|
|
|
rp = sub.add_parser("reset-password", help="reset a user's password")
|
|
rp.add_argument("username")
|
|
rp.add_argument("--password", help="set non-interactively (otherwise prompted)")
|
|
|
|
dp = sub.add_parser("disable", help="disable an account (blocks login)")
|
|
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 == "create-admin":
|
|
cmd_create(args, role="admin")
|
|
elif args.cmd == "create":
|
|
cmd_create(args)
|
|
elif args.cmd == "list":
|
|
cmd_list(args)
|
|
elif args.cmd == "reset-password":
|
|
cmd_reset_password(args)
|
|
elif args.cmd == "disable":
|
|
_set_active(args.username, False)
|
|
elif args.cmd == "enable":
|
|
_set_active(args.username, True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|