feat(directive): implement BRIDGE-WP-0004 AccessManagementDirective alignment

- ActorType enum (adm/agt/atm) replaces actor_class string; config validates
  naming convention (adm-*/agt-*/atm-*) with hard ConfigError on mismatch;
  legacy 'human'/'automation' values accepted with DeprecationWarning
- cert_command: pluggable shell string run before each SSH launch; cert written
  to state dir; -i cert appended to SSH command alongside -i key
- TTL-aware cert refresh: parses Valid-to via ssh-keygen -L; pre-emptive restart
  5 min before expiry (no backoff, no attempt increment); CERT_EXPIRING logged
- CertAcquisitionError: cert failures trigger normal backoff/retry loop
- cert_identity: Key ID parsed from cert and recorded in BRIDGE_CONNECTED event
- bridge cert-status: new CLI command; exit 1 on expired cert; --json flag
- 233 tests passing, ruff clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 09:38:29 +02:00
parent 22601ef3e6
commit bd169a07e2
17 changed files with 730 additions and 145 deletions

View File

@@ -22,7 +22,7 @@ class TestAuditLogger:
tunnel="my-tunnel",
event=AuditEvent.BRIDGE_STARTED,
actor="operator.bernd",
actor_class="human",
actor_type="adm",
)
log_file = log_dir / "my-tunnel.log"
assert log_file.exists()
@@ -32,7 +32,7 @@ class TestAuditLogger:
tunnel="my-tunnel",
event=AuditEvent.BRIDGE_STARTED,
actor="operator.bernd",
actor_class="human",
actor_type="adm",
)
lines = (log_dir / "my-tunnel.log").read_text().strip().splitlines()
assert len(lines) == 1
@@ -40,12 +40,12 @@ class TestAuditLogger:
assert entry["tunnel"] == "my-tunnel"
assert entry["event"] == "bridge_started"
assert entry["actor"] == "operator.bernd"
assert entry["actor_class"] == "human"
assert entry["actor_type"] == "adm"
assert "timestamp" in entry
def test_multiple_events_append(self, logger, log_dir):
for event in [AuditEvent.BRIDGE_STARTED, AuditEvent.BRIDGE_CONNECTED, AuditEvent.BRIDGE_STOPPED]:
logger.log(tunnel="t", event=event, actor="a", actor_class="human")
logger.log(tunnel="t", event=event, actor="a", actor_type="adm")
lines = (log_dir / "t.log").read_text().strip().splitlines()
assert len(lines) == 3
@@ -54,7 +54,7 @@ class TestAuditLogger:
tunnel="t",
event=AuditEvent.HEALTH_CHECK_FAILED,
actor="a",
actor_class="automation",
actor_type="atm",
detail="connection refused",
)
entry = json.loads((log_dir / "t.log").read_text().strip())
@@ -72,15 +72,15 @@ class TestAuditLogger:
def test_timestamp_is_iso8601(self, logger, log_dir):
from datetime import datetime
logger.log(tunnel="t", event=AuditEvent.BRIDGE_STOPPED, actor="a", actor_class="human")
logger.log(tunnel="t", event=AuditEvent.BRIDGE_STOPPED, actor="a", actor_type="adm")
entry = json.loads((log_dir / "t.log").read_text().strip())
# Should parse without error
dt = datetime.fromisoformat(entry["timestamp"])
assert dt.tzinfo is not None or True # UTC or naive both acceptable
def test_read_events(self, logger, log_dir):
logger.log(tunnel="t", event=AuditEvent.BRIDGE_STARTED, actor="a", actor_class="human")
logger.log(tunnel="t", event=AuditEvent.BRIDGE_STOPPED, actor="a", actor_class="human")
logger.log(tunnel="t", event=AuditEvent.BRIDGE_STARTED, actor="a", actor_type="adm")
logger.log(tunnel="t", event=AuditEvent.BRIDGE_STOPPED, actor="a", actor_type="adm")
events = logger.read_events("t")
assert len(events) == 2
assert events[0]["event"] == "bridge_started"