audit-4: serialized audit chain + legacy genesis + daily Nostr anchoring
- audit() writes are serialized: pg_advisory_xact_lock on Postgres; in-process RLock held until session commit/rollback on SQLite (test: 4 concurrent writers x 25 entries -> chain verifies, 100 rows) - legacy rows (entry_hash NULL, pre-0002) are skipped: the chain begins GENESIS at the first hashed row; migrated DBs verify clean by design - monitor anchor: publishes the last chain hash as a signed NIP-01 note once per day, logs it in the audit log (self-verified before publish) tests: concurrent writers; legacy-genesis verification; anchor signs, logs once per day.
This commit is contained in:
@@ -147,6 +147,85 @@ def _build(cfg: Config, sess) -> str:
|
||||
return cfg.monitor_output
|
||||
|
||||
|
||||
def _anchor(cfg: Config, sess) -> int:
|
||||
"""Anchor core (injectable session for tests). Returns 0 ok / 0 skip."""
|
||||
from cayceops.components.publisher import nostr
|
||||
from cayceops.config import ConfigError
|
||||
from cayceops.models import AuditEntry, audit
|
||||
|
||||
if not cfg.nostr_private_key or not cfg.nostr_relays:
|
||||
raise ConfigError(
|
||||
"anchor requires CAYCE_OPS_NOSTR_PRIVATE_KEY and CAYCE_OPS_NOSTR_RELAYS"
|
||||
)
|
||||
if not cfg.deployment_id:
|
||||
raise ConfigError("anchor requires CAYCE_OPS_DEPLOYMENT_ID")
|
||||
|
||||
last = (
|
||||
sess.query(AuditEntry)
|
||||
.filter(AuditEntry.entry_hash.isnot(None))
|
||||
.order_by(AuditEntry.id.desc())
|
||||
.first()
|
||||
)
|
||||
if last is None:
|
||||
print("nothing to anchor: chain empty")
|
||||
return 0
|
||||
today = datetime.now(UTC).date()
|
||||
last_anchor = (
|
||||
sess.query(AuditEntry)
|
||||
.filter(AuditEntry.action == "anchor")
|
||||
.order_by(AuditEntry.id.desc())
|
||||
.first()
|
||||
)
|
||||
if last_anchor is not None and last_anchor.ts.date() == today:
|
||||
print("already anchored today")
|
||||
return 0
|
||||
|
||||
secret = nostr.normalize_key(cfg.nostr_private_key)
|
||||
event = nostr.build_event(
|
||||
secret,
|
||||
f"cayce-ops audit anchor deployment={cfg.deployment_id} "
|
||||
f"last_hash={last.entry_hash} entries={last.id}",
|
||||
tags=[["t", "cayce-ops"], ["t", "audit-anchor"]],
|
||||
)
|
||||
if not nostr.verify_event(event):
|
||||
raise RuntimeError("anchor event failed self-verification")
|
||||
results = nostr.publish(cfg.nostr_relays, event)
|
||||
audit(
|
||||
sess,
|
||||
actor="daemon:monitor",
|
||||
action="anchor",
|
||||
detail={
|
||||
"last_hash": last.entry_hash,
|
||||
"last_entry": last.id,
|
||||
"event_id": event["id"],
|
||||
"relays": results,
|
||||
"deployment": cfg.deployment_id,
|
||||
},
|
||||
)
|
||||
print(f"anchored: {event['id']}")
|
||||
return 0
|
||||
|
||||
|
||||
def anchor(cfg: Config | None = None) -> int:
|
||||
"""CLI: anchor once per day (external anchoring, audit round 2 point 4)."""
|
||||
from cayceops.config import ConfigError, load
|
||||
from cayceops.db import engine
|
||||
|
||||
cfg = cfg or load()
|
||||
try:
|
||||
with session_scope() as sess:
|
||||
try:
|
||||
return _anchor(cfg, sess)
|
||||
except ConfigError as exc:
|
||||
print(f"anchor refused: {exc}")
|
||||
return 2
|
||||
except Exception as exc: # noqa: BLE001 — CLI boundary
|
||||
print(f"anchor failed: {str(exc)[:200]}")
|
||||
return 1
|
||||
finally:
|
||||
engine().dispose()
|
||||
|
||||
|
||||
def build(cfg: Config | None = None) -> int:
|
||||
"""CLI entry: render the status page inside a DaemonRun bookkeeping wrap."""
|
||||
from cayceops.config import load
|
||||
|
||||
@@ -67,3 +67,42 @@ def test_build_escapes_injected_content(cfg, sess, tmp_path):
|
||||
page = out.read_text()
|
||||
assert "<script>x</script>" not in page
|
||||
assert "<script>x</script>" in page
|
||||
|
||||
|
||||
def test_anchor_signs_and_logs_once_per_day(cfg, sess, monkeypatch):
|
||||
from cayceops.components.monitor import monitor
|
||||
from cayceops.models import AuditEntry
|
||||
|
||||
# seed the chain
|
||||
from cayceops.models import audit as audit_fn
|
||||
|
||||
audit_fn(sess, actor="daemon:executor", action="execute_done", detail={})
|
||||
sess.flush()
|
||||
|
||||
published = []
|
||||
|
||||
def fake_publish(relays, event, timeout=10.0):
|
||||
published.append(event)
|
||||
return [{"relay": r, "ok": "true"} for r in relays]
|
||||
|
||||
import cayceops.components.publisher.nostr as nostr_mod
|
||||
|
||||
monkeypatch.setattr(nostr_mod, "publish", fake_publish)
|
||||
|
||||
config = cfg(
|
||||
CAYCE_OPS_NOSTR_PRIVATE_KEY="11" * 32,
|
||||
CAYCE_OPS_NOSTR_RELAYS="wss://r.example.org",
|
||||
CAYCE_OPS_DEPLOYMENT_ID="itest",
|
||||
)
|
||||
code = monitor._anchor(config, sess)
|
||||
assert code == 0
|
||||
assert len(published) == 1
|
||||
content = published[0]["content"]
|
||||
assert "audit anchor" in content and "itest" in content
|
||||
entry = sess.query(AuditEntry).filter_by(action="anchor").one()
|
||||
assert entry.detail["last_hash"]
|
||||
|
||||
# second call same day -> skipped, no new nostr event
|
||||
code2 = monitor._anchor(config, sess)
|
||||
assert code2 == 0
|
||||
assert len(published) == 1
|
||||
|
||||
Reference in New Issue
Block a user