feat(warden): implement WARDEN-WP-0002 correctness and operational completeness

T1 — TTL max enforcement:
  - models.py: MAX_TTL_HOURS policy constant
  - ca.py: _enforce_ttl() raises CAError when spec.ttl_hours > type max
  - Called at top of LocalCA.sign() and VaultCA.sign()
  - scorecard.py: check_ttl_policy() — flags certs with issued TTL > type max
  - run_scorecard() now returns 5 checks

T2 — Stale cert cleanup:
  - ca.py: _evict_cert() removes existing cert before writing new one (no accumulation)
  - cli.py: warden cleanup [actor] [--dry-run] command
  - check_no_stale_certs detail suggests 'warden cleanup' when stale certs found

T3 — Outgoing signatures log:
  - ca.py: _append_signature_log() writes JSONL to state_dir/signatures.log
  - Called after every successful sign() in LocalCA and VaultCA
  - cli.py: warden log [actor] [--last N] [--json] command
  - parse_cert_metadata now also returns valid_from (needed for TTL policy check)

61 tests passing, ruff clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 15:53:10 +02:00
parent 66e93e5e5c
commit 9857ed1424
9 changed files with 494 additions and 37 deletions

View File

@@ -1,6 +1,7 @@
"""CA backends for OpsWarden: LocalCA (ssh-keygen) and abstract base."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
@@ -10,7 +11,7 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from warden.models import CertRecord, CertSpec
from warden.models import CertRecord, CertSpec, MAX_TTL_HOURS
class CAError(Exception):
@@ -24,6 +25,42 @@ class CABackend(ABC):
...
def _enforce_ttl(spec: CertSpec) -> None:
"""Raise CAError if spec.ttl_hours exceeds the type maximum (directive §2)."""
max_h = MAX_TTL_HOURS[spec.actor_type]
if spec.ttl_hours > max_h:
raise CAError(
f"TTL {spec.ttl_hours}h exceeds maximum {max_h}h for actor type "
f"{spec.actor_type.value!r} (AccessManagementDirective §2)"
)
def _evict_cert(actor_name: str, state_dir: Path) -> None:
"""Remove the existing cert for actor_name from state_dir, if present."""
cert_path = state_dir / f"{actor_name}-cert.pub"
cert_path.unlink(missing_ok=True)
def _append_signature_log(
record: CertRecord, spec: CertSpec, state_dir: Path, backend: str
) -> None:
"""Append one JSONL line to state_dir/signatures.log."""
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"actor": spec.actor_name,
"actor_type": spec.actor_type.value,
"identity": record.identity,
"principals": record.principals,
"ttl_hours": spec.ttl_hours,
"valid_before": record.valid_before.isoformat(),
"cert_path": str(record.cert_path),
"backend": backend,
}
state_dir.mkdir(parents=True, exist_ok=True)
with (state_dir / "signatures.log").open("a") as f:
f.write(json.dumps(entry) + "\n")
def parse_cert_metadata(cert_path: Path) -> dict:
"""Parse ssh-keygen -L output into identity, valid_before, and principals.
@@ -40,6 +77,7 @@ def parse_cert_metadata(cert_path: Path) -> dict:
identity: Optional[str] = None
valid_before: Optional[datetime] = None
valid_from: Optional[datetime] = None
principals: List[str] = []
in_principals = False
@@ -59,6 +97,13 @@ def parse_cert_metadata(cert_path: Path) -> dict:
valid_before = dt.replace(tzinfo=timezone.utc)
except ValueError:
pass
from_parts = parts[0].split("from ", 1)
if len(from_parts) == 2:
try:
dt_from = datetime.fromisoformat(from_parts[1].strip())
valid_from = dt_from.replace(tzinfo=timezone.utc)
except ValueError:
pass
elif stripped == "Principals:":
in_principals = True
elif in_principals:
@@ -76,6 +121,7 @@ def parse_cert_metadata(cert_path: Path) -> dict:
return {
"identity": identity or "",
"valid_before": valid_before,
"valid_from": valid_from,
"principals": principals,
}
@@ -89,6 +135,7 @@ class LocalCA(CABackend):
def sign(self, spec: CertSpec) -> CertRecord:
"""Sign the public key in spec. Returns a CertRecord; cert saved to state_dir."""
_enforce_ttl(spec)
pubkey = Path(os.path.expanduser(str(spec.pubkey_path)))
if not pubkey.exists():
raise CAError(f"Public key not found: {pubkey}")
@@ -125,10 +172,11 @@ class LocalCA(CABackend):
meta = parse_cert_metadata(cert_path_tmp)
self._state_dir.mkdir(parents=True, exist_ok=True)
_evict_cert(spec.actor_name, self._state_dir)
dest = self._state_dir / f"{spec.actor_name}-cert.pub"
shutil.copy2(cert_path_tmp, dest)
return CertRecord(
record = CertRecord(
identity=meta["identity"] or spec.identity,
valid_before=meta["valid_before"],
cert_path=dest,
@@ -136,6 +184,8 @@ class LocalCA(CABackend):
principals=meta["principals"],
actor_name=spec.actor_name,
)
_append_signature_log(record, spec, self._state_dir, "local")
return record
def generate_keypair(self, actor_name: str) -> tuple[Path, Path]:
"""Generate an ed25519 keypair for an actor.

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Annotated, List, Optional
@@ -394,3 +394,105 @@ def inventory_remove(
raise typer.Exit(1)
console.print(f"[green]Removed[/green] {actor_name}")
# ---------------------------------------------------------------------------
# warden cleanup
# ---------------------------------------------------------------------------
@app.command()
def cleanup(
actor_name: Annotated[Optional[str], typer.Argument(help="Actor name (omit for all)")] = None,
dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without deleting")] = False,
) -> None:
"""Remove stale (expired > 5 min) certificates from state dir."""
cfg = _load_cfg()
cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
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 []
removed = []
for cert_path in paths:
try:
meta = parse_cert_metadata(cert_path)
except Exception:
continue
if meta["valid_before"] < cutoff:
if dry_run:
console.print(f"would remove: {cert_path.name}")
else:
cert_path.unlink()
console.print(f"removed: {cert_path.name}")
removed.append(cert_path.name)
if not removed:
console.print("No stale certificates found.")
# ---------------------------------------------------------------------------
# warden log
# ---------------------------------------------------------------------------
@app.command()
def log(
actor_name: Annotated[Optional[str], typer.Argument(help="Filter by actor name")] = None,
last: Annotated[int, typer.Option("--last", help="Show last N entries")] = 20,
output_json: Annotated[bool, typer.Option("--json", help="Output JSON")] = False,
) -> None:
"""Show outgoing certificate signing history."""
cfg = _load_cfg()
log_path = cfg.state_dir / "signatures.log"
if not log_path.exists():
if output_json:
print("[]")
else:
console.print("No signatures log found.")
return
entries = []
for line in log_path.read_text().splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if actor_name and entry.get("actor") != actor_name:
continue
entries.append(entry)
entries = entries[-last:]
if output_json:
print(json.dumps(entries, indent=2))
return
if not entries:
console.print("No matching log entries.")
return
table = Table(title="Signatures Log")
table.add_column("Timestamp")
table.add_column("Actor")
table.add_column("Type")
table.add_column("Identity")
table.add_column("TTL (h)")
table.add_column("Valid Before (UTC)")
table.add_column("Backend")
for e in entries:
table.add_row(
e.get("timestamp", "")[:19],
e.get("actor", ""),
e.get("actor_type", ""),
e.get("identity", ""),
str(e.get("ttl_hours", "")),
e.get("valid_before", "")[:19],
e.get("backend", ""),
)
console.print(table)

View File

@@ -21,6 +21,9 @@ DEFAULT_TTL_HOURS: dict[ActorType, int] = {
ActorType.ATM: 8,
}
# Maximum permitted TTLs — same values, explicit policy name for enforcement
MAX_TTL_HOURS: dict[ActorType, int] = dict(DEFAULT_TTL_HOURS)
# Required name prefixes per ActorType (directive §2 naming convention)
ACTOR_PREFIX: dict[ActorType, str] = {
ActorType.ADM: "adm-",

View File

@@ -8,7 +8,7 @@ from typing import List
from warden.ca import CAError, parse_cert_metadata
from warden.inventory import PrincipalsInventory
from warden.models import ACTOR_PREFIX
from warden.models import ACTOR_PREFIX, MAX_TTL_HOURS
@dataclass
@@ -84,7 +84,45 @@ def check_no_stale_certs(state_dir: Path) -> CheckResult:
return CheckResult(
name="no_stale_certs",
passed=len(stale) == 0,
detail=f"stale certs present: {stale}" if stale else "no stale certs",
detail=(
f"stale certs present: {stale} — run 'warden cleanup'" if stale
else "no stale certs"
),
)
def check_ttl_policy(state_dir: Path, inventory: PrincipalsInventory) -> CheckResult:
"""Certs in state_dir must not exceed the type-max TTL (directive §2)."""
if not state_dir.exists():
return CheckResult("ttl_policy", passed=True, detail="no state dir")
violations = []
for cert_path in state_dir.glob("*-cert.pub"):
actor_name = cert_path.stem.replace("-cert", "")
entry = inventory.actors.get(actor_name)
if entry is None:
continue
try:
meta = parse_cert_metadata(cert_path)
except CAError:
continue
valid_from = meta.get("valid_from")
if valid_from is None:
continue
ttl_hours = (meta["valid_before"] - valid_from).total_seconds() / 3600
max_hours = MAX_TTL_HOURS[entry.actor_type]
if ttl_hours > max_hours + 0.01:
violations.append(
f"{actor_name!r}: {ttl_hours:.1f}h issued > {max_hours}h max"
)
return CheckResult(
name="ttl_policy",
passed=len(violations) == 0,
detail=(
"; ".join(violations) if violations
else "all certs within TTL policy"
),
)
@@ -95,4 +133,5 @@ def run_scorecard(state_dir: Path, inventory: PrincipalsInventory) -> List[Check
check_all_actors_have_principals(inventory),
check_no_expired_certs(state_dir),
check_no_stale_certs(state_dir),
check_ttl_policy(state_dir, inventory),
]

View File

@@ -8,7 +8,7 @@ from pathlib import Path
import httpx
from warden.ca import CABackend, CAError, parse_cert_metadata
from warden.ca import CABackend, CAError, _append_signature_log, _enforce_ttl, _evict_cert, parse_cert_metadata
from warden.config import VaultConfig
from warden.models import CertRecord, CertSpec
@@ -31,6 +31,7 @@ class VaultCA(CABackend):
def sign(self, spec: CertSpec) -> CertRecord:
"""Sign the public key via Vault SSH engine. Returns a CertRecord."""
_enforce_ttl(spec)
pubkey_path = Path(os.path.expanduser(str(spec.pubkey_path)))
if not pubkey_path.exists():
raise CAError(f"Public key not found: {pubkey_path}")
@@ -72,6 +73,7 @@ class VaultCA(CABackend):
cert_text = response.json()["data"]["signed_key"].strip()
self._state_dir.mkdir(parents=True, exist_ok=True)
_evict_cert(spec.actor_name, self._state_dir)
dest = self._state_dir / f"{spec.actor_name}-cert.pub"
dest.write_text(cert_text + "\n")
@@ -87,7 +89,7 @@ class VaultCA(CABackend):
finally:
tmp_cert.unlink(missing_ok=True)
return CertRecord(
record = CertRecord(
identity=meta["identity"] or spec.identity,
valid_before=meta["valid_before"],
cert_path=dest,
@@ -95,3 +97,5 @@ class VaultCA(CABackend):
principals=meta["principals"],
actor_name=spec.actor_name,
)
_append_signature_log(record, spec, self._state_dir, "vault")
return record