"""Provision reviewer accounts into the store (#128). The write endpoints authenticate a per-user login, so a deployment needs accounts before anyone can confirm a value. Two ways to supply them, both idempotent (a re-run rotates a password or role in place): # From the ENDOPATH_SEED_USERS secret (the same JSON the Space seeds from at startup): ENDOPATH_SEED_USERS='[{"username":"apath","password":"...","role":"pathologist","holds_licence":true, "display_name":"A. Pathologist"}]' \ PYTHONPATH=src python scripts/seed_users.py # Or one account from flags. The password comes from ENDOPATH_SEED_PASSWORD or an interactive prompt, # never a flag, so it does not land in shell history or the process listing: ENDOPATH_SEED_PASSWORD=... PYTHONPATH=src python scripts/seed_users.py \ --username apath --role pathologist --licence --display-name "A. Pathologist" Writes to SQLite by default and to Postgres when DATABASE_URL is set (the deployed store), the same resolution storage.connect uses. The plaintext password is hashed once and never stored. """ from __future__ import annotations import argparse import getpass import os from endopath import auth, storage # The single-account path reads the password from here (or an interactive prompt), never a --password # flag, so a real credential never enters shell history or the process listing. SEED_PASSWORD_ENV = "ENDOPATH_SEED_PASSWORD" def main() -> None: parser = argparse.ArgumentParser(description="Provision reviewer accounts (#128).") parser.add_argument("--username") parser.add_argument("--role", default="pathologist") parser.add_argument( "--licence", action="store_true", help="the account holds a pathology licence (may confirm licence-required fields)", ) parser.add_argument("--display-name", default="") args = parser.parse_args() if args.username: password = os.environ.get(SEED_PASSWORD_ENV) or getpass.getpass( f"password for {args.username!r}: " ) seeds = [ auth.SeedUser( username=args.username, password=password, role=args.role, holds_licence=args.licence, display_name=args.display_name, ) ] else: seeds = auth.parse_seed_users(os.environ.get(auth.SEED_USERS_ENV, "")) if not seeds: parser.error( f"no accounts to seed: pass --username (with {SEED_PASSWORD_ENV} or a prompt), " f"or set {auth.SEED_USERS_ENV} to a JSON list" ) conn = storage.connect() try: for seed in seeds: if not seed.password: parser.error(f"account {seed.username!r} has no password") storage.upsert_user( conn, username=seed.username, password_hash=auth.hash_password(seed.password), role=seed.role, holds_licence=seed.holds_licence, display_name=seed.display_name, ) print( f"provisioned {seed.username!r} " f"(role={seed.role}, licence={'yes' if seed.holds_licence else 'no'})" ) print(f"{storage.count_users(conn)} account(s) total") finally: conn.close() if __name__ == "__main__": main()