"""OpsWarden CLI.""" from __future__ import annotations import json from datetime import datetime, timezone from pathlib import Path from typing import Annotated, List, Optional import typer from rich.console import Console from rich.table import Table from warden.ca import CAError, LocalCA, parse_cert_metadata from warden.config import ConfigError, WardenConfig, load_config from warden.inventory import ActorEntry, InventoryError, PrincipalsInventory, load_inventory, save_inventory from warden.models import ActorType, CertSpec, DEFAULT_TTL_HOURS, validate_actor_name from warden.scorecard import run_scorecard app = typer.Typer( help="OpsWarden — SSH CA and certificate lifecycle manager", no_args_is_help=True, ) inventory_app = typer.Typer(help="Manage principals inventory", no_args_is_help=True) app.add_typer(inventory_app, name="inventory") console = Console() err = Console(stderr=True) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _load_cfg() -> WardenConfig: try: return load_config() except ConfigError as e: err.print(f"[red]Config error:[/red] {e}") raise typer.Exit(1) def _load_inventory(cfg: WardenConfig) -> PrincipalsInventory: try: return load_inventory(cfg.inventory_path) except InventoryError as e: err.print(f"[red]Inventory error:[/red] {e}") raise typer.Exit(1) def _get_ca(cfg: WardenConfig): if cfg.backend == "vault": from warden.vault import VaultCA return VaultCA(cfg.vault, cfg.state_dir) return LocalCA(cfg.ca_key, cfg.state_dir) # --------------------------------------------------------------------------- # warden sign # --------------------------------------------------------------------------- @app.command() def sign( actor_name: Annotated[str, typer.Argument(help="Actor name (e.g. agt-state-hub-bridge)")], pubkey: Annotated[Path, typer.Option("--pubkey", help="Path to actor's public key file")], ttl: Annotated[Optional[int], typer.Option("--ttl", help="Override TTL in hours")] = None, ) -> None: """Sign a public key for the given actor. Writes cert text to stdout. This is the cert_command interface: ops-bridge calls this and uses stdout as the certificate passed to SSH alongside the private key. """ cfg = _load_cfg() inventory = _load_inventory(cfg) entry = inventory.actors.get(actor_name) if entry is None: err.print( f"[red]Actor {actor_name!r} not found in inventory.[/red] " f"Add it with: warden inventory add" ) raise typer.Exit(1) spec = CertSpec( actor_name=actor_name, actor_type=entry.actor_type, pubkey_path=pubkey, ttl_hours=ttl or entry.ttl_hours, principals=entry.principals, identity=actor_name, ) ca = _get_ca(cfg) try: record = ca.sign(spec) except CAError as e: err.print(f"[red]Signing failed:[/red] {e}") raise typer.Exit(1) # cert_command interface: write cert text to stdout only print(record.cert_path.read_text().strip()) # --------------------------------------------------------------------------- # warden issue # --------------------------------------------------------------------------- @app.command() def issue( actor_name: Annotated[str, typer.Argument(help="Actor name")], ttl: Annotated[Optional[int], typer.Option("--ttl", help="Override TTL in hours")] = None, output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False, ) -> None: """Generate a new keypair and sign it for the given actor. Only supported with the local backend. Outputs keypair + cert paths and metadata. """ cfg = _load_cfg() if cfg.backend != "local": err.print("[red]warden issue is only supported with the local backend.[/red]") raise typer.Exit(1) inventory = _load_inventory(cfg) entry = inventory.actors.get(actor_name) if entry is None: err.print(f"[red]Actor {actor_name!r} not found in inventory.[/red]") raise typer.Exit(1) ca = LocalCA(cfg.ca_key, cfg.state_dir) try: privkey_path, pubkey_path = ca.generate_keypair(actor_name) except CAError as e: err.print(f"[red]Key generation failed:[/red] {e}") raise typer.Exit(1) spec = CertSpec( actor_name=actor_name, actor_type=entry.actor_type, pubkey_path=pubkey_path, ttl_hours=ttl or entry.ttl_hours, principals=entry.principals, identity=actor_name, ) try: record = ca.sign(spec) except CAError as e: err.print(f"[red]Signing failed:[/red] {e}") raise typer.Exit(1) result = { "actor": actor_name, "privkey": str(privkey_path), "cert": str(record.cert_path), "identity": record.identity, "principals": record.principals, "valid_before": record.valid_before.isoformat(), "signed_at": record.signed_at.isoformat(), } if output_json: print(json.dumps(result, indent=2)) else: console.print(f"[green]Issued credentials for {actor_name}[/green]") for k, v in result.items(): console.print(f" {k}: {v}") # --------------------------------------------------------------------------- # warden status # --------------------------------------------------------------------------- @app.command() def status( actor_name: Annotated[Optional[str], typer.Argument(help="Actor name (omit for all)")] = None, output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False, ) -> None: """Show certificate status. Exits 1 if any cert is expired.""" cfg = _load_cfg() now = datetime.now(timezone.utc) if actor_name: cert_path = cfg.state_dir / f"{actor_name}-cert.pub" paths = [cert_path] if cert_path.exists() else [] else: paths = sorted(cfg.state_dir.glob("*-cert.pub")) if cfg.state_dir.exists() else [] if not paths: msg = ( f"No certificate found for {actor_name!r} (static key / no cert)" if actor_name else "No certificates in state dir." ) console.print(msg) return rows = [] for cert_path in paths: name = cert_path.stem.replace("-cert", "") try: meta = parse_cert_metadata(cert_path) valid_before = meta["valid_before"] remaining = valid_before - now secs = remaining.total_seconds() if secs > 0: h, rem = divmod(int(secs), 3600) m = rem // 60 remaining_str = f"{h}h {m}m" expired = False else: remaining_str = "EXPIRED" expired = True rows.append({ "actor": name, "identity": meta["identity"], "principals": ", ".join(meta["principals"]), "valid_before": valid_before.isoformat(), "remaining": remaining_str, "expired": expired, }) except Exception as e: rows.append({"actor": name, "error": str(e), "expired": False}) if output_json: print(json.dumps(rows, indent=2)) else: table = Table(title="Certificate Status") table.add_column("Actor") table.add_column("Identity") table.add_column("Principals") table.add_column("Valid Before (UTC)") table.add_column("Remaining") for row in rows: if "error" in row: table.add_row(row["actor"], "[red]parse error[/red]", "", "", row["error"]) else: rem_styled = ( f"[red]{row['remaining']}[/red]" if row["expired"] else row["remaining"] ) table.add_row( row["actor"], row["identity"], row["principals"], row["valid_before"], rem_styled, ) console.print(table) if any(r.get("expired") for r in rows): raise typer.Exit(1) # --------------------------------------------------------------------------- # warden scorecard # --------------------------------------------------------------------------- @app.command() def scorecard( output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False, ) -> None: """Run compliance scorecard checks (AccessManagementDirective §5, cert-side).""" cfg = _load_cfg() inventory = _load_inventory(cfg) results = run_scorecard(cfg.state_dir, inventory) passed = sum(1 for r in results if r.passed) total = len(results) if output_json: print(json.dumps( [{"check": r.name, "passed": r.passed, "detail": r.detail} for r in results], indent=2, )) else: table = Table(title=f"OpsWarden Scorecard ({passed}/{total})") table.add_column("Check") table.add_column("Status") table.add_column("Detail") for r in results: status_str = "[green]PASS[/green]" if r.passed else "[red]FAIL[/red]" table.add_row(r.name, status_str, r.detail) console.print(table) console.print( f"\nScore: {passed}/{total} " + ("[green]Operational[/green]" if passed == total else "[yellow]Needs attention[/yellow]") ) if passed < total: raise typer.Exit(1) # --------------------------------------------------------------------------- # warden inventory # --------------------------------------------------------------------------- @inventory_app.command("list") def inventory_list( output_json: Annotated[bool, typer.Option("--json")] = False, ) -> None: """List all actors in the principals inventory.""" cfg = _load_cfg() inventory = _load_inventory(cfg) if not inventory.actors: console.print("No actors in inventory.") return if output_json: print(json.dumps({ name: { "type": e.actor_type.value, "principals": e.principals, "ttl_hours": e.ttl_hours, "description": e.description, } for name, e in inventory.actors.items() }, indent=2)) return table = Table(title=f"Principals Inventory ({cfg.inventory_path})") table.add_column("Actor") table.add_column("Type") table.add_column("Principals") table.add_column("TTL (h)") table.add_column("Description") for name, e in inventory.actors.items(): table.add_row( name, e.actor_type.value, ", ".join(e.principals), str(e.ttl_hours), e.description, ) console.print(table) @inventory_app.command("add") def inventory_add( actor_name: Annotated[str, typer.Argument(help="Actor name (e.g. agt-state-hub-bridge)")], actor_type: Annotated[ActorType, typer.Option("--type", "-t", help="adm | agt | atm")], principals: Annotated[ Optional[List[str]], typer.Option("--principal", "-p", help="Principal (repeat for multiple)"), ] = None, ttl: Annotated[Optional[int], typer.Option("--ttl", help="TTL in hours")] = None, description: Annotated[str, typer.Option("--description", "-d")] = "", ) -> None: """Add an actor to the principals inventory.""" cfg = _load_cfg() try: validate_actor_name(actor_name, actor_type) except ValueError as e: err.print(f"[red]{e}[/red]") raise typer.Exit(1) resolved_principals: List[str] = principals or [actor_name] inventory = _load_inventory(cfg) inventory.actors[actor_name] = ActorEntry( name=actor_name, actor_type=actor_type, principals=resolved_principals, ttl_hours=ttl or DEFAULT_TTL_HOURS[actor_type], description=description, ) try: save_inventory(inventory, cfg.inventory_path) except Exception as e: err.print(f"[red]Failed to save inventory:[/red] {e}") raise typer.Exit(1) console.print( f"[green]Added[/green] {actor_name} " f"(type={actor_type.value}, principals={resolved_principals}, ttl={ttl or DEFAULT_TTL_HOURS[actor_type]}h)" ) @inventory_app.command("remove") def inventory_remove( actor_name: Annotated[str, typer.Argument(help="Actor name to remove")], ) -> None: """Remove an actor from the principals inventory.""" cfg = _load_cfg() inventory = _load_inventory(cfg) if actor_name not in inventory.actors: err.print(f"[red]Actor {actor_name!r} not in inventory.[/red]") raise typer.Exit(1) del inventory.actors[actor_name] try: save_inventory(inventory, cfg.inventory_path) except Exception as e: err.print(f"[red]Failed to save inventory:[/red] {e}") raise typer.Exit(1) console.print(f"[green]Removed[/green] {actor_name}")