generated from coulomb/repo-seed
Initial Commit
This commit is contained in:
3
src/warden/__init__.py
Normal file
3
src/warden/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""OpsWarden — SSH CA and certificate lifecycle manager."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
164
src/warden/ca.py
Normal file
164
src/warden/ca.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""CA backends for OpsWarden: LocalCA (ssh-keygen) and abstract base."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from warden.models import CertRecord, CertSpec
|
||||
|
||||
|
||||
class CAError(Exception):
|
||||
"""Raised when a CA operation fails."""
|
||||
|
||||
|
||||
class CABackend(ABC):
|
||||
@abstractmethod
|
||||
def sign(self, spec: CertSpec) -> CertRecord:
|
||||
"""Sign the public key in spec and return a CertRecord."""
|
||||
...
|
||||
|
||||
|
||||
def parse_cert_metadata(cert_path: Path) -> dict:
|
||||
"""Parse ssh-keygen -L output into identity, valid_before, and principals.
|
||||
|
||||
Note: ssh-keygen displays timestamps without explicit timezone; we treat them
|
||||
as UTC, consistent with how ssh-keygen internally stores certificate validity.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["ssh-keygen", "-L", "-f", str(cert_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise CAError(f"ssh-keygen -L failed: {result.stderr.strip()}")
|
||||
|
||||
identity: Optional[str] = None
|
||||
valid_before: Optional[datetime] = None
|
||||
principals: List[str] = []
|
||||
in_principals = False
|
||||
|
||||
for line in result.stdout.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("Key ID:"):
|
||||
# Key ID: "agt-state-hub-bridge"
|
||||
raw = stripped.split(":", 1)[1].strip()
|
||||
identity = raw.strip('"')
|
||||
elif stripped.startswith("Valid:"):
|
||||
# Valid: from 2026-03-28T10:00:00 to 2026-03-29T10:00:00
|
||||
parts = stripped.split(" to ", 1)
|
||||
if len(parts) == 2:
|
||||
ts_str = parts[1].strip()
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts_str)
|
||||
valid_before = dt.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
pass
|
||||
elif stripped == "Principals:":
|
||||
in_principals = True
|
||||
elif in_principals:
|
||||
if stripped and not stripped.endswith(":") and stripped != "(none)":
|
||||
principals.append(stripped)
|
||||
else:
|
||||
in_principals = False
|
||||
|
||||
if valid_before is None:
|
||||
raise CAError(
|
||||
f"Could not parse valid_before from cert at {cert_path}. "
|
||||
f"Ensure the cert has a valid TTL."
|
||||
)
|
||||
|
||||
return {
|
||||
"identity": identity or "",
|
||||
"valid_before": valid_before,
|
||||
"principals": principals,
|
||||
}
|
||||
|
||||
|
||||
class LocalCA(CABackend):
|
||||
"""File-based CA using ssh-keygen. Requires the CA private key on disk."""
|
||||
|
||||
def __init__(self, ca_key: Path, state_dir: Path) -> None:
|
||||
self._ca_key = Path(os.path.expanduser(str(ca_key)))
|
||||
self._state_dir = Path(os.path.expanduser(str(state_dir)))
|
||||
|
||||
def sign(self, spec: CertSpec) -> CertRecord:
|
||||
"""Sign the public key in spec. Returns a CertRecord; cert saved to state_dir."""
|
||||
pubkey = Path(os.path.expanduser(str(spec.pubkey_path)))
|
||||
if not pubkey.exists():
|
||||
raise CAError(f"Public key not found: {pubkey}")
|
||||
if not self._ca_key.exists():
|
||||
raise CAError(f"CA key not found: {self._ca_key}")
|
||||
|
||||
principals_str = ",".join(spec.principals)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmpdir_path = Path(tmpdir)
|
||||
pubkey_copy = tmpdir_path / "key.pub"
|
||||
shutil.copy2(pubkey, pubkey_copy)
|
||||
# ssh-keygen -s writes cert to <input_stem>-cert.pub
|
||||
cert_path_tmp = tmpdir_path / "key-cert.pub"
|
||||
|
||||
cmd = [
|
||||
"ssh-keygen",
|
||||
"-s", str(self._ca_key),
|
||||
"-I", spec.identity,
|
||||
"-n", principals_str,
|
||||
"-V", f"+{spec.ttl_hours}h",
|
||||
str(pubkey_copy),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise CAError(f"Signing failed: {result.stderr.strip()}")
|
||||
|
||||
if not cert_path_tmp.exists():
|
||||
raise CAError(
|
||||
f"Expected cert not written after signing: {cert_path_tmp}. "
|
||||
f"ssh-keygen stderr: {result.stderr.strip()}"
|
||||
)
|
||||
|
||||
meta = parse_cert_metadata(cert_path_tmp)
|
||||
|
||||
self._state_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self._state_dir / f"{spec.actor_name}-cert.pub"
|
||||
shutil.copy2(cert_path_tmp, dest)
|
||||
|
||||
return CertRecord(
|
||||
identity=meta["identity"] or spec.identity,
|
||||
valid_before=meta["valid_before"],
|
||||
cert_path=dest,
|
||||
signed_at=datetime.now(timezone.utc),
|
||||
principals=meta["principals"],
|
||||
actor_name=spec.actor_name,
|
||||
)
|
||||
|
||||
def generate_keypair(self, actor_name: str) -> tuple[Path, Path]:
|
||||
"""Generate an ed25519 keypair for an actor.
|
||||
|
||||
Returns (privkey_path, pubkey_path). Overwrites existing files.
|
||||
"""
|
||||
key_dir = self._state_dir / "keys"
|
||||
key_dir.mkdir(parents=True, exist_ok=True)
|
||||
privkey = key_dir / f"{actor_name}_ed25519"
|
||||
pubkey = key_dir / f"{actor_name}_ed25519.pub"
|
||||
|
||||
for p in (privkey, pubkey):
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
|
||||
cmd = [
|
||||
"ssh-keygen", "-t", "ed25519",
|
||||
"-f", str(privkey),
|
||||
"-N", "", # no passphrase
|
||||
"-C", actor_name,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise CAError(f"Key generation failed: {result.stderr.strip()}")
|
||||
|
||||
return privkey, pubkey
|
||||
397
src/warden/cli.py
Normal file
397
src/warden/cli.py
Normal file
@@ -0,0 +1,397 @@
|
||||
"""OpsWarden CLI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
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}")
|
||||
114
src/warden/config.py
Normal file
114
src/warden/config.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Config loading for OpsWarden."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""Raised when config is invalid or missing."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class VaultConfig:
|
||||
addr: str
|
||||
role_map: Dict[str, str] # ActorType.value -> vault role name
|
||||
token_env: str = "VAULT_TOKEN" # env var holding the Vault token
|
||||
mount: str = "ssh" # Vault secrets engine mount path
|
||||
|
||||
|
||||
@dataclass
|
||||
class WardenConfig:
|
||||
backend: str # "local" or "vault"
|
||||
ca_key: Optional[Path] = None # required for local backend
|
||||
vault: Optional[VaultConfig] = None # required for vault backend
|
||||
inventory_path: Path = field(
|
||||
default_factory=lambda: Path.home() / ".config" / "warden" / "inventory.yaml"
|
||||
)
|
||||
state_dir: Path = field(
|
||||
default_factory=lambda: Path.home() / ".local" / "state" / "warden"
|
||||
)
|
||||
|
||||
|
||||
def _default_config_path() -> Path:
|
||||
return Path.home() / ".config" / "warden" / "warden.yaml"
|
||||
|
||||
|
||||
def load_config(path: Optional[Path] = None) -> WardenConfig:
|
||||
"""Load and validate warden.yaml. Respects WARDEN_CONFIG env var."""
|
||||
config_path = path or Path(
|
||||
os.environ.get("WARDEN_CONFIG", str(_default_config_path()))
|
||||
)
|
||||
if not config_path.exists():
|
||||
raise ConfigError(f"Config not found: {config_path}")
|
||||
|
||||
try:
|
||||
with config_path.open() as f:
|
||||
raw = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
raise ConfigError(f"Invalid YAML in {config_path}: {e}") from e
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
raise ConfigError("Config must be a YAML mapping")
|
||||
|
||||
backend = str(raw.get("backend", "local"))
|
||||
if backend not in ("local", "vault"):
|
||||
raise ConfigError(
|
||||
f"backend must be 'local' or 'vault', got: {backend!r}"
|
||||
)
|
||||
|
||||
ca_key = None
|
||||
if "ca_key" in raw and raw["ca_key"]:
|
||||
ca_key = Path(os.path.expanduser(str(raw["ca_key"])))
|
||||
|
||||
vault_cfg = None
|
||||
if backend == "vault":
|
||||
v = raw.get("vault") or {}
|
||||
if "addr" not in v:
|
||||
raise ConfigError("vault backend requires vault.addr")
|
||||
role_map = v.get("role_map") or {
|
||||
"adm": "adm-role",
|
||||
"agt": "agt-role",
|
||||
"atm": "atm-role",
|
||||
}
|
||||
vault_cfg = VaultConfig(
|
||||
addr=str(v["addr"]),
|
||||
role_map=dict(role_map),
|
||||
token_env=str(v.get("token_env", "VAULT_TOKEN")),
|
||||
mount=str(v.get("mount", "ssh")),
|
||||
)
|
||||
elif backend == "local" and ca_key is None:
|
||||
raise ConfigError("local backend requires ca_key")
|
||||
|
||||
inventory_path = Path(
|
||||
os.path.expanduser(
|
||||
str(
|
||||
raw.get(
|
||||
"inventory_path",
|
||||
str(Path.home() / ".config" / "warden" / "inventory.yaml"),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
state_dir = Path(
|
||||
os.path.expanduser(
|
||||
str(
|
||||
raw.get(
|
||||
"state_dir",
|
||||
str(Path.home() / ".local" / "state" / "warden"),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return WardenConfig(
|
||||
backend=backend,
|
||||
ca_key=ca_key,
|
||||
vault=vault_cfg,
|
||||
inventory_path=inventory_path,
|
||||
state_dir=state_dir,
|
||||
)
|
||||
108
src/warden/inventory.py
Normal file
108
src/warden/inventory.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Principals inventory — actor registry with type, principals, and TTL policy."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import yaml
|
||||
|
||||
from warden.models import ActorType, DEFAULT_TTL_HOURS, validate_actor_name
|
||||
|
||||
|
||||
class InventoryError(Exception):
|
||||
"""Raised when inventory is invalid."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActorEntry:
|
||||
name: str
|
||||
actor_type: ActorType
|
||||
principals: List[str]
|
||||
ttl_hours: int
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostEntry:
|
||||
name: str
|
||||
allowed_principals: Dict[str, List[str]] # actor_type.value -> [principal, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrincipalsInventory:
|
||||
actors: Dict[str, ActorEntry] = field(default_factory=dict)
|
||||
hosts: Dict[str, HostEntry] = field(default_factory=dict)
|
||||
|
||||
|
||||
def load_inventory(path: Path) -> PrincipalsInventory:
|
||||
"""Load inventory.yaml. Returns empty inventory if path does not exist."""
|
||||
if not path.exists():
|
||||
return PrincipalsInventory()
|
||||
|
||||
try:
|
||||
with path.open() as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError as e:
|
||||
raise InventoryError(f"Invalid YAML in {path}: {e}") from e
|
||||
|
||||
actors: Dict[str, ActorEntry] = {}
|
||||
for name, data in (raw.get("actors") or {}).items():
|
||||
if not isinstance(data, dict):
|
||||
raise InventoryError(f"Actor {name!r} must be a mapping")
|
||||
type_raw = str(data.get("type", ""))
|
||||
try:
|
||||
actor_type = ActorType(type_raw)
|
||||
except ValueError:
|
||||
raise InventoryError(
|
||||
f"Actor {name!r} has invalid type: {type_raw!r}. "
|
||||
f"Must be one of: adm, agt, atm"
|
||||
)
|
||||
try:
|
||||
validate_actor_name(name, actor_type)
|
||||
except ValueError as e:
|
||||
raise InventoryError(str(e)) from e
|
||||
|
||||
ttl = int(data.get("ttl_hours", DEFAULT_TTL_HOURS[actor_type]))
|
||||
principals = list(data.get("principals") or [name])
|
||||
actors[name] = ActorEntry(
|
||||
name=name,
|
||||
actor_type=actor_type,
|
||||
principals=principals,
|
||||
ttl_hours=ttl,
|
||||
description=str(data.get("description", "")),
|
||||
)
|
||||
|
||||
hosts: Dict[str, HostEntry] = {}
|
||||
for hostname, data in (raw.get("hosts") or {}).items():
|
||||
if not isinstance(data, dict):
|
||||
raise InventoryError(f"Host {hostname!r} must be a mapping")
|
||||
hosts[hostname] = HostEntry(
|
||||
name=hostname,
|
||||
allowed_principals=dict(data.get("allowed_principals") or {}),
|
||||
)
|
||||
|
||||
return PrincipalsInventory(actors=actors, hosts=hosts)
|
||||
|
||||
|
||||
def save_inventory(inventory: PrincipalsInventory, path: Path) -> None:
|
||||
"""Write inventory to path, creating parent directories as needed."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
raw: dict = {
|
||||
"actors": {
|
||||
name: {
|
||||
"type": e.actor_type.value,
|
||||
"principals": e.principals,
|
||||
"ttl_hours": e.ttl_hours,
|
||||
**({"description": e.description} if e.description else {}),
|
||||
}
|
||||
for name, e in inventory.actors.items()
|
||||
},
|
||||
}
|
||||
if inventory.hosts:
|
||||
raw["hosts"] = {
|
||||
name: {"allowed_principals": h.allowed_principals}
|
||||
for name, h in inventory.hosts.items()
|
||||
}
|
||||
with path.open("w") as f:
|
||||
yaml.dump(raw, f, default_flow_style=False, sort_keys=False)
|
||||
67
src/warden/models.py
Normal file
67
src/warden/models.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Domain models for OpsWarden."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
class ActorType(str, Enum):
|
||||
ADM = "adm" # human operator
|
||||
AGT = "agt" # LLM-powered autonomous agent
|
||||
ATM = "atm" # deterministic script / pipeline
|
||||
|
||||
|
||||
# Default certificate TTLs per ActorType (AccessManagementDirective §2)
|
||||
DEFAULT_TTL_HOURS: dict[ActorType, int] = {
|
||||
ActorType.ADM: 48,
|
||||
ActorType.AGT: 24,
|
||||
ActorType.ATM: 8,
|
||||
}
|
||||
|
||||
# Required name prefixes per ActorType (directive §2 naming convention)
|
||||
ACTOR_PREFIX: dict[ActorType, str] = {
|
||||
ActorType.ADM: "adm-",
|
||||
ActorType.AGT: "agt-",
|
||||
ActorType.ATM: "atm-",
|
||||
}
|
||||
|
||||
|
||||
def validate_actor_name(name: str, actor_type: ActorType) -> None:
|
||||
"""Raise ValueError if name does not carry the required prefix for actor_type."""
|
||||
prefix = ACTOR_PREFIX[actor_type]
|
||||
if not name.startswith(prefix):
|
||||
raise ValueError(
|
||||
f"Actor name {name!r} must start with {prefix!r} for type {actor_type.value!r}. "
|
||||
f"(AccessManagementDirective §2 naming convention)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CertSpec:
|
||||
"""Signing request passed to a CABackend."""
|
||||
|
||||
actor_name: str
|
||||
actor_type: ActorType
|
||||
pubkey_path: Path
|
||||
ttl_hours: int
|
||||
principals: List[str]
|
||||
identity: str = "" # defaults to actor_name if empty
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.identity:
|
||||
self.identity = self.actor_name
|
||||
|
||||
|
||||
@dataclass
|
||||
class CertRecord:
|
||||
"""Result returned by a CABackend after signing."""
|
||||
|
||||
identity: str
|
||||
valid_before: datetime
|
||||
cert_path: Path
|
||||
signed_at: datetime
|
||||
principals: List[str] = field(default_factory=list)
|
||||
actor_name: str = ""
|
||||
98
src/warden/scorecard.py
Normal file
98
src/warden/scorecard.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""Compliance scorecard — cert-side checks (AccessManagementDirective §5)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from warden.ca import CAError, parse_cert_metadata
|
||||
from warden.inventory import PrincipalsInventory
|
||||
from warden.models import ACTOR_PREFIX, ActorType
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckResult:
|
||||
name: str
|
||||
passed: bool
|
||||
detail: str = ""
|
||||
|
||||
|
||||
def check_actor_name_prefixes(inventory: PrincipalsInventory) -> CheckResult:
|
||||
"""All actor names must carry the prefix matching their type."""
|
||||
violations = []
|
||||
for name, entry in inventory.actors.items():
|
||||
expected = ACTOR_PREFIX[entry.actor_type]
|
||||
if not name.startswith(expected):
|
||||
violations.append(f"{name!r} should start with {expected!r}")
|
||||
return CheckResult(
|
||||
name="actor_name_prefixes",
|
||||
passed=len(violations) == 0,
|
||||
detail=(
|
||||
"; ".join(violations) if violations else "all actor names match prefix convention"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def check_all_actors_have_principals(inventory: PrincipalsInventory) -> CheckResult:
|
||||
"""Every actor in inventory must have at least one principal."""
|
||||
missing = [name for name, e in inventory.actors.items() if not e.principals]
|
||||
return CheckResult(
|
||||
name="actors_have_principals",
|
||||
passed=len(missing) == 0,
|
||||
detail=f"missing principals: {missing}" if missing else "all actors have principals",
|
||||
)
|
||||
|
||||
|
||||
def check_no_expired_certs(state_dir: Path) -> CheckResult:
|
||||
"""No cert in state_dir should be currently expired."""
|
||||
if not state_dir.exists():
|
||||
return CheckResult("no_expired_certs", passed=True, detail="no state dir")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expired = []
|
||||
for cert_path in state_dir.glob("*-cert.pub"):
|
||||
try:
|
||||
meta = parse_cert_metadata(cert_path)
|
||||
except CAError:
|
||||
continue
|
||||
if meta["valid_before"] < now:
|
||||
expired.append(cert_path.stem.replace("-cert", ""))
|
||||
|
||||
return CheckResult(
|
||||
name="no_expired_certs",
|
||||
passed=len(expired) == 0,
|
||||
detail=f"expired: {expired}" if expired else "no expired certs",
|
||||
)
|
||||
|
||||
|
||||
def check_no_stale_certs(state_dir: Path) -> CheckResult:
|
||||
"""Certs expired by more than 5 minutes should have been cleaned up."""
|
||||
if not state_dir.exists():
|
||||
return CheckResult("no_stale_certs", passed=True, detail="no state dir")
|
||||
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||
stale = []
|
||||
for cert_path in state_dir.glob("*-cert.pub"):
|
||||
try:
|
||||
meta = parse_cert_metadata(cert_path)
|
||||
except CAError:
|
||||
continue
|
||||
if meta["valid_before"] < cutoff:
|
||||
stale.append(cert_path.name)
|
||||
|
||||
return CheckResult(
|
||||
name="no_stale_certs",
|
||||
passed=len(stale) == 0,
|
||||
detail=f"stale certs present: {stale}" if stale else "no stale certs",
|
||||
)
|
||||
|
||||
|
||||
def run_scorecard(state_dir: Path, inventory: PrincipalsInventory) -> List[CheckResult]:
|
||||
"""Run all cert-side scorecard checks. Returns list of CheckResult."""
|
||||
return [
|
||||
check_actor_name_prefixes(inventory),
|
||||
check_all_actors_have_principals(inventory),
|
||||
check_no_expired_certs(state_dir),
|
||||
check_no_stale_certs(state_dir),
|
||||
]
|
||||
0
src/warden/scripts/__init__.py
Normal file
0
src/warden/scripts/__init__.py
Normal file
82
src/warden/scripts/ops_ssh_wrapper.py
Normal file
82
src/warden/scripts/ops_ssh_wrapper.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""ops-ssh-wrapper — acquire a warden cert and exec the given SSH command.
|
||||
|
||||
Usage:
|
||||
WARDEN_ACTOR=agt-my-agent SSH_PUBKEY=~/.ssh/agt-my-agent_ed25519.pub \\
|
||||
ops-ssh-wrapper ssh -R 8001:127.0.0.1:8000 agt-my-agent@host
|
||||
|
||||
Environment:
|
||||
WARDEN_ACTOR Actor name in the warden inventory (e.g. agt-state-hub-bridge)
|
||||
SSH_PUBKEY Path to the actor's SSH public key file
|
||||
|
||||
The wrapper requests a fresh cert from warden on every invocation, loads it into
|
||||
ssh-agent, then execs the given command. Equivalent to the pattern in
|
||||
AccessManagementDirective §4.1, hardened for production use.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
actor = os.environ.get("WARDEN_ACTOR")
|
||||
pubkey = os.environ.get("SSH_PUBKEY")
|
||||
|
||||
if not actor:
|
||||
print("ops-ssh-wrapper: WARDEN_ACTOR not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not pubkey:
|
||||
print("ops-ssh-wrapper: SSH_PUBKEY not set", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
pubkey_path = Path(os.path.expanduser(pubkey))
|
||||
if not pubkey_path.exists():
|
||||
print(f"ops-ssh-wrapper: SSH_PUBKEY not found: {pubkey_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
cert_text = subprocess.check_output(
|
||||
["warden", "sign", actor, "--pubkey", str(pubkey_path)],
|
||||
text=True,
|
||||
).strip()
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(
|
||||
f"ops-ssh-wrapper: warden sign failed (exit {e.returncode})", file=sys.stderr
|
||||
)
|
||||
sys.exit(1)
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
"ops-ssh-wrapper: 'warden' not found in PATH. "
|
||||
"Install ops-warden: uv tool install ops-warden",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix="-cert.pub", mode="w", delete=False, prefix=f"{actor}-"
|
||||
) as f:
|
||||
f.write(cert_text + "\n")
|
||||
cert_path = f.name
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh-add", cert_path], capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
f"ops-ssh-wrapper: ssh-add warning: {result.stderr.strip()} "
|
||||
f"(ssh-agent may not be running — continuing anyway)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
finally:
|
||||
os.unlink(cert_path)
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
os.execvp(sys.argv[1], sys.argv[1:])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
97
src/warden/vault.py
Normal file
97
src/warden/vault.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""VaultCA backend — HashiCorp Vault SSH engine."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from warden.ca import CABackend, CAError, parse_cert_metadata
|
||||
from warden.config import VaultConfig
|
||||
from warden.models import CertRecord, CertSpec
|
||||
|
||||
|
||||
class VaultCA(CABackend):
|
||||
"""CA backend that signs via HashiCorp Vault SSH secrets engine."""
|
||||
|
||||
def __init__(self, vault_cfg: VaultConfig, state_dir: Path) -> None:
|
||||
self._cfg = vault_cfg
|
||||
self._state_dir = Path(os.path.expanduser(str(state_dir)))
|
||||
|
||||
def _token(self) -> str:
|
||||
token = os.environ.get(self._cfg.token_env, "")
|
||||
if not token:
|
||||
raise CAError(
|
||||
f"Vault token not found. Set the {self._cfg.token_env!r} "
|
||||
f"environment variable, or run: vault login"
|
||||
)
|
||||
return token
|
||||
|
||||
def sign(self, spec: CertSpec) -> CertRecord:
|
||||
"""Sign the public key via Vault SSH engine. Returns a CertRecord."""
|
||||
pubkey_path = Path(os.path.expanduser(str(spec.pubkey_path)))
|
||||
if not pubkey_path.exists():
|
||||
raise CAError(f"Public key not found: {pubkey_path}")
|
||||
|
||||
pubkey_text = pubkey_path.read_text().strip()
|
||||
role = self._cfg.role_map.get(spec.actor_type.value)
|
||||
if not role:
|
||||
raise CAError(
|
||||
f"No Vault role mapped for actor type {spec.actor_type.value!r}. "
|
||||
f"Add it to vault.role_map in warden.yaml."
|
||||
)
|
||||
|
||||
url = f"{self._cfg.addr}/v1/{self._cfg.mount}/sign/{role}"
|
||||
try:
|
||||
response = httpx.post(
|
||||
url,
|
||||
json={
|
||||
"public_key": pubkey_text,
|
||||
"valid_principals": ",".join(spec.principals),
|
||||
"ttl": f"{spec.ttl_hours}h",
|
||||
"cert_type": "user",
|
||||
"key_id": spec.identity,
|
||||
},
|
||||
headers={"X-Vault-Token": self._token()},
|
||||
timeout=10.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise CAError(
|
||||
f"Vault signing failed (HTTP {e.response.status_code}): "
|
||||
f"{e.response.text}"
|
||||
) from e
|
||||
except httpx.RequestError as e:
|
||||
raise CAError(
|
||||
f"Vault unreachable at {self._cfg.addr}. "
|
||||
f"Is Vault running? Consider --backend local as a fallback.\n{e}"
|
||||
) from e
|
||||
|
||||
cert_text = response.json()["data"]["signed_key"].strip()
|
||||
|
||||
self._state_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = self._state_dir / f"{spec.actor_name}-cert.pub"
|
||||
dest.write_text(cert_text + "\n")
|
||||
|
||||
# Parse metadata by writing to a tempfile and running ssh-keygen -L
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix="-cert.pub", mode="w", delete=False
|
||||
) as f:
|
||||
f.write(cert_text + "\n")
|
||||
tmp_cert = Path(f.name)
|
||||
|
||||
try:
|
||||
meta = parse_cert_metadata(tmp_cert)
|
||||
finally:
|
||||
tmp_cert.unlink(missing_ok=True)
|
||||
|
||||
return CertRecord(
|
||||
identity=meta["identity"] or spec.identity,
|
||||
valid_before=meta["valid_before"],
|
||||
cert_path=dest,
|
||||
signed_at=datetime.now(timezone.utc),
|
||||
principals=meta["principals"],
|
||||
actor_name=spec.actor_name,
|
||||
)
|
||||
Reference in New Issue
Block a user