Files
ops-warden/src/warden/cli.py
tegwick 42ca370085 feat(bootstrap): WARDEN-WP-0001 initial implementation — 42 tests passing
- LocalCA: ssh-keygen -s signing, keypair generation, cert parsing via ssh-keygen -L
- VaultCA: Vault SSH engine backend via httpx
- Inventory: YAML actor registry with ActorType, principals, TTL policy
- Scorecard: four cert-side compliance checks (prefixes, principals, no expired/stale)
- CLI: sign (cert_command interface), issue, status, scorecard, inventory subcommands
- ops-ssh-wrapper: acquire cert and exec SSH command
- Fix: principal parser stops at section headers containing ':' (Critical Options, Extensions)
- Move WARDEN-WP-0001 workplan from ops-bridge; register repo in state-hub (74df727e)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 13:27:49 +02:00

397 lines
13 KiB
Python

"""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}")