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

@@ -5,8 +5,10 @@ from unittest.mock import MagicMock, patch
import pytest
from warden.ca import CAError, LocalCA, parse_cert_metadata
from warden.models import ActorType, CertSpec
import json
from warden.ca import CAError, LocalCA, _enforce_ttl, _evict_cert, _append_signature_log, parse_cert_metadata
from warden.models import ActorType, CertSpec, CertRecord
SAMPLE_SSHKEYGEN_L = """\
/tmp/key-cert.pub:
@@ -178,3 +180,183 @@ def test_local_ca_sign_ssh_keygen_failure(tmp_path):
with patch("warden.ca.subprocess.run", side_effect=fail_run):
with pytest.raises(CAError, match="Signing failed"):
ca.sign(spec)
# ---------------------------------------------------------------------------
# _enforce_ttl
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("actor_type,max_h", [
(ActorType.ADM, 48),
(ActorType.AGT, 24),
(ActorType.ATM, 8),
])
def test_enforce_ttl_rejects_over_max(actor_type, max_h, tmp_path):
spec = CertSpec(
actor_name=f"{actor_type.value}-test",
actor_type=actor_type,
pubkey_path=tmp_path / "k.pub",
ttl_hours=max_h + 1,
principals=["x"],
)
with pytest.raises(CAError, match="exceeds maximum"):
_enforce_ttl(spec)
@pytest.mark.parametrize("actor_type,max_h", [
(ActorType.ADM, 48),
(ActorType.AGT, 24),
(ActorType.ATM, 8),
])
def test_enforce_ttl_accepts_at_max(actor_type, max_h, tmp_path):
spec = CertSpec(
actor_name=f"{actor_type.value}-test",
actor_type=actor_type,
pubkey_path=tmp_path / "k.pub",
ttl_hours=max_h,
principals=["x"],
)
_enforce_ttl(spec) # must not raise
# ---------------------------------------------------------------------------
# _evict_cert
# ---------------------------------------------------------------------------
def test_evict_cert_removes_existing(tmp_path):
cert = tmp_path / "agt-test-cert.pub"
cert.write_text("old cert")
_evict_cert("agt-test", tmp_path)
assert not cert.exists()
def test_evict_cert_noop_when_absent(tmp_path):
_evict_cert("agt-test", tmp_path) # must not raise
# ---------------------------------------------------------------------------
# _append_signature_log
# ---------------------------------------------------------------------------
def test_append_signature_log_creates_file(tmp_path):
record = CertRecord(
identity="agt-test",
valid_before=datetime(2026, 3, 29, 10, 0, 0, tzinfo=timezone.utc),
cert_path=tmp_path / "agt-test-cert.pub",
signed_at=datetime(2026, 3, 28, 10, 0, 0, tzinfo=timezone.utc),
principals=["agt-task"],
actor_name="agt-test",
)
spec = CertSpec(
actor_name="agt-test",
actor_type=ActorType.AGT,
pubkey_path=tmp_path / "k.pub",
ttl_hours=24,
principals=["agt-task"],
)
_append_signature_log(record, spec, tmp_path, "local")
log_path = tmp_path / "signatures.log"
assert log_path.exists()
entry = json.loads(log_path.read_text().strip())
assert entry["actor"] == "agt-test"
assert entry["actor_type"] == "agt"
assert entry["ttl_hours"] == 24
assert entry["backend"] == "local"
assert entry["principals"] == ["agt-task"]
def test_append_signature_log_appends(tmp_path):
record = CertRecord(
identity="agt-test",
valid_before=datetime(2026, 3, 29, 10, 0, 0, tzinfo=timezone.utc),
cert_path=tmp_path / "agt-test-cert.pub",
signed_at=datetime(2026, 3, 28, 10, 0, 0, tzinfo=timezone.utc),
principals=["agt-task"],
actor_name="agt-test",
)
spec = CertSpec(
actor_name="agt-test",
actor_type=ActorType.AGT,
pubkey_path=tmp_path / "k.pub",
ttl_hours=24,
principals=["agt-task"],
)
_append_signature_log(record, spec, tmp_path, "local")
_append_signature_log(record, spec, tmp_path, "local")
lines = (tmp_path / "signatures.log").read_text().strip().splitlines()
assert len(lines) == 2
# ---------------------------------------------------------------------------
# LocalCA.sign with TTL enforcement, eviction, and log
# ---------------------------------------------------------------------------
def test_local_ca_sign_enforces_ttl(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
pubkey = tmp_path / "key.pub"
pubkey.write_text("ssh-ed25519 AAAA")
spec = CertSpec(
actor_name="agt-test",
actor_type=ActorType.AGT,
pubkey_path=pubkey,
ttl_hours=100, # exceeds AGT max of 24h
principals=["agt-test"],
)
ca = LocalCA(ca_key, tmp_path / "state")
with pytest.raises(CAError, match="exceeds maximum"):
ca.sign(spec)
def test_local_ca_sign_evicts_existing_cert(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
pubkey = tmp_path / "key.pub"
pubkey.write_text("ssh-ed25519 AAAA actor-key")
state = tmp_path / "state"
state.mkdir()
old_cert = state / "agt-state-hub-bridge-cert.pub"
old_cert.write_text("old cert content")
spec = CertSpec(
actor_name="agt-state-hub-bridge",
actor_type=ActorType.AGT,
pubkey_path=pubkey,
ttl_hours=24,
principals=["agt-task-bridge"],
identity="agt-state-hub-bridge",
)
with patch("warden.ca.subprocess.run", side_effect=_mock_run_factory(CERT_CONTENT)):
ca = LocalCA(ca_key, state)
record = ca.sign(spec)
assert record.cert_path.read_text().strip() == CERT_CONTENT
# Only one cert file for this actor (old was replaced)
assert len(list(state.glob("agt-state-hub-bridge-cert.pub"))) == 1
def test_local_ca_sign_writes_signature_log(tmp_path):
ca_key = tmp_path / "ca_key"
ca_key.write_text("fake-ca")
pubkey = tmp_path / "key.pub"
pubkey.write_text("ssh-ed25519 AAAA actor-key")
state = tmp_path / "state"
spec = CertSpec(
actor_name="agt-state-hub-bridge",
actor_type=ActorType.AGT,
pubkey_path=pubkey,
ttl_hours=24,
principals=["agt-task-bridge"],
identity="agt-state-hub-bridge",
)
with patch("warden.ca.subprocess.run", side_effect=_mock_run_factory(CERT_CONTENT)):
ca = LocalCA(ca_key, state)
ca.sign(spec)
log_path = state / "signatures.log"
assert log_path.exists()
entry = json.loads(log_path.read_text().strip())
assert entry["actor"] == "agt-state-hub-bridge"
assert entry["backend"] == "local"
assert entry["ttl_hours"] == 24

View File

@@ -4,11 +4,15 @@ from pathlib import Path
from warden.inventory import ActorEntry, PrincipalsInventory
from warden.models import ActorType
from unittest.mock import patch
from datetime import datetime, timezone
from warden.scorecard import (
check_actor_name_prefixes,
check_all_actors_have_principals,
check_no_stale_certs,
check_no_expired_certs,
check_ttl_policy,
run_scorecard,
)
@@ -96,4 +100,77 @@ def test_run_scorecard_clean(tmp_path):
)
results = run_scorecard(tmp_path, inv)
assert all(r.passed for r in results)
assert len(results) == 4
assert len(results) == 5
# ---------------------------------------------------------------------------
# check_ttl_policy
# ---------------------------------------------------------------------------
def test_ttl_policy_no_state_dir():
inv = make_inventory(("agt-bridge", ActorType.AGT, ["agt-task-bridge"]))
result = check_ttl_policy(Path("/nonexistent/state"), inv)
assert result.passed
def test_ttl_policy_empty_dir(tmp_path):
inv = make_inventory(("agt-bridge", ActorType.AGT, ["agt-task-bridge"]))
result = check_ttl_policy(tmp_path, inv)
assert result.passed
def test_ttl_policy_pass(tmp_path):
inv = make_inventory(("agt-bridge", ActorType.AGT, ["agt-task-bridge"]))
cert_path = tmp_path / "agt-bridge-cert.pub"
cert_path.write_text("fake")
# 24h window — exactly at AGT max
meta = {
"identity": "agt-bridge",
"valid_before": datetime(2026, 3, 29, 10, 0, 0, tzinfo=timezone.utc),
"valid_from": datetime(2026, 3, 28, 10, 0, 0, tzinfo=timezone.utc),
"principals": ["agt-task-bridge"],
}
with patch("warden.scorecard.parse_cert_metadata", return_value=meta):
result = check_ttl_policy(tmp_path, inv)
assert result.passed
def test_ttl_policy_fail(tmp_path):
inv = make_inventory(("agt-bridge", ActorType.AGT, ["agt-task-bridge"]))
cert_path = tmp_path / "agt-bridge-cert.pub"
cert_path.write_text("fake")
# 48h window — exceeds AGT max of 24h
meta = {
"identity": "agt-bridge",
"valid_before": datetime(2026, 3, 30, 10, 0, 0, tzinfo=timezone.utc),
"valid_from": datetime(2026, 3, 28, 10, 0, 0, tzinfo=timezone.utc),
"principals": ["agt-task-bridge"],
}
with patch("warden.scorecard.parse_cert_metadata", return_value=meta):
result = check_ttl_policy(tmp_path, inv)
assert not result.passed
assert "agt-bridge" in result.detail
def test_ttl_policy_skips_unknown_actor(tmp_path):
inv = PrincipalsInventory() # empty — no actors
cert_path = tmp_path / "agt-unknown-cert.pub"
cert_path.write_text("fake")
result = check_ttl_policy(tmp_path, inv)
assert result.passed # unknown actor skipped, not a violation
def test_stale_certs_detail_suggests_cleanup(tmp_path):
cert_path = tmp_path / "agt-bridge-cert.pub"
cert_path.write_text("fake")
# Expired well over 5 minutes ago
meta = {
"identity": "agt-bridge",
"valid_before": datetime(2020, 1, 1, tzinfo=timezone.utc),
"valid_from": datetime(2019, 12, 31, tzinfo=timezone.utc),
"principals": [],
}
with patch("warden.scorecard.parse_cert_metadata", return_value=meta):
result = check_no_stale_certs(tmp_path)
assert not result.passed
assert "warden cleanup" in result.detail