"""`catlas api-key create|list|revoke` — API keys for rate-limit tiers (the raw key is printed once; only its sha256 hash is stored).""" from __future__ import annotations import hashlib import secrets from datetime import UTC, datetime from typing import Annotated import typer from rich.table import Table from companyatlas.cli import out, run_async from companyatlas.db import execute, fetch_all, fetch_val, transaction from companyatlas.ids import new_id TIERS = ("authenticated", "paid", "internal") keys_app = typer.Typer(name="api-key", help="API keys (X-CA-API-Key) and their rate-limit tiers.", no_args_is_help=True) def _new_raw_key(tier: str) -> str: return f"ca_{tier[:4]}_{secrets.token_urlsafe(32)}" @keys_app.command("create") def create(name: Annotated[str, typer.Argument(help="Human label (customer, service…)")], tier: Annotated[str, typer.Option("--tier", "-t", help="authenticated | paid | internal")] = "authenticated") -> None: """Create a key and print it once.""" if tier not in TIERS: raise typer.BadParameter(f"tier must be one of {', '.join(TIERS)}") raw = _new_raw_key(tier) key_hash = hashlib.sha256(raw.encode("utf-8")).hexdigest() kid = new_id("api_key") async def _insert() -> None: async with transaction() as conn: await execute(conn, "insert into api_keys (id, key_hash, prefix, name, tier) values (:id, :h, :p, :n, :t)", id=kid, h=key_hash, p=raw[:12], n=name, t=tier) run_async(_insert()) out.print(f"[green]created[/] {kid} tier={tier} name={name!r}") out.print("[bold]API key (shown once, store it now):[/]") out.print(raw) @keys_app.command("list") def list_keys(include_revoked: bool = False) -> None: async def _rows(): # type: ignore[no-untyped-def] async with transaction() as conn: extra = "" if include_revoked else " where revoked_at is null" return await fetch_all(conn, f"select id, prefix, name, tier, created_at, last_used_at, request_count, revoked_at from api_keys{extra} order by created_at desc") rows = run_async(_rows()) t = Table("id", "prefix", "name", "tier", "created", "last used", "requests", "revoked") for r in rows: t.add_row(r["id"], r["prefix"] + "…", r["name"], r["tier"], str(r["created_at"])[:19], str(r["last_used_at"] or "")[:19], str(r["request_count"]), str(r["revoked_at"] or "")[:19]) out.print(t) @keys_app.command("revoke") def revoke(key_id: Annotated[str, typer.Argument(help="key id (key_…)")]) -> None: async def _revoke() -> int: async with transaction() as conn: n = await fetch_val(conn, "with u as (update api_keys set revoked_at = :t where id = :id and revoked_at is null returning 1) select count(*) from u", t=datetime.now(UTC), id=key_id) return int(n or 0) n = run_async(_revoke()) out.print("[green]revoked[/]" if n else "[yellow]no active key with that id[/]") def register(app: typer.Typer) -> None: app.add_typer(keys_app, name="api-key") __all__ = ["keys_app", "register"]