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:
tegwick 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.