131 lines
5.5 KiB
Python
131 lines
5.5 KiB
Python
|
|
"""Owner-configured runtime and scheduled durable audit delivery.
|
||
|
|
|
||
|
|
This loads explicit configuration; it neither provisions credentials nor admits
|
||
|
|
a deployment. Projected caller tokens and Audit Core sender custody have owners.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
from pathlib import Path
|
||
|
|
import stat
|
||
|
|
import tempfile
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
|
||
|
|
from .approval_http import ApprovalHTTPClient
|
||
|
|
from .audit import AuditCoreSink, OutboxWorker
|
||
|
|
from .policy import PolicyClient
|
||
|
|
from .review import ReviewController
|
||
|
|
from .store import Store
|
||
|
|
|
||
|
|
|
||
|
|
def token_file(path):
|
||
|
|
path = Path(path)
|
||
|
|
if not path.is_absolute():
|
||
|
|
raise ValueError("credential file path must be absolute")
|
||
|
|
def read():
|
||
|
|
with path.open("rb") as handle:
|
||
|
|
value = handle.read(32769)
|
||
|
|
if len(value) > 32768:
|
||
|
|
raise ValueError("credential file too large")
|
||
|
|
return value.decode("ascii").strip()
|
||
|
|
return read
|
||
|
|
|
||
|
|
|
||
|
|
class AuditPump:
|
||
|
|
"""One process, bounded batches, 30s ticks; never drops a failed record."""
|
||
|
|
def __init__(self, store, sink, *, clock=time.time):
|
||
|
|
self.store, self.worker, self.clock = store, OutboxWorker(store, sink), clock
|
||
|
|
self._stop = threading.Event()
|
||
|
|
self._thread = None
|
||
|
|
self._lock = threading.Lock()
|
||
|
|
self._last_ok = None
|
||
|
|
self._last_reconciled = 0
|
||
|
|
|
||
|
|
def ready(self):
|
||
|
|
with self._lock:
|
||
|
|
return self._last_ok is not None and self.clock() - self._last_ok < 90
|
||
|
|
|
||
|
|
def tick(self):
|
||
|
|
try:
|
||
|
|
self.store.queue_heartbeats()
|
||
|
|
result = self.worker.run_once(limit=10)
|
||
|
|
now = self.clock()
|
||
|
|
if now - self._last_reconciled >= 300:
|
||
|
|
stamp = lambda t: datetime.fromtimestamp(t, timezone.utc).isoformat(timespec="microseconds")
|
||
|
|
report = self.worker.reconcile(stamp(now - 86400), stamp(now))
|
||
|
|
# Report the two time bases, not an invented loss/completeness
|
||
|
|
# result. A retained private snapshot is for operator inspection.
|
||
|
|
directory = self.store.path.parent
|
||
|
|
fd, temporary = tempfile.mkstemp(prefix=".reconciliation-", dir=directory)
|
||
|
|
try:
|
||
|
|
with os.fdopen(fd, "w") as handle:
|
||
|
|
json.dump(report, handle, sort_keys=True)
|
||
|
|
handle.write("\n"); handle.flush(); os.fsync(handle.fileno())
|
||
|
|
os.replace(temporary, directory / "audit-reconciliation.json")
|
||
|
|
finally:
|
||
|
|
if os.path.exists(temporary):
|
||
|
|
os.unlink(temporary)
|
||
|
|
self._last_reconciled = now
|
||
|
|
healthy = not result["retrying"] and not result["blocked"] and not any(
|
||
|
|
row["state"] != "delivered" for row in self.store.outbox())
|
||
|
|
with self._lock:
|
||
|
|
self._last_ok = now if healthy else None
|
||
|
|
except Exception:
|
||
|
|
# Keep readiness closed and retry next tick. Never log response
|
||
|
|
# bodies, file paths, bearer tokens or a fabricated human decline.
|
||
|
|
with self._lock:
|
||
|
|
self._last_ok = None
|
||
|
|
|
||
|
|
def start(self):
|
||
|
|
if self._thread is not None:
|
||
|
|
raise RuntimeError("audit delivery already started")
|
||
|
|
def run():
|
||
|
|
while not self._stop.is_set():
|
||
|
|
self.tick()
|
||
|
|
self._stop.wait(30)
|
||
|
|
self._thread = threading.Thread(target=run, name="infd-audit-delivery", daemon=True)
|
||
|
|
self._thread.start()
|
||
|
|
|
||
|
|
def stop(self):
|
||
|
|
self._stop.set()
|
||
|
|
if self._thread is not None:
|
||
|
|
self._thread.join(timeout=6)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class Runtime:
|
||
|
|
controller: ReviewController
|
||
|
|
pump: AuditPump
|
||
|
|
|
||
|
|
@classmethod
|
||
|
|
def from_file(cls, filename):
|
||
|
|
path = Path(filename)
|
||
|
|
info = path.lstat()
|
||
|
|
if (not path.is_absolute() or not stat.S_ISREG(info.st_mode) or info.st_nlink != 1
|
||
|
|
or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600
|
||
|
|
or info.st_size > 16384):
|
||
|
|
raise ValueError("runtime configuration must be an owned private 0600 file")
|
||
|
|
data = json.loads(path.read_text())
|
||
|
|
if (set(data) != {"schema", "evidence_db", "policy", "approval_origin", "audit"}
|
||
|
|
or data["schema"] != "informed-decision.review-runtime.v1"
|
||
|
|
or set(data["policy"]) != {"origin", "package", "version", "package_digest", "caller_token_file"}
|
||
|
|
or set(data["audit"]) != {"origin", "sender_token_file"}):
|
||
|
|
raise ValueError("invalid review runtime configuration")
|
||
|
|
if not Path(data["evidence_db"]).is_absolute():
|
||
|
|
raise ValueError("absolute evidence database path required")
|
||
|
|
from .http_transport import fixed_origin
|
||
|
|
approval_origin = fixed_origin(data["approval_origin"], allow_internal_http=True)
|
||
|
|
policy = data["policy"]
|
||
|
|
client = PolicyClient(policy["origin"], token_file(policy["caller_token_file"]),
|
||
|
|
package=policy["package"], version=policy["version"], package_digest=policy["package_digest"],
|
||
|
|
allow_internal_http=True)
|
||
|
|
sink = AuditCoreSink(data["audit"]["origin"], token_file(data["audit"]["sender_token_file"]),
|
||
|
|
allow_internal_http=True)
|
||
|
|
store = Store(data["evidence_db"])
|
||
|
|
controller = ReviewController(store, client, lambda session: ApprovalHTTPClient(
|
||
|
|
approval_origin, session, allow_internal_http=True))
|
||
|
|
return cls(controller, AuditPump(store, sink))
|