diff --git a/audit_core/cadence_registration.py b/audit_core/cadence_registration.py new file mode 100644 index 0000000..ebfb4cd --- /dev/null +++ b/audit_core/cadence_registration.py @@ -0,0 +1,137 @@ +"""Credential-preserving Approval Engine registry metadata update. + +No credential is created, returned, logged or written to a local file. The +caller owns the attended custody envelope and persists only ``receipt``. +""" +from __future__ import annotations + +import copy +import hashlib +import json +from typing import Any + +from audit_core.emission_cadence import parse_declaration +from audit_core.senders import SenderRegistry + +CLASSES = {"issuance", "use", "supersession", "revocation"} + + +def strict_json(raw: bytes | str) -> Any: + def unique(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate_json_key") + result[key] = value + return result + return json.loads(raw, object_pairs_hook=unique) + + +def prepare(body: dict, declaration_raw: bytes, proposal: dict) -> tuple[dict, dict]: + """Return an in-memory KV data body and a nonsecret receipt. + + Refuse absent/ambiguous identity, wrong scope, credential absence, stale + source pins, widened class inventory, or proposal/declaration disagreement. + Preserve every sibling sender, credential, expiry, policy and KV property. + """ + digest = hashlib.sha256(declaration_raw).hexdigest() + if proposal.get("emission_cadence_sha256") != digest: + raise ValueError("declaration_digest_mismatch") + if (proposal.get("sender"), proposal.get("source"), proposal.get("tenant"), + proposal.get("may_read")) != ("approval-engine", "approval-engine", "tenant:platform", False): + raise ValueError("unexpected_proposal_scope") + if proposal.get("preserve_existing_credentials_and_other_registry_fields") is not True: + raise ValueError("preservation_not_declared") + frame = proposal["coordination_frame"] + f = frame["frame_seconds"] + if type(f) is not int or f <= 0: + raise ValueError("invalid_frame") + waits = proposal["heartbeat_classes"] + if waits != dict.fromkeys(CLASSES, 2 * f): + raise ValueError("unexpected_class_waits") + if (proposal.get("receiver_grace_factor"), proposal.get("effective_deadline_seconds")) != (1.5, 3 * f): + raise ValueError("unexpected_deadline") + declaration = strict_json(declaration_raw) + parsed = parse_declaration(declaration) + if parsed.source != "approval-engine" or parsed.rates: + raise ValueError("unexpected_declaration_source_or_rate") + declared = {e["event_class"]: e for e in parsed.other_entries} + if len(declared) != len(parsed.other_entries) or set(declared) != CLASSES | {"heartbeat"}: + raise ValueError("unexpected_declaration_inventory") + if declaration.get("extensions", {}).get("netkingdom", {}).get("coordination_frame") != frame: + raise ValueError("frame_provenance_mismatch") + for name in CLASSES: + beat = declared[name].get("heartbeat", {}) + ext = beat.get("extensions", {}).get("approval-engine", {}) + if (beat.get("event_class"), beat.get("interval_seconds"), ext.get("asserted_class"), + ext.get("requested_observer_max_gap_seconds"), ext.get("effective_deadline_seconds")) != ( + "audit-core.heartbeat", f, name, 2 * f, 3 * f): + raise ValueError("declaration_wait_mismatch") + rows = strict_json(body["senders.json"]) + if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows): + raise ValueError("invalid_registry") + indices = [i for i, row in enumerate(rows) if row.get("name") == "approval-engine"] + if len(indices) != 1: + raise ValueError("sender_absent_or_ambiguous") + i = indices[0] + before = rows[i] + if (before.get("sources"), before.get("tenants"), before.get("may_write", True), + before.get("may_read", False)) != (["approval-engine"], ["tenant:platform"], True, False): + raise ValueError("existing_sender_scope_mismatch") + if not (before.get("tokens") or before.get("token")): + raise ValueError("existing_credentials_required") + existing_classes = set(before.get("heartbeat_classes", {})) + if existing_classes - CLASSES: + raise ValueError("would_drop_existing_heartbeat_class") + updated_rows = copy.deepcopy(rows) + updated_rows[i]["heartbeat_classes"] = copy.deepcopy(waits) + updated_rows[i]["emission_cadence"] = declaration + # Validate the entire candidate against the actual receiver parser. No + # private data appears in exceptions emitted by the attended wrapper. + SenderRegistry.from_env({"AUDIT_CORE_SENDERS": json.dumps(updated_rows)}) + updated = copy.deepcopy(body) + changed = updated_rows != rows + if changed: + updated["senders.json"] = json.dumps(updated_rows, separators=(",", ":")) + receipt = {"sender": "approval-engine", "source": "approval-engine", + "tenant": "tenant:platform", "may_read": False, + "emission_cadence_sha256": digest, "heartbeat_classes": waits, + "effective_deadline_seconds": 3 * f, "changed": changed, + "credentials_preserved": True, "other_registry_fields_preserved": True} + return updated, receipt + + +def qonto_metadata(declaration: dict, declaration_sha256: str) -> dict: + """Build a nonsecret sender proposal; custody owner adds tokens in memory. + + The digest is of the owner's YAML bytes, which the custody caller verifies + before parsing; JSON serialization cannot stand in for that source digest. + """ + parsed = parse_declaration(declaration) + if parsed.source != "qonto-assistant" or parsed.stream_id != "qonto-assistant.audit" or parsed.rates: + raise ValueError("unexpected_qonto_declaration") + entries = {e["event_class"]: e for e in parsed.other_entries} + kinds = {"audit.allow": "attributive", "audit.deny": "load-bearing", "audit.heartbeat": "load-bearing"} + if set(entries) != set(kinds) or len(entries) != len(parsed.other_entries): + raise ValueError("unexpected_qonto_inventory") + frame = declaration.get("extensions", {}).get("netkingdom", {}).get("coordination_frame", {}) + f = frame.get("frame_seconds") + if type(f) is not int or f <= 0: + raise ValueError("invalid_qonto_frame") + if len(declaration_sha256) != 64 or any(c not in "0123456789abcdef" for c in declaration_sha256): + raise ValueError("invalid_qonto_source_digest") + from audit_core.emission_cadence import duration_seconds + for name, kind in kinds.items(): + entry = entries[name] + if entry.get("extensions", {}).get("netkingdom", {}).get("evidence_class") != kind: + raise ValueError("qonto_classification_mismatch") + beat = entry.get("heartbeat", {}) + if beat.get("event_class") != "audit.heartbeat" or duration_seconds(beat.get("interval")) != f: + raise ValueError("qonto_cadence_mismatch") + if beat.get("extensions", {}).get("qonto-assistant", {}).get("assertion_scope") != "no-deny-transitions-in-window": + raise ValueError("qonto_assertion_scope_mismatch") + return {"name": "rapp-qonto-collector", "sources": ["qonto-assistant"], + "tenants": ["tenant:friendly:binky"], "may_write": True, "may_read": False, + "secret_policy": "redact", "evidence_kind": "load-bearing", + "heartbeat_classes": {"audit.deny": 2 * f}, + "emission_cadence": copy.deepcopy(declaration)} diff --git a/deploy/qonto-collector-ingress.yaml b/deploy/qonto-collector-ingress.yaml new file mode 100644 index 0000000..df2af05 --- /dev/null +++ b/deploy/qonto-collector-ingress.yaml @@ -0,0 +1,25 @@ +# NK-WP-0035 / AUDIT-WP-0013-T05. Prepared, not applied. +# Namespace and pod selectors are ANDed within one peer. Collector egress +# and credential binding remain independently required. +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: audit-core-qonto-collector-ingress + namespace: audit-core +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: audit-core + app.kubernetes.io/component: receiver + policyTypes: [Ingress] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: rapp-qonto + podSelector: + matchLabels: + app.kubernetes.io/name: rapp-qonto-audit-collector + ports: + - protocol: TCP + port: 8080 diff --git a/docs/cadence-registration.md b/docs/cadence-registration.md new file mode 100644 index 0000000..928b39f --- /dev/null +++ b/docs/cadence-registration.md @@ -0,0 +1,75 @@ +# Cadence registry admission — NK-WP-0035 + +## Prepared source, not live acceptance + +The 2026-10-15 source frame is f=3600 seconds. Approval Engine declares +issuance, use, supersession and revocation heartbeats every f. Register each +class wait at 2f=7200; audit-core's existing 1.5 grace makes the effective +missing-heartbeat deadline 3f=10800. Class keys are the asserted class names, +not the action names approval.issuance etc. Retain write true/read false, +source approval-engine and tenant tenant:platform. + +`audit_core.cadence_registration.prepare` takes the protected KV data object, +raw source declaration bytes and the reviewed nonsecret proposal. It validates +the exact declaration SHA256, source/class/interval/provenance agreement and +incumbent scope. It returns the proposed in-memory KV object and metadata-only +receipt. Only heartbeat_classes and emission_cadence may change. Every token, +rotation overlap, expiry, policy, sibling sender and other KV property remains +unchanged. Existing unexpected heartbeat classes refuse a destructive drop. +The entire candidate is parsed by the real receiver sender registry parser. + +No registry values belong in operator terminals, agent outputs, local files, +Git or State Hub. Root's sanctioned contained platform owner process may call +this pure API. `scripts/register-approval-engine-cadence.py` is a silent +attended wrapper requiring the warden isolated HOME and non-root platform-admin +identity. It defaults to preflight and uses KV-v2 CAS plus exact readback with +--apply. It does not mint credentials, synchronize ESO or restart workloads. +Consult the current custody route before invoking any credential operation; +this helper does not create custody authority. + +## Receiver artifact and database prerequisite + +Read-only native metadata on 2026-10-15 found receiver Deployment UID +b85fe3d0-75c9-4e0d-8c34-c6f1df0881bb, ready replicas 1 and image +forgejo.coulomb.social/coulomb/audit-core@sha256:ec15f63d49226bfe507af2bc38ffbd5e83f549ba2c6d1ae7a338e7e053f34615. +That artifact has heartbeat_classes and stream findings, but does not contain +audit_core.emission_cadence or stream_instance_id filtering in either ingest +or PostgreSQL. Its database ledger reports migrations 0001-events through +0006-chain. Source adds additive 0007-import-receipts. Applying registry cadence +alone to that old image is not receiver cadence admission. + +Release gates: verify backup and rollback position; build/test current committed +source and pin its immutable image; run the migration Job with the existing +migration lease (runtime cannot migrate); verify ledger 0007; update the receiver +image without widening selectors/credentials; wait readyz and native feature +proof. Then the protected metadata CAS, exact ESO readback and receiver reload +must agree on the admitted declaration and class waits. Own-source counts, +wrong-scope refusals and actual heartbeat findings need native receipts. +No absence-of-finding claim proves completeness or adversarial omission. + +## Qonto receiver mapping + +rapp-qonto owns the collector, qonto-assistant owns declaration/classification, +and King's Guard owns independent observation. Proposed sender +rapp-qonto-collector permits source qonto-assistant, tenant tenant:friendly:binky, +subject qonto-assistant.audit, write true/read false, explicit redact policy. +Qonto source ownership must confirm that exact tenant binding. The mixed +sender is load-bearing; allow remains attributive in the per-class source +inventory. Stable original instance/sequence drives one retry-safe event ID. +Allow and deny retain their class; audit.heartbeat maps one-to-one to +audit-core.heartbeat, retains event_class audit.heartbeat, and asserts +`data.class=audit.deny`. It does not assert quiet allow activity. + +`deploy/qonto-collector-ingress.yaml` is prepared separately from the general +network policies. It admits only namespace rapp-qonto AND pod label +app.kubernetes.io/name=rapp-qonto-audit-collector to receiver port8080. +The endpoint is http://audit-core.audit-core.svc.cluster.local:8080. +Sender admission and collector egress remain independently required. No bank +credential or bank API operation is required. Retain sequence, instance, +source counters/window boundaries and receiver acceptance-time boundaries; +compare only after the explicitly bounded drain lag. Per-instance filtering +isolates instances but does not detect gaps on its own. + +AUDIT-WP-0013-T05 and NK-WP-0035 remain open until actual accepted records, +duplicate/refusal/reconciliation receipts and independent observer evidence +are available. Local tests and this prepared ingress are not those receipts. diff --git a/scripts/register-approval-engine-cadence.py b/scripts/register-approval-engine-cadence.py new file mode 100644 index 0000000..7b2d531 --- /dev/null +++ b/scripts/register-approval-engine-cadence.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Silent attended registry CAS; no rollout, no credential mint, no local secrets. + +Run through the existing warden attended --exec envelope. Default is preflight +only. --apply authorizes exactly the proposal's two metadata fields. Receipt +contains metadata only. Receiver ESO synchronization/restart is a separate gate. +""" +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import subprocess +import sys +from datetime import datetime, timezone + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from audit_core.cadence_registration import prepare, strict_json + +REGISTRY = "platform/data/workloads/audit-core/senders" + + +def bao(args, payload=None): + proc = subprocess.run(["bao", *args], input=None if payload is None else json.dumps(payload).encode(), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + if proc.returncode: + raise RuntimeError("bao_operation_failed") + return strict_json(proc.stdout) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--proposal", required=True, type=Path) + parser.add_argument("--declaration", required=True, type=Path) + parser.add_argument("--receipt", required=True, type=Path) + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + receipt = {"observed_at": datetime.now(timezone.utc).isoformat(), + "registry": "platform/workloads/audit-core/senders", + "credential_values_emitted": False, "applied": False} + try: + if Path.home().parent.name != ".warden-attended-login": + raise ValueError("attended_warden_envelope_required") + if any(os.environ.get(k) for k in ("BAO_TOKEN", "VAULT_TOKEN", "OPENBAO_TOKEN")): + raise ValueError("token_environment_forbidden") + identity = bao(["token", "lookup", "-format=json"])["data"] + policies = identity.get("policies", []) + if "platform-admin" not in policies or "root" in policies: + raise ValueError("attended_platform_admin_required") + raw = bao(["read", "-format=json", REGISTRY])["data"] + version = raw["metadata"]["version"] + body = raw["data"] + updated, detail = prepare(body, args.declaration.read_bytes(), strict_json(args.proposal.read_bytes())) + receipt.update(detail, registry_version_before=version) + if args.apply and detail["changed"]: + result = bao(["write", "-format=json", REGISTRY, "-"], + {"options": {"cas": version}, "data": updated}) + receipt["applied"] = True + receipt["request_id"] = result.get("request_id") + after = bao(["read", "-format=json", REGISTRY])["data"] + if after["metadata"]["version"] != version + 1 or after["data"] != updated: + raise RuntimeError("readback_mismatch") + receipt["registry_version_after"] = after["metadata"]["version"] + receipt["status"] = "registered" if args.apply else "preflight_only" + code = 0 + except Exception: + # Never propagate exceptions containing parser input or subprocess + # output: either may contain an incumbent registry credential. + receipt["status"] = "failed_closed" + code = 1 + args.receipt.parent.mkdir(parents=True, exist_ok=True) + args.receipt.write_text(json.dumps(receipt, indent=2) + "\n") + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cadence_registration.py b/tests/test_cadence_registration.py new file mode 100644 index 0000000..0e3891b --- /dev/null +++ b/tests/test_cadence_registration.py @@ -0,0 +1,98 @@ +"""Guarded registration never changes custody or unrelated registry state.""" +import copy +import hashlib +import json +from pathlib import Path + +import pytest + +from audit_core.cadence_registration import prepare, strict_json + + +def fixture(): + frame = {"frame_seconds": 3600} + entries = [{"source_id": n, "event_class": n, "form": "heartbeat-or-reconciliation", + "heartbeat": {"event_class": "audit-core.heartbeat", "interval_seconds": 3600, + "assertion": "nothing-to-report", "missing": "finding", + "extensions": {"approval-engine": {"asserted_class": n, + "requested_observer_max_gap_seconds": 7200, + "effective_deadline_seconds": 10800}}}} + for n in ("issuance", "use", "supersession", "revocation")] + entries.append({"source_id": "heartbeat", "event_class": "heartbeat", + "form": "heartbeat-or-reconciliation", "reconciliation": { + "compare_local": "local", "compare_observed": "receiver", "divergence": "finding"}}) + doc = {"schema_version": "0.1", "declaration_id": "test", "source": "approval-engine", + "sources": entries, "extensions": {"netkingdom": {"coordination_frame": frame}}} + raw = json.dumps(doc).encode() + proposal = {"sender": "approval-engine", "source": "approval-engine", "tenant": "tenant:platform", + "may_read": False, "preserve_existing_credentials_and_other_registry_fields": True, + "coordination_frame": frame, "emission_cadence_sha256": hashlib.sha256(raw).hexdigest(), + "heartbeat_classes": dict.fromkeys(("issuance", "use", "supersession", "revocation"),7200), + "receiver_grace_factor": 1.5, "effective_deadline_seconds": 10800} + rows = [{"name": "sibling", "sources": ["sibling"], "tokens": ["sibling-fixture"]}, + {"name": "approval-engine", "sources": ["approval-engine"], "tenants": ["tenant:platform"], + "tokens": ["current-fixture", "overlap-fixture"], "may_read": False, + "evidence_kind": "load-bearing", "secret_policy": "redact", "owner_property": {"keep": True}}] + return {"senders.json": json.dumps(rows), "unrelated": "preserve"}, raw, proposal + + +def test_preserves_every_non_metadata_field_and_inputs_and_is_idempotent(): + body, raw, proposal = fixture() + original = copy.deepcopy(body) + updated, receipt = prepare(body, raw, proposal) + before, after = json.loads(body["senders.json"]), json.loads(updated["senders.json"]) + assert body == original and updated["unrelated"] == "preserve" and before[0] == after[0] + assert {k:v for k,v in after[1].items() if k not in ("heartbeat_classes", "emission_cadence")} == before[1] + assert "fixture" not in json.dumps(receipt) + assert prepare(updated, raw, proposal)[0] == updated + assert prepare(updated, raw, proposal)[1]["changed"] is False + + +@pytest.mark.parametrize("mutation", ["digest", "read", "tenant", "class", "credentials", "duplicate", "absent"]) +def test_fail_closed(mutation): + body, raw, proposal = fixture() + rows = json.loads(body["senders.json"]) + if mutation == "digest": proposal["emission_cadence_sha256"] = "bad" + if mutation == "read": rows[1]["may_read"] = True + if mutation == "tenant": rows[1]["tenants"] = ["*"] + if mutation == "class": rows[1]["heartbeat_classes"] = {"must-not-drop": 3600} + if mutation == "credentials": rows[1]["tokens"] = [] + if mutation == "duplicate": rows.append(copy.deepcopy(rows[1])) + if mutation == "absent": rows.pop() + body["senders.json"] = json.dumps(rows) + with pytest.raises(ValueError): prepare(body, raw, proposal) + + +def test_duplicate_json_refused(): + with pytest.raises(ValueError): strict_json('{"name":1,"name":2}') + + +def test_real_owner_proposal_when_checkout_available(): + root = Path(__file__).resolve().parents[2] + proposal = root / "net-kingdom/generated/coordination-frame/approval-engine-receiver.json" + declaration = root / "approval-engine/cadence.yaml" + if not proposal.exists() or not declaration.exists(): pytest.skip("owner checkouts unavailable") + body, _, _ = fixture() + _, receipt = prepare(body, declaration.read_bytes(), strict_json(proposal.read_bytes())) + assert receipt["effective_deadline_seconds"] == 10800 + + +def test_qonto_proposal_retains_mixed_classification(): + from audit_core.cadence_registration import qonto_metadata + body, raw, proposal = fixture() + doc = strict_json(raw) + doc["source"] = "qonto-assistant" + doc["stream_id"] = "qonto-assistant.audit" + kinds = {"audit.allow": "attributive", "audit.deny": "load-bearing", "audit.heartbeat": "load-bearing"} + doc["sources"] = [{"source_id": name, "event_class": name, "form": "heartbeat-or-reconciliation", + "extensions": {"netkingdom": {"evidence_class": kind}}, + "heartbeat": {"event_class": "audit.heartbeat", "interval": "PT3600S", + "assertion": "nothing-to-report", "missing": "finding", + "extensions": {"qonto-assistant": {"assertion_scope": "no-deny-transitions-in-window"}}}} + for name, kind in kinds.items()] + entry = qonto_metadata(doc, "a" * 64) + assert "tokens" not in entry and entry["may_read"] is False + assert entry["heartbeat_classes"] == {"audit.deny": 7200} + assert entry["emission_cadence"] == doc + doc["sources"][0]["extensions"]["netkingdom"]["evidence_class"] = "load-bearing" + with pytest.raises(ValueError, match="classification"): qonto_metadata(doc, "a" * 64) diff --git a/workplans/AUDIT-WP-0013-cross-repo-sender-admission.md b/workplans/AUDIT-WP-0013-cross-repo-sender-admission.md index d4276dd..c24b6c9 100644 --- a/workplans/AUDIT-WP-0013-cross-repo-sender-admission.md +++ b/workplans/AUDIT-WP-0013-cross-repo-sender-admission.md @@ -8,7 +8,7 @@ status: blocked owner: codex topic_slug: audit-evidence-dependencies created: "2026-10-04" -updated: "2026-10-04" +updated: "2026-10-15" state_hub_workstream_id: "9e400de2-6ba1-520e-9b25-94aa2470e528" --- @@ -86,3 +86,17 @@ state_hub_task_id: "76b59808-abc3-5b8d-b31a-e3fb611f2cbe" ``` QONTO-WP-0006-T02. Audit-core owns admission and review of accepted mapping; rapp-qonto owns runtime delivery. Required inputs and proposed audit.allow/audit.deny/audit.heartbeat mapping are in docs/cross-repo-evidence-admission.md. Retain original stream instance, sequence and source counts. Approve deterministic retry IDs, heartbeat vouched-for class, authoritative tenant and acceptance-time/sequence-window comparison. Supply pinned collector/runtime artifacts, sanitized success/failure receipts and actual accepted records. Neither source stdout nor finished RAPP-QONTO-WP-0003 closes this gate. KG-WP-0005-T03 observer acceptance follows sender proof, never precedes it. + +### NK-WP-0035 receiver readiness, 2026-10-15 + +Prepared credential-preserving Approval Engine cadence transformation and silent +attended CAS wrapper; default f=3600, class waits7200, effective10800. Ten focused +preservation/refusal/idempotence tests pass. Prepared exact Qonto collector ingress +(namespace AND pod selector), contingent on source-owner tenant/mapping acceptance. +Read-only native metadata proves deployed ec15f63d artifact lacks cadence module +and process-instance filtering; database migration ledger is0006, source adds +additive0007-import-receipts. Current-source receiver release/migration is therefore +required before registration acceptance. See docs/cadence-registration.md for +execution gates. No protected registry was fetched or changed, no sender token +was minted and no live resource was mutated by this readiness work. T05 stays +wait pending source delivery and actual native custody/mapping receipts.