#!/usr/bin/env python3 """Admin CLI for API key management — no HTTP, no admin login. Operates directly on the database (same env as the backend), so it must run where the DB is reachable, e.g. inside the backend container: docker compose exec backend python apikey_cli.py create \ --alias ci-bot --name "CI bot" --roles creator docker compose exec backend python apikey_cli.py list docker compose exec backend python apikey_cli.py revoke --alias ci-bot `create` with an existing --alias renews that key (same key string, validity reset, reactivated, name/roles updated) — matching the HTTP POST /api/apikey behaviour. """ import argparse import secrets import string import sys from datetime import datetime, timedelta, UTC from db import get_db from db.models.APIKey import APIKey # Keep in sync with api.apikey.ALLOWED_API_KEY_ROLES ALLOWED_ROLES = {"admin", "creator", "user"} KEY_TTL_DEFAULT_DAYS = 15 def _gen_key(length=32): alphabet = string.ascii_letters + string.digits return "".join(secrets.choice(alphabet) for _ in range(length)) def _validate_roles(roles): bad = [r for r in roles if r not in ALLOWED_ROLES] if bad: sys.exit(f"error: invalid role(s) {bad}; allowed: {sorted(ALLOWED_ROLES)}") def cmd_create(args): alias = args.alias.strip() if not alias: sys.exit("error: --alias is required") roles = args.roles or [] _validate_roles(roles) expire = datetime.now(UTC) + timedelta(days=args.ttl_days) with get_db() as session: existing = session.query(APIKey).filter_by(alias=alias).first() if existing is not None: existing.name = args.name existing.roles = roles existing.is_active = True existing.expire = expire session.commit() row, renewed = existing.to_dict(), True else: ak = APIKey( key=_gen_key(), alias=alias, name=args.name, roles=roles, expire=expire, ) session.add(ak) session.commit() row, renewed = ak.to_dict(), False print("renewed" if renewed else "created") print(f" alias : {row['alias']}") print(f" name : {row['name']}") print(f" roles : {row['roles']}") print(f" expire: {row['expire']}") print(f" key : {row['key']}") def cmd_list(args): with get_db() as session: keys = session.query(APIKey).order_by(APIKey.created_at).all() rows = [k.to_dict() for k in keys] if not rows: print("(no API keys)") return for r in rows: key = r["key"] if args.show_keys else (r["key"][:6] + "…") state = "active" if r["is_active"] else "revoked" print( f"{r['alias']!r:<22} {state:<8} roles={r['roles']} " f"expire={r['expire']} last_used={r['last_used_at']} " f"name={r['name']!r} key={key}" ) def cmd_revoke(args): with get_db() as session: q = session.query(APIKey) ak = (q.filter_by(alias=args.alias).first() if args.alias else q.filter_by(key=args.key).first()) if ak is None: sys.exit("error: API key not found") ak.is_active = False session.commit() print(f"revoked: alias={ak.alias} name={ak.name!r}") def main(): p = argparse.ArgumentParser(prog="apikey_cli", description=__doc__) sub = p.add_subparsers(dest="cmd", required=True) c = sub.add_parser("create", help="create or renew (by alias) an API key") c.add_argument("--alias", required=True, help="unique alias; reuse to renew") c.add_argument("--name", required=True, help="human-readable name") c.add_argument("--roles", nargs="*", default=[], help=f"subset of {sorted(ALLOWED_ROLES)}") c.add_argument("--ttl-days", type=int, default=KEY_TTL_DEFAULT_DAYS, dest="ttl_days", help="validity window in days") c.set_defaults(func=cmd_create) l = sub.add_parser("list", help="list all API keys") l.add_argument("--show-keys", action="store_true", help="print full key strings (default: masked)") l.set_defaults(func=cmd_list) r = sub.add_parser("revoke", help="deactivate an API key") g = r.add_mutually_exclusive_group(required=True) g.add_argument("--alias", help="revoke by alias") g.add_argument("--key", help="revoke by key string") r.set_defaults(func=cmd_revoke) args = p.parse_args() args.func(args) if __name__ == "__main__": main()