"""Manage API keys for external partner access to the URAAS dashboard API (e.g. Africa PID Alliance / DOCiD reading papers/analytics endpoints server-to-server, instead of using the browser session-cookie login). The plaintext key is shown exactly once, at creation — it is never stored; only its SHA-256 hash is, so a stolen database dump can't be used to authenticate as a partner. If a key is lost, revoke it and create a new one. Usage: python scripts/manage_api_keys.py create --name "Africa PID Alliance / DOCiD" python scripts/manage_api_keys.py list python scripts/manage_api_keys.py revoke --prefix uraas_live_AbCd """ import argparse import hashlib import os import secrets import sys from datetime import datetime sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from uraas.database import ApiKey, SessionLocal _PREFIX = "uraas_live_" def _hash(key: str) -> str: return hashlib.sha256(key.encode("utf-8")).hexdigest() def cmd_create(args): raw = _PREFIX + secrets.token_urlsafe(32) session = SessionLocal() try: row = ApiKey( name=args.name, key_hash=_hash(raw), key_prefix=raw[: len(_PREFIX) + 8], scope="read", created_by=args.created_by or "cli", ) session.add(row) session.commit() print("Created API key — this is the ONLY time the full value is shown:\n") print(f" {raw}\n") print(f"Name: {row.name}") print(f"Prefix: {row.key_prefix} (safe to log/reference later)") print(f"Scope: {row.scope}") print("\nGive the partner this header format:") print(f' X-API-Key: {raw}') finally: session.close() def cmd_list(args): session = SessionLocal() try: rows = session.query(ApiKey).order_by(ApiKey.created_at.desc()).all() if not rows: print("No API keys yet.") return for r in rows: status = "REVOKED" if r.revoked else "active" last_used = r.last_used_at.isoformat() if r.last_used_at else "never" print(f"[{r.id}] {r.key_prefix}... {r.name!r} scope={r.scope} " f"status={status} created={r.created_at} last_used={last_used}") finally: session.close() def cmd_revoke(args): session = SessionLocal() try: q = session.query(ApiKey) row = None if args.id: row = q.filter(ApiKey.id == args.id).first() elif args.prefix: row = q.filter(ApiKey.key_prefix == args.prefix).first() if not row: print("No matching key found.") return row.revoked = True row.revoked_at = datetime.utcnow() session.commit() print(f"Revoked key [{row.id}] {row.key_prefix}... ({row.name!r})") finally: session.close() def main(): parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="command", required=True) p_create = sub.add_parser("create", help="Issue a new partner API key") p_create.add_argument("--name", required=True, help="Partner label, e.g. 'Africa PID Alliance / DOCiD'") p_create.add_argument("--created-by", default=None) p_create.set_defaults(func=cmd_create) p_list = sub.add_parser("list", help="List all keys (never shows the full value)") p_list.set_defaults(func=cmd_list) p_revoke = sub.add_parser("revoke", help="Revoke a key") p_revoke.add_argument("--id", type=int, default=None) p_revoke.add_argument("--prefix", default=None) p_revoke.set_defaults(func=cmd_revoke) args = parser.parse_args() if args.command == "revoke" and not (args.id or args.prefix): parser.error("revoke requires --id or --prefix") args.func(args) if __name__ == "__main__": main()