feat: admin CLI for API key management (no admin login) #3

Merged
hzhang merged 1 commits from feat/apikey-admin-cli into master 2026-05-16 22:11:55 +00:00

134
apikey_cli.py Normal file
View File

@@ -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()