From 67a04d67d929337c1f25ef65956f8a40d921b8dd Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 16 May 2026 23:10:31 +0100 Subject: [PATCH] feat: admin CLI for API key management (no admin login) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apikey_cli.py operates directly on the DB (run inside the backend container). Subcommands: create (alias required; reusing an alias renews — same key, validity reset, reactivated, name/roles updated; roles allowlisted; configurable --ttl-days), list (masked keys, --show-keys to reveal), revoke (by --alias or --key). Co-Authored-By: Claude Opus 4.7 (1M context) --- apikey_cli.py | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 apikey_cli.py diff --git a/apikey_cli.py b/apikey_cli.py new file mode 100644 index 0000000..619cb6d --- /dev/null +++ b/apikey_cli.py @@ -0,0 +1,134 @@ +#!/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()