generated from coulomb/repo-seed
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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-",
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user