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:
parent
66e93e5e5c
commit
9857ed1424
9 changed files with 494 additions and 37 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue