From d7133e3a0489373fa7c9fbf581e36509218e496b Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 4 Nov 2026 01:39:57 +0100 Subject: [PATCH] NK-WP-0056-T15: receipt schema v0.1, validator and executor fixtures; audit sender exercise - canon/schemas/execution-attribution-receipt_v0.1.schema.json: the bounded-executor profile of the PCC v0.2 receipt, both audit-core events (started, receipt), every object closed. - tools/execution-attribution-receipt: EAR-1..EAR-10 validator, fixtures generated from the real executor (completed, reverted, completed-on-retry) with a drift check. - executor: started carries receipt_version, digests, steps_planned and data.window.not_after (audit-core AUDIT-WP-0018-T03 request); receipt carries receipt_version and initiator.iss (configured approver_issuer, else unknown). - bounded-executor/exercise_audit_sender.py: real executor and client against audit-core's real ingestion and the Git scope overlay: 202, duplicate 200, conflict 409 as a defect, wrong source/tenant/token refused and not stored, a receipt-less start flagged by its own window. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 523928@bnt-lap001 Assistant-Session: 6c737afb-9c14-4152-9f02-477214b9c350 --- Makefile | 5 +- bounded-executor/cli.py | 3 +- bounded-executor/executor.py | 19 +- bounded-executor/exercise_audit_sender.py | 289 ++++++++++++++++++ ...ution-attribution-receipt_v0.1.schema.json | 152 +++++++++ tools/execution-attribution-receipt/README.md | 36 +++ .../valid/completed-on-retry-receipt.json | 229 ++++++++++++++ .../valid/completed-on-retry-started.json | 24 ++ .../fixtures/valid/completed-receipt.json | 117 +++++++ .../fixtures/valid/completed-started.json | 24 ++ .../fixtures/valid/reverted-receipt.json | 173 +++++++++++ .../fixtures/valid/reverted-started.json | 24 ++ .../generate_fixtures.py | 139 +++++++++ .../tests/test_validate.py | 132 ++++++++ .../execution-attribution-receipt/validate.py | 147 +++++++++ 15 files changed, 1506 insertions(+), 7 deletions(-) create mode 100644 bounded-executor/exercise_audit_sender.py create mode 100644 canon/schemas/execution-attribution-receipt_v0.1.schema.json create mode 100644 tools/execution-attribution-receipt/README.md create mode 100644 tools/execution-attribution-receipt/fixtures/valid/completed-on-retry-receipt.json create mode 100644 tools/execution-attribution-receipt/fixtures/valid/completed-on-retry-started.json create mode 100644 tools/execution-attribution-receipt/fixtures/valid/completed-receipt.json create mode 100644 tools/execution-attribution-receipt/fixtures/valid/completed-started.json create mode 100644 tools/execution-attribution-receipt/fixtures/valid/reverted-receipt.json create mode 100644 tools/execution-attribution-receipt/fixtures/valid/reverted-started.json create mode 100644 tools/execution-attribution-receipt/generate_fixtures.py create mode 100644 tools/execution-attribution-receipt/tests/test_validate.py create mode 100644 tools/execution-attribution-receipt/validate.py diff --git a/Makefile b/Makefile index 7fdeab5..0fdca88 100644 --- a/Makefile +++ b/Makefile @@ -209,6 +209,9 @@ bounded-execution-admission-test: ## Run bounded-execution admission validator t bounded-execution-admission-check: ## Validate the example admissions (read-only, offline) python3 tools/bounded-execution-admission/validate.py validate examples/bounded-execution-admission/*.yaml +execution-attribution-receipt-test: ## Receipt schema v0.1 validator; fixtures match the executor (NK-WP-0056-T15) + python3 -m pytest tools/execution-attribution-receipt/tests + bounded-executor-test: ## Bounded executor RBAC and declaration tests; the pilot admission inside its declared grants (NK-WP-0056-T03) python3 -m pytest sso-mfa/k8s/bounded-executor bounded-executor/tests tests/test_executor_lanes.py python3 tools/service-identity/validate.py tools/service-identity/declarations/bounded-executor.yaml \ @@ -395,7 +398,7 @@ security-bootstrap-ui: security-bootstrap-metadata-init ## Serve local custody a openbao-init-unseal openbao-init-unseal-dry-run \ iam-profile-conformance-test playbook-contract-test posture-feedback-test \ custody-record-test service-identity-test service-identity-check security-tools \ - bounded-execution-admission-test bounded-execution-admission-check bounded-executor-test \ + bounded-execution-admission-test bounded-execution-admission-check bounded-executor-test execution-attribution-receipt-test \ emission-cadence-profile-test \ posture-feedback-reference security-scenario-compose-lightweight \ security-bootstrap-console-test security-bootstrap-scripts-syntax \ diff --git a/bounded-executor/cli.py b/bounded-executor/cli.py index 8f87fb4..b87a061 100644 --- a/bounded-executor/cli.py +++ b/bounded-executor/cli.py @@ -128,7 +128,8 @@ def build(cfg: dict, logins: list): alert=alert) config = X.Config(executor_identity=cfg["executor_identity"], runtime_principal=cfg["runtime_principal"], tenant=cfg["tenant"], environment=cfg["environment"], - approver_subjects=dict(cfg["approver_subjects"])) + approver_subjects=dict(cfg["approver_subjects"]), + approver_issuer=cfg.get("approver_issuer", "unknown")) return config, deps diff --git a/bounded-executor/executor.py b/bounded-executor/executor.py index 8ad8120..6e7e6e5 100644 --- a/bounded-executor/executor.py +++ b/bounded-executor/executor.py @@ -43,6 +43,7 @@ _spec.loader.exec_module(bea) ACTION = "bounded-execution" STARTED = "execution.run.started" RECEIPT = "execution.attribution.receipt" +RECEIPT_VERSION = "0.1" # canon/schemas/execution-attribution-receipt_v0.1.schema.json class Refused(Exception): @@ -81,6 +82,7 @@ class Config: approver_subjects: dict[str, str] # admission approver name -> carrier subject_id (no guessing) source_root: pathlib.Path = REPO clock_source: str = "system-utc" + approver_issuer: str = "unknown" # the approvers' token issuer; recorded as `unknown` unless configured @dataclasses.dataclass @@ -320,8 +322,9 @@ class Run: def _receipt(self, outcome: str, attempts: int) -> dict: end = self.d.wall() return self._event(RECEIPT, occurred_at=end, body={ + "receipt_version": RECEIPT_VERSION, "run_id": self.run_id, - "initiator": {"sub": self.approver_subject, "role": "approver"}, + "initiator": {"iss": self.cfg.approver_issuer, "sub": self.approver_subject, "role": "approver"}, "executing_workload": self.cfg.executor_identity, "runtime_principal": self.cfg.runtime_principal, "tenant": self.cfg.tenant, "environment": self.cfg.environment, @@ -358,10 +361,16 @@ class Run: self.d.alert("consume_unconfirmed", {"admission": self.spec["id"], "run_id": self.run_id}) return {"outcome": "refused", "reason": str(refusal), "run_id": self.run_id, "effects": "consume-only"} try: - self.d.audit.emit(self._event(STARTED, {"run_id": self.run_id, "admission": self.spec["id"], - "approval": self.approval_id, "consumed_at": self.consumed_at, - "executing_workload": self.cfg.executor_identity}, - occurred_at=self.d.wall())) + self.d.audit.emit(self._event(STARTED, { + "receipt_version": RECEIPT_VERSION, "run_id": self.run_id, "admission": self.spec["id"], + "admission_digest": bea.admission_digest(self.doc), "plan_digest": self.spec["plan_digest"], + "approval": self.approval_id, "consumed_at": self.consumed_at, + "executing_workload": self.cfg.executor_identity, "runtime_principal": self.cfg.runtime_principal, + "steps_planned": len(self.spec["plan"]["steps"]), + # audit-core holds each run to its own window (AUDIT-WP-0018-T03): a started event without a receipt + # becomes a finding after the earlier of this and the sender's declared bound. + "window": {"not_after": iso(bea.parse_time(self.spec["window"]["not_after"]))}}, + occurred_at=self.d.wall())) except Exception: self.d.alert("started_not_in_custody", {"admission": self.spec["id"], "run_id": self.run_id}) return {"outcome": "stopped", "reason": "audit_unavailable", "run_id": self.run_id, "effects": "consume-only"} diff --git a/bounded-executor/exercise_audit_sender.py b/bounded-executor/exercise_audit_sender.py new file mode 100644 index 0000000..dda8aa9 --- /dev/null +++ b/bounded-executor/exercise_audit_sender.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""Executor audit sender against audit-core's real ingestion (NK-WP-0056-T15). + +audit-core's source (IngestionApplication, SQLite backend) is served on loopback with its sender registry built the +way production builds it: a Secret-shaped AUDIT_CORE_SENDERS entry plus the Git-declared scope overlay +deploy/senders-scope.json. The executor side is real: executor.Run.execute() on in-memory operations, emitting +through clients.AuditCore over HTTP with a token file. + +Checks: + - a completed run and a reverted run: started and receipt accepted (202), and both pairs pass the receipt validator + (tools/execution-attribution-receipt, schema v0.1) + - a retry of the same event is a duplicate (200); the same id with another body is a conflict (409), which the + client reports as a run-id-reuse defect + - wrong source, wrong tenant (`tenant:platform`), no token and a wrong token are refused; nothing is stored for them + - a started event whose admission window has passed, with no receipt, becomes an opening_without_closing finding + held to that window; the closed runs do not +Synthetic tokens only. Writes a value-free receipt. Exit 0 when every check passes. + + python3 bounded-executor/exercise_audit_sender.py --receipt FILE [--audit-core-source ~/audit-core] +""" +from __future__ import annotations + +import argparse +import copy +import datetime as dt +import hashlib +import importlib.util +import io +import json +import os +import pathlib +import secrets +import subprocess +import sys +import tempfile +import threading +import urllib.error +import urllib.request +import uuid +from wsgiref.simple_server import WSGIRequestHandler, make_server + +HERE = pathlib.Path(__file__).resolve().parent +REPO = HERE.parent +TOOL = REPO / "tools/execution-attribution-receipt" + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +G = _load("ear_generate", TOOL / "generate_fixtures.py") # loads executor.py as `executor` +V = _load("ear_validate", TOOL / "validate.py") +X = G.X +C = _load("clients", HERE / "clients.py") + + +def commit(path: pathlib.Path) -> str: + return subprocess.run(["git", "-C", str(path), "rev-parse", "HEAD"], capture_output=True, text=True, + check=True).stdout.strip() + + +def private_write(path: pathlib.Path, value: str) -> None: + with path.open("x") as out: + os.chmod(path, 0o600) + out.write(value) + + +def run_executor(name: str, audit, fail_checks=()) -> list[dict]: + """One real executor run on the example admission with a window around now; returns the events it emitted.""" + doc = G.X.bea.load(REPO / "examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml") + now = dt.datetime.now(dt.UTC).replace(microsecond=0) + doc["spec"]["id"] = name + doc["spec"]["window"] = {"not_before": (now - dt.timedelta(minutes=5)).isoformat().replace("+00:00", "Z"), + "not_after": (now + dt.timedelta(hours=2)).isoformat().replace("+00:00", "Z")} + doc["spec"]["budget"]["attempts"] = 1 # a failed check then ends the run as reverted, not retried + doc["spec"]["plan_digest"] = X.bea.plan_digest(doc) + fake = G.Fake(doc, fail_checks) + seen: list[dict] = [] + + class Tee: + def emit(self, event): + audit.emit(event) # raises unless audit-core stored it + seen.append(event) + + plan = doc["spec"]["plan"] + deps = X.Deps(carrier=fake, operations={s["operation"]: fake.op for s in plan["steps"] + plan["revert_steps"]}, + checks={c["id"]: fake.check for c in doc["spec"]["checks"]}, trust=lambda _s: True, audit=Tee(), + alert=lambda *_a: None, sleep=lambda _s: None) + cfg = X.Config(executor_identity=doc["spec"]["executor"]["identity"], + runtime_principal="system:serviceaccount:sso:nk-bind-executor", tenant="platform", + environment="TEST", approver_subjects={"admin": "kc-sub-0f3a"}, + approver_issuer="https://kc.coulomb.social") + run = X.Run(doc, G.APPROVAL, cfg, deps) + run.preflight = lambda: run._check_approval(fake.get(G.APPROVAL), consumed_ok=False) # pins are the example's + result = run.execute() + assert result["outcome"] in ("completed", "reverted") and "reason" not in result, result.get("reason") + if G.MARKER in json.dumps(seen): + raise RuntimeError("an operation value reached an event") + return seen + + +def exercise(args) -> dict: + sys.path.insert(0, str(args.audit_core_source)) + from audit_core.ingestion import IngestionApplication + from audit_core.senders import SenderRegistry + from audit_core.sqlite_backend import SQLiteAuditBackend + + scope = args.audit_core_source / "deploy/senders-scope.json" + receipt = { + "schema": "netkingdom.bounded-executor.audit-sender-exercise/v1", + "task": "NK-WP-0056-T15", + "target": "disposable local processes; synthetic tokens", + "started_at": dt.datetime.now(dt.UTC).isoformat(timespec="seconds"), + "audit_core_commit": commit(args.audit_core_source), + "senders_scope_sha256": hashlib.sha256(scope.read_bytes()).hexdigest(), + "net_kingdom_commit": commit(REPO), + "executor_source_sha256": {n: hashlib.sha256((HERE / n).read_bytes()).hexdigest() + for n in ("executor.py", "clients.py")}, + "receipt_schema_sha256": hashlib.sha256(V.SCHEMA.read_bytes()).hexdigest(), + "limitations": [ + "audit-core runs from source in-process (SQLite backend), not the released receiver image", + "the executor's operations are in-memory; the bind rotation itself is T12's synthetic pilot", + "plain HTTP on loopback; the live path is the ESO-delivered bearer over the sso ingress (T16)", + ], + "checks": {}, + } + checks = receipt["checks"] + tokens = {"executor": secrets.token_urlsafe(32), "operator": secrets.token_urlsafe(32), + "other": secrets.token_urlsafe(32)} + with tempfile.TemporaryDirectory(prefix="nk56-t15-") as temporary: + root = pathlib.Path(temporary) + registry = SenderRegistry.from_env({ + # Minted with the defaults on purpose, as audit-core's own test does: the Git overlay must narrow it. + "AUDIT_CORE_SENDERS": json.dumps([ + {"name": "nk-bounded-executor", "tokens": [tokens["executor"]], "sources": ["*"], "tenants": ["*"]}, + {"name": "user-engine", "tokens": [tokens["other"]], "sources": ["user-engine"]}, + {"name": "operator", "tokens": [tokens["operator"]], "sources": ["*"], "tenants": ["*"], + "may_write": False, "may_read": True}]), + "AUDIT_CORE_SENDERS_SCOPE_PATH": str(scope)}) + backend = SQLiteAuditBackend(str(root / "audit.db")) + app = IngestionApplication(backend, registry) + + class Quiet(WSGIRequestHandler): + def log_message(self, *_args): + pass + + server = make_server("127.0.0.1", 0, app, handler_class=Quiet) + threading.Thread(target=server.serve_forever, daemon=True).start() + base = f"http://127.0.0.1:{server.server_port}" + try: + for name, value in tokens.items(): + private_write(root / f"{name}.token", value + "\n") + audit = C.AuditCore(base, str(root / "executor.token")) + + def raw(event, token: str | None = "executor", path="/v1/events", method="POST"): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = "Bearer " + (root / f"{token}.token").read_text().strip() \ + if token in tokens else "Bearer " + token + if event is not None: + headers["Idempotency-Key"] = event["id"] + req = urllib.request.Request(base + path, method=method, headers=headers, + data=json.dumps(event).encode() if event is not None else None) + try: + with urllib.request.urlopen(req, timeout=5) as response: + return response.status, json.loads(response.read() or b"{}") + except urllib.error.HTTPError as error: + with error: + return error.code, json.loads(error.read() or b"{}") + + # ---- real runs through the real client + first_status = [] + original = audit.http.call + + def recording(method, path, body=None, headers=None, scope=None): + status, data = original(method, path, body, headers, scope) + first_status.append(status) + return status, data + + audit.http.call = recording + completed = run_executor("BEA-2026-9201", audit) + reverted = run_executor("BEA-2026-9202", audit, fail_checks=["synthetic-journey"]) + audit.http.call = original + assert first_status == [202, 202, 202, 202], first_status + assert completed[1]["data"]["outcome"] == "completed" and reverted[1]["data"]["outcome"] == "reverted" + checks["completed_run_started_and_receipt_accepted_202"] = True + checks["reverted_run_started_and_receipt_accepted_202"] = True + for started, final in (completed, reverted): + assert V.evaluate_pair(started, final) == {"valid": True, "findings": []} + checks["emitted_pairs_pass_receipt_schema_v0_1"] = True + + assert raw(completed[0])[0] == 200 and raw(completed[1])[0] == 200 + audit.emit(completed[1]) # the client's own retry path counts 200 as stored + checks["retry_is_duplicate_200"] = True + + changed = copy.deepcopy(completed[1]) + changed["data"]["outcome"] = "stopped" + assert raw(changed)[0] == 409 + try: + audit.emit(changed) + except C.ClientError as error: + assert "409" in str(error) and "run id reused" in str(error) + else: + raise AssertionError("conflict accepted") + checks["same_id_other_body_conflict_409_reported_as_defect"] = True + + def fresh(event, **over): + e = copy.deepcopy(event) + rid = str(uuid.uuid4()) + e.update(id=f"run:{rid}:started", correlation_id=rid, **over) + e["data"]["run_id"] = rid + return e + + refused = { + "wrong_source": (fresh(completed[0], source="user-engine"), "executor"), + "wrong_tenant": (fresh(completed[0], tenant="tenant:platform"), "executor"), + "other_sender_token": (fresh(completed[0]), "other"), + "no_token": (fresh(completed[0]), None), + "unknown_token": (fresh(completed[0]), "synthetic-unknown-token"), + } + for label, (event, token) in refused.items(): + status, _ = raw(event, token) + assert status in (400, 401, 403), (label, status) + for event, _ in refused.values(): # nothing stored under a refused event's run id + status, body = raw(None, "operator", "/v1/events?correlation_id=" + event["correlation_id"], "GET") + assert status == 200 and body["events"] == [], status + status, body = raw(None, "operator", "/v1/events?correlation_id=" + completed[0]["correlation_id"], "GET") + assert status == 200 and len(body["events"]) == 2, status # while the accepted run's events are held + checks["wrong_source_tenant_or_token_refused_and_not_stored"] = True + + # ---- a started event without a receipt, whose admission window closed two hours ago + orphan = fresh(completed[0]) + orphan["data"]["window"]["not_after"] = (dt.datetime.now(dt.UTC) - dt.timedelta(hours=2)) \ + .isoformat(timespec="seconds").replace("+00:00", "Z") + audit.emit(orphan) + status, body = raw(None, "operator", "/v1/stream-findings", "GET") + assert status == 200 + opened = [f for f in body.get("stream_findings", []) if f.get("kind") == "opening_without_closing" + and f.get("class") == "execution.run.started"] + flagged = json.dumps(opened) + mine = [f for f in opened if f.get("correlation_id") == orphan["correlation_id"]] + assert len(mine) == 1 and mine[0]["bound"] == "window.not_after", "orphan not flagged by its window" + assert completed[0]["correlation_id"] not in flagged and reverted[0]["correlation_id"] not in flagged + checks["started_without_receipt_flagged_by_its_own_window"] = True + checks["closed_runs_not_flagged"] = True + finally: + server.shutdown() + server.server_close() + + receipt.update(status="passed", cleanup_complete=True, + finished_at=dt.datetime.now(dt.UTC).isoformat(timespec="seconds")) + if any(v in json.dumps(receipt) for v in tokens.values()): + raise RuntimeError("receipt would carry a generated value") + return receipt + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument("--audit-core-source", type=pathlib.Path, default=pathlib.Path.home() / "audit-core") + parser.add_argument("--receipt", required=True, type=pathlib.Path) + parser.add_argument("--trace", action="store_true", help="print the failing line (no values)") + args = parser.parse_args(argv) + if args.receipt.exists(): + raise SystemExit("receipt path must be new") + try: + receipt = exercise(args) + except Exception as error: + where = "" + if args.trace: + tb, line = error.__traceback__, None + while tb: + if tb.tb_frame.f_code.co_filename == __file__: + line = tb.tb_lineno + tb = tb.tb_next + detail = str(error) if isinstance(error, AssertionError) else "" # own messages: statuses, labels + where = f" at line {line} {detail}".rstrip() + print(f"Audit sender exercise failed: {type(error).__name__}{where}", file=sys.stderr) + return 1 + private_write(args.receipt, json.dumps(receipt, indent=2) + "\n") + print(f"Audit sender exercise passed: {len(receipt['checks'])} checks; temporary resources removed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/canon/schemas/execution-attribution-receipt_v0.1.schema.json b/canon/schemas/execution-attribution-receipt_v0.1.schema.json new file mode 100644 index 0000000..e5a45f2 --- /dev/null +++ b/canon/schemas/execution-attribution-receipt_v0.1.schema.json @@ -0,0 +1,152 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://netkingdom.io/schemas/execution-attribution-receipt_v0.1.schema.json", + "title": "NetKingdom Execution Attribution Receipt v0.1 (bounded-executor profile)", + "description": "The two audit-core events of one bounded-executor run: execution.run.started and execution.attribution.receipt, in audit-core's eight-field ingest envelope (audit-core docs/event-envelope.md, docs/bounded-executor-receipts.md). Fields follow Playbook Capability Contract v0.2 'Execution Attribution Receipt' and BEA v0.1 section 6. Structure only; the cross-field rules (EAR-1..EAR-10) are in tools/execution-attribution-receipt/validate.py. Values handled by operations never appear: steps carry identifiers, outcomes, check results and error types only.", + "oneOf": [ + {"$ref": "#/$defs/started_event"}, + {"$ref": "#/$defs/receipt_event"} + ], + "$defs": { + "run_id": {"type": "string", "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"}, + "admission_id": {"type": "string", "pattern": "^BEA-[0-9]{4}-[0-9]{4}$"}, + "time": {"type": "string", "format": "date-time", "pattern": "(Z|[+-][0-9]{2}:[0-9]{2})$"}, + "identifier": {"type": "string", "minLength": 1, "maxLength": 200, "pattern": "^[A-Za-z0-9][A-Za-z0-9:._/@=,+-]*$"}, + "envelope": { + "type": "object", + "required": ["id", "type", "correlation_id", "subject", "source", "tenant", "occurred_at", "data"], + "properties": { + "id": {"type": "string", "pattern": "^run:[0-9a-f-]{36}:(started|receipt)$"}, + "correlation_id": {"$ref": "#/$defs/run_id"}, + "subject": {"type": "string", "pattern": "^admission:BEA-[0-9]{4}-[0-9]{4}$"}, + "source": {"const": "net-kingdom.bounded-executor"}, + "tenant": {"$ref": "#/$defs/identifier"}, + "occurred_at": {"$ref": "#/$defs/time"} + } + }, + "started_event": { + "allOf": [{"$ref": "#/$defs/envelope"}], + "type": "object", + "additionalProperties": false, + "properties": { + "id": true, "correlation_id": true, "subject": true, "source": true, "tenant": true, "occurred_at": true, + "type": {"const": "execution.run.started"}, + "data": { + "type": "object", + "additionalProperties": false, + "required": ["receipt_version", "run_id", "admission", "admission_digest", "plan_digest", "approval", + "consumed_at", "executing_workload", "runtime_principal", "steps_planned", "window"], + "properties": { + "receipt_version": {"const": "0.1"}, + "run_id": {"$ref": "#/$defs/run_id"}, + "admission": {"$ref": "#/$defs/admission_id"}, + "admission_digest": {"$ref": "#/$defs/digest"}, + "plan_digest": {"$ref": "#/$defs/digest"}, + "approval": {"$ref": "#/$defs/identifier"}, + "consumed_at": {"anyOf": [{"$ref": "#/$defs/time"}, {"type": "null"}]}, + "executing_workload": {"$ref": "#/$defs/identifier"}, + "runtime_principal": {"$ref": "#/$defs/identifier"}, + "steps_planned": {"type": "integer", "minimum": 1}, + "window": { + "type": "object", "additionalProperties": false, "required": ["not_after"], + "properties": {"not_after": {"$ref": "#/$defs/time"}} + } + } + } + } + }, + "check_result": { + "type": "object", "additionalProperties": false, "required": ["id", "result"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "result": {"enum": ["pass", "fail", "budget"]} + } + }, + "step": { + "type": "object", + "additionalProperties": false, + "required": ["id", "kind", "operation", "target", "consumer", "outcome", "checks"], + "properties": { + "id": {"$ref": "#/$defs/identifier"}, + "kind": {"enum": ["step", "revert"]}, + "operation": {"$ref": "#/$defs/identifier"}, + "target": {"$ref": "#/$defs/identifier"}, + "consumer": {"$ref": "#/$defs/identifier"}, + "outcome": {"enum": ["done", "check_failed", "failed", "unknown", "error"]}, + "checks": {"type": "array", "items": {"$ref": "#/$defs/check_result"}}, + "error_type": {"type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]{0,79}$"} + } + }, + "decision_ref": { + "type": "object", "additionalProperties": false, "required": ["label", "ref"], + "properties": { + "label": {"enum": ["admission", "admission_digest", "approval"]}, + "ref": {"$ref": "#/$defs/identifier"} + } + }, + "budget_values": { + "type": "object", "additionalProperties": false, "required": ["wall_time_seconds", "attempts"], + "properties": { + "wall_time_seconds": {"type": "number", "minimum": 0}, + "attempts": {"type": "integer", "minimum": 0} + } + }, + "receipt_event": { + "allOf": [{"$ref": "#/$defs/envelope"}], + "type": "object", + "additionalProperties": false, + "properties": { + "id": true, "correlation_id": true, "subject": true, "source": true, "tenant": true, "occurred_at": true, + "type": {"const": "execution.attribution.receipt"}, + "data": { + "type": "object", + "additionalProperties": false, + "required": ["receipt_version", "run_id", "initiator", "executing_workload", "runtime_principal", "tenant", + "environment", "source_commit", "artifact_digest", "target", "action", "decision_refs", "pdp", + "time_interval", "outcome", "reverted", "attempts", "budget", "steps"], + "properties": { + "receipt_version": {"const": "0.1"}, + "run_id": {"$ref": "#/$defs/run_id"}, + "initiator": { + "type": "object", "additionalProperties": false, "required": ["iss", "sub", "role"], + "properties": { + "iss": {"anyOf": [{"const": "unknown"}, {"type": "string", "pattern": "^https://[^\\s]+$"}]}, + "sub": {"$ref": "#/$defs/identifier"}, + "role": {"const": "approver"} + } + }, + "delegation": {"$ref": "#/$defs/identifier"}, + "executing_workload": {"$ref": "#/$defs/identifier"}, + "runtime_principal": {"$ref": "#/$defs/identifier"}, + "tenant": {"$ref": "#/$defs/identifier"}, + "environment": {"$ref": "#/$defs/identifier"}, + "source_commit": {"type": "string", "pattern": "^[0-9a-f]{7,40}$"}, + "artifact_digest": {"$ref": "#/$defs/digest"}, + "target": {"type": "string", "pattern": "^admission:BEA-[0-9]{4}-[0-9]{4}$"}, + "action": {"const": "bounded-execution"}, + "decision_refs": {"type": "array", "minItems": 3, "maxItems": 3, "items": {"$ref": "#/$defs/decision_ref"}}, + "pdp": {"enum": ["not-evaluated", "allow"]}, + "time_interval": { + "type": "object", "additionalProperties": false, "required": ["start", "end", "clock_source"], + "properties": { + "start": {"$ref": "#/$defs/time"}, + "end": {"$ref": "#/$defs/time"}, + "clock_source": {"$ref": "#/$defs/identifier"}, + "bound": {"type": "number", "exclusiveMinimum": 0} + } + }, + "outcome": {"enum": ["completed", "reverted", "revert_failed", "stopped", "unknown", "budget"]}, + "reverted": {"type": "boolean"}, + "attempts": {"type": "integer", "minimum": 1}, + "budget": { + "type": "object", "additionalProperties": false, "required": ["limit", "used"], + "properties": {"limit": {"$ref": "#/$defs/budget_values"}, "used": {"$ref": "#/$defs/budget_values"}} + }, + "steps": {"type": "array", "items": {"$ref": "#/$defs/step"}} + } + } + } + } + } +} diff --git a/tools/execution-attribution-receipt/README.md b/tools/execution-attribution-receipt/README.md new file mode 100644 index 0000000..c96b841 --- /dev/null +++ b/tools/execution-attribution-receipt/README.md @@ -0,0 +1,36 @@ +# Execution Attribution Receipt validator (NK-WP-0056-T15) + +Schema `canon/schemas/execution-attribution-receipt_v0.1.schema.json` covers the bounded-executor profile of the +Playbook Capability Contract v0.2 receipt: the two events one executor run emits to audit-core, in audit-core's ingest +envelope. + +- `execution.run.started` is sent after consume, before the first step. It carries `data.window.not_after`, so + audit-core holds the run to its own admission window (AUDIT-WP-0018-T03). +- `execution.attribution.receipt` is one per run, with every step, check result, revert and the budget used. + +```bash +python3 tools/execution-attribution-receipt/validate.py validate EVENT.json... # EAR-1..EAR-9 per event +python3 tools/execution-attribution-receipt/validate.py pair STARTED.json RECEIPT.json # plus EAR-10 across the pair +python3 tools/execution-attribution-receipt/generate_fixtures.py [--check] # fixtures from the real executor +make execution-attribution-receipt-test +``` + +The rules are listed in `validate.py`. Findings name a rule and a location, never a value. The schema closes every +object (`additionalProperties: false`), so no field can carry a value an operation handled. Steps record identifiers, +outcomes, check results and an error *type* only. + +`fixtures/valid/` holds three scenarios: `completed`, `reverted` and `completed-on-retry`. They are generated by +running `executor.Run` on in-memory fakes, and a test fails when they drift from what the executor emits. audit-core +can use them for AUDIT-WP-0014-T02 (canonical receipt validation). + +Profile decisions (audit-core's open points in `docs/bounded-executor-receipts.md`): + +- `target` is `admission:` and `action` is `bounded-execution` at receipt level. Per-step targets stay in + `steps[]`. +- `decision_refs` are exactly `admission`, `admission_digest` and `approval`, as `{label, ref}`. +- `initiator.iss` is the approvers' issuer when configured, else the explicit `unknown`. +- `delegation` is absent: the pilot has no delegated initiator. +- `tenant` is the audit-core tenant (`platform`) and must equal the envelope tenant. +- `pdp` is `not-evaluated` in the pilot. + +Not covered: the railiance-infra S1 runner profile (NK-WP-0040-T02), which waits on its own emitter decision. diff --git a/tools/execution-attribution-receipt/fixtures/valid/completed-on-retry-receipt.json b/tools/execution-attribution-receipt/fixtures/valid/completed-on-retry-receipt.json new file mode 100644 index 0000000..93d49b0 --- /dev/null +++ b/tools/execution-attribution-receipt/fixtures/valid/completed-on-retry-receipt.json @@ -0,0 +1,229 @@ +{ + "correlation_id": "2d906c74-5e3f-4b80-8cb2-3d4e5f607182", + "data": { + "action": "bounded-execution", + "artifact_digest": "sha256:df214e02c24e70c6ac799bee873862f0d24d9bd537bf77cfb845a7b880c7565b", + "attempts": 2, + "budget": { + "limit": { + "attempts": 2, + "wall_time_seconds": 1200 + }, + "used": { + "attempts": 2, + "wall_time_seconds": 84.0 + } + }, + "decision_refs": [ + { + "label": "admission", + "ref": "BEA-2026-0001" + }, + { + "label": "admission_digest", + "ref": "sha256:9bf8633a4ef9d555bf9cc69c8102081332ecc386d28675b1f3beabb953da0c43" + }, + { + "label": "approval", + "ref": "6f1c2b9e-0a4d-4e3b-9c51-7d2f8a6b3e10" + } + ], + "environment": "PRODUCTION", + "executing_workload": "service:nk-bind-executor", + "initiator": { + "iss": "https://kc.coulomb.social", + "role": "approver", + "sub": "kc-sub-0f3a" + }, + "outcome": "completed", + "pdp": "not-evaluated", + "receipt_version": "0.1", + "reverted": true, + "run_id": "2d906c74-5e3f-4b80-8cb2-3d4e5f607182", + "runtime_principal": "system:serviceaccount:sso:nk-bind-executor", + "source_commit": "15c9cdbef881", + "steps": [ + { + "checks": [ + { + "id": "kv-advanced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "generate", + "kind": "step", + "operation": "openbao.kv.write-next-version", + "outcome": "done", + "target": "openbao:platform/workloads/net-kingdom/directory-binds/svc-authelia" + }, + { + "checks": [ + { + "id": "bind-signs-in", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "set-directory", + "kind": "step", + "operation": "lldap.user.change-own-password-from-kv", + "outcome": "done", + "target": "lldap:svc-authelia" + }, + { + "checks": [ + { + "id": "eso-synced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "deliver", + "kind": "step", + "operation": "eso.force-sync", + "outcome": "done", + "target": "k8s:sso/externalsecret/authelia-secrets" + }, + { + "checks": [ + { + "id": "synthetic-journey", + "result": "fail" + } + ], + "consumer": "authelia", + "id": "restart", + "kind": "step", + "operation": "k8s.rollout-restart", + "outcome": "check_failed", + "target": "k8s:sso/deployment/authelia" + }, + { + "checks": [ + { + "id": "kv-advanced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "restore-kv", + "kind": "revert", + "operation": "openbao.kv.write-previous-value-as-next-version", + "outcome": "done", + "target": "openbao:platform/workloads/net-kingdom/directory-binds/svc-authelia" + }, + { + "checks": [ + { + "id": "bind-signs-in", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "restore-directory", + "kind": "revert", + "operation": "lldap.user.change-own-password-from-kv", + "outcome": "done", + "target": "lldap:svc-authelia" + }, + { + "checks": [ + { + "id": "eso-synced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "redeliver", + "kind": "revert", + "operation": "eso.force-sync", + "outcome": "done", + "target": "k8s:sso/externalsecret/authelia-secrets" + }, + { + "checks": [ + { + "id": "synthetic-journey", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "restart-again", + "kind": "revert", + "operation": "k8s.rollout-restart", + "outcome": "done", + "target": "k8s:sso/deployment/authelia" + }, + { + "checks": [ + { + "id": "kv-advanced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "generate", + "kind": "step", + "operation": "openbao.kv.write-next-version", + "outcome": "done", + "target": "openbao:platform/workloads/net-kingdom/directory-binds/svc-authelia" + }, + { + "checks": [ + { + "id": "bind-signs-in", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "set-directory", + "kind": "step", + "operation": "lldap.user.change-own-password-from-kv", + "outcome": "done", + "target": "lldap:svc-authelia" + }, + { + "checks": [ + { + "id": "eso-synced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "deliver", + "kind": "step", + "operation": "eso.force-sync", + "outcome": "done", + "target": "k8s:sso/externalsecret/authelia-secrets" + }, + { + "checks": [ + { + "id": "synthetic-journey", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "restart", + "kind": "step", + "operation": "k8s.rollout-restart", + "outcome": "done", + "target": "k8s:sso/deployment/authelia" + } + ], + "target": "admission:BEA-2026-0001", + "tenant": "platform", + "time_interval": { + "clock_source": "system-utc", + "end": "2026-11-03T09:31:24Z", + "start": "2026-11-03T09:30:00Z" + } + }, + "id": "run:2d906c74-5e3f-4b80-8cb2-3d4e5f607182:receipt", + "occurred_at": "2026-11-03T09:31:24+00:00", + "source": "net-kingdom.bounded-executor", + "subject": "admission:BEA-2026-0001", + "tenant": "platform", + "type": "execution.attribution.receipt" +} diff --git a/tools/execution-attribution-receipt/fixtures/valid/completed-on-retry-started.json b/tools/execution-attribution-receipt/fixtures/valid/completed-on-retry-started.json new file mode 100644 index 0000000..a7ba3ef --- /dev/null +++ b/tools/execution-attribution-receipt/fixtures/valid/completed-on-retry-started.json @@ -0,0 +1,24 @@ +{ + "correlation_id": "2d906c74-5e3f-4b80-8cb2-3d4e5f607182", + "data": { + "admission": "BEA-2026-0001", + "admission_digest": "sha256:9bf8633a4ef9d555bf9cc69c8102081332ecc386d28675b1f3beabb953da0c43", + "approval": "6f1c2b9e-0a4d-4e3b-9c51-7d2f8a6b3e10", + "consumed_at": "2026-11-03T09:30:00Z", + "executing_workload": "service:nk-bind-executor", + "plan_digest": "sha256:df214e02c24e70c6ac799bee873862f0d24d9bd537bf77cfb845a7b880c7565b", + "receipt_version": "0.1", + "run_id": "2d906c74-5e3f-4b80-8cb2-3d4e5f607182", + "runtime_principal": "system:serviceaccount:sso:nk-bind-executor", + "steps_planned": 4, + "window": { + "not_after": "2026-11-03T11:00:00Z" + } + }, + "id": "run:2d906c74-5e3f-4b80-8cb2-3d4e5f607182:started", + "occurred_at": "2026-11-03T09:30:00+00:00", + "source": "net-kingdom.bounded-executor", + "subject": "admission:BEA-2026-0001", + "tenant": "platform", + "type": "execution.run.started" +} diff --git a/tools/execution-attribution-receipt/fixtures/valid/completed-receipt.json b/tools/execution-attribution-receipt/fixtures/valid/completed-receipt.json new file mode 100644 index 0000000..854e308 --- /dev/null +++ b/tools/execution-attribution-receipt/fixtures/valid/completed-receipt.json @@ -0,0 +1,117 @@ +{ + "correlation_id": "0b7e4a52-3c1d-4f6e-8a90-1b2c3d4e5f60", + "data": { + "action": "bounded-execution", + "artifact_digest": "sha256:df214e02c24e70c6ac799bee873862f0d24d9bd537bf77cfb845a7b880c7565b", + "attempts": 1, + "budget": { + "limit": { + "attempts": 1, + "wall_time_seconds": 1200 + }, + "used": { + "attempts": 1, + "wall_time_seconds": 28.0 + } + }, + "decision_refs": [ + { + "label": "admission", + "ref": "BEA-2026-0001" + }, + { + "label": "admission_digest", + "ref": "sha256:bbaa3afb2885cc1dd8a4b00f48f3f7265aaf05d4c2f03479fb935c44e5bf013b" + }, + { + "label": "approval", + "ref": "6f1c2b9e-0a4d-4e3b-9c51-7d2f8a6b3e10" + } + ], + "environment": "PRODUCTION", + "executing_workload": "service:nk-bind-executor", + "initiator": { + "iss": "https://kc.coulomb.social", + "role": "approver", + "sub": "kc-sub-0f3a" + }, + "outcome": "completed", + "pdp": "not-evaluated", + "receipt_version": "0.1", + "reverted": false, + "run_id": "0b7e4a52-3c1d-4f6e-8a90-1b2c3d4e5f60", + "runtime_principal": "system:serviceaccount:sso:nk-bind-executor", + "source_commit": "15c9cdbef881", + "steps": [ + { + "checks": [ + { + "id": "kv-advanced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "generate", + "kind": "step", + "operation": "openbao.kv.write-next-version", + "outcome": "done", + "target": "openbao:platform/workloads/net-kingdom/directory-binds/svc-authelia" + }, + { + "checks": [ + { + "id": "bind-signs-in", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "set-directory", + "kind": "step", + "operation": "lldap.user.change-own-password-from-kv", + "outcome": "done", + "target": "lldap:svc-authelia" + }, + { + "checks": [ + { + "id": "eso-synced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "deliver", + "kind": "step", + "operation": "eso.force-sync", + "outcome": "done", + "target": "k8s:sso/externalsecret/authelia-secrets" + }, + { + "checks": [ + { + "id": "synthetic-journey", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "restart", + "kind": "step", + "operation": "k8s.rollout-restart", + "outcome": "done", + "target": "k8s:sso/deployment/authelia" + } + ], + "target": "admission:BEA-2026-0001", + "tenant": "platform", + "time_interval": { + "clock_source": "system-utc", + "end": "2026-11-03T09:30:28Z", + "start": "2026-11-03T09:30:00Z" + } + }, + "id": "run:0b7e4a52-3c1d-4f6e-8a90-1b2c3d4e5f60:receipt", + "occurred_at": "2026-11-03T09:30:28+00:00", + "source": "net-kingdom.bounded-executor", + "subject": "admission:BEA-2026-0001", + "tenant": "platform", + "type": "execution.attribution.receipt" +} diff --git a/tools/execution-attribution-receipt/fixtures/valid/completed-started.json b/tools/execution-attribution-receipt/fixtures/valid/completed-started.json new file mode 100644 index 0000000..2ef956d --- /dev/null +++ b/tools/execution-attribution-receipt/fixtures/valid/completed-started.json @@ -0,0 +1,24 @@ +{ + "correlation_id": "0b7e4a52-3c1d-4f6e-8a90-1b2c3d4e5f60", + "data": { + "admission": "BEA-2026-0001", + "admission_digest": "sha256:bbaa3afb2885cc1dd8a4b00f48f3f7265aaf05d4c2f03479fb935c44e5bf013b", + "approval": "6f1c2b9e-0a4d-4e3b-9c51-7d2f8a6b3e10", + "consumed_at": "2026-11-03T09:30:00Z", + "executing_workload": "service:nk-bind-executor", + "plan_digest": "sha256:df214e02c24e70c6ac799bee873862f0d24d9bd537bf77cfb845a7b880c7565b", + "receipt_version": "0.1", + "run_id": "0b7e4a52-3c1d-4f6e-8a90-1b2c3d4e5f60", + "runtime_principal": "system:serviceaccount:sso:nk-bind-executor", + "steps_planned": 4, + "window": { + "not_after": "2026-11-03T11:00:00Z" + } + }, + "id": "run:0b7e4a52-3c1d-4f6e-8a90-1b2c3d4e5f60:started", + "occurred_at": "2026-11-03T09:30:00+00:00", + "source": "net-kingdom.bounded-executor", + "subject": "admission:BEA-2026-0001", + "tenant": "platform", + "type": "execution.run.started" +} diff --git a/tools/execution-attribution-receipt/fixtures/valid/reverted-receipt.json b/tools/execution-attribution-receipt/fixtures/valid/reverted-receipt.json new file mode 100644 index 0000000..b9d7b31 --- /dev/null +++ b/tools/execution-attribution-receipt/fixtures/valid/reverted-receipt.json @@ -0,0 +1,173 @@ +{ + "correlation_id": "1c8f5b63-4d2e-4a7f-9ba1-2c3d4e5f6071", + "data": { + "action": "bounded-execution", + "artifact_digest": "sha256:df214e02c24e70c6ac799bee873862f0d24d9bd537bf77cfb845a7b880c7565b", + "attempts": 1, + "budget": { + "limit": { + "attempts": 1, + "wall_time_seconds": 1200 + }, + "used": { + "attempts": 1, + "wall_time_seconds": 56.0 + } + }, + "decision_refs": [ + { + "label": "admission", + "ref": "BEA-2026-0001" + }, + { + "label": "admission_digest", + "ref": "sha256:bbaa3afb2885cc1dd8a4b00f48f3f7265aaf05d4c2f03479fb935c44e5bf013b" + }, + { + "label": "approval", + "ref": "6f1c2b9e-0a4d-4e3b-9c51-7d2f8a6b3e10" + } + ], + "environment": "PRODUCTION", + "executing_workload": "service:nk-bind-executor", + "initiator": { + "iss": "https://kc.coulomb.social", + "role": "approver", + "sub": "kc-sub-0f3a" + }, + "outcome": "reverted", + "pdp": "not-evaluated", + "receipt_version": "0.1", + "reverted": true, + "run_id": "1c8f5b63-4d2e-4a7f-9ba1-2c3d4e5f6071", + "runtime_principal": "system:serviceaccount:sso:nk-bind-executor", + "source_commit": "15c9cdbef881", + "steps": [ + { + "checks": [ + { + "id": "kv-advanced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "generate", + "kind": "step", + "operation": "openbao.kv.write-next-version", + "outcome": "done", + "target": "openbao:platform/workloads/net-kingdom/directory-binds/svc-authelia" + }, + { + "checks": [ + { + "id": "bind-signs-in", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "set-directory", + "kind": "step", + "operation": "lldap.user.change-own-password-from-kv", + "outcome": "done", + "target": "lldap:svc-authelia" + }, + { + "checks": [ + { + "id": "eso-synced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "deliver", + "kind": "step", + "operation": "eso.force-sync", + "outcome": "done", + "target": "k8s:sso/externalsecret/authelia-secrets" + }, + { + "checks": [ + { + "id": "synthetic-journey", + "result": "fail" + } + ], + "consumer": "authelia", + "id": "restart", + "kind": "step", + "operation": "k8s.rollout-restart", + "outcome": "check_failed", + "target": "k8s:sso/deployment/authelia" + }, + { + "checks": [ + { + "id": "kv-advanced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "restore-kv", + "kind": "revert", + "operation": "openbao.kv.write-previous-value-as-next-version", + "outcome": "done", + "target": "openbao:platform/workloads/net-kingdom/directory-binds/svc-authelia" + }, + { + "checks": [ + { + "id": "bind-signs-in", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "restore-directory", + "kind": "revert", + "operation": "lldap.user.change-own-password-from-kv", + "outcome": "done", + "target": "lldap:svc-authelia" + }, + { + "checks": [ + { + "id": "eso-synced", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "redeliver", + "kind": "revert", + "operation": "eso.force-sync", + "outcome": "done", + "target": "k8s:sso/externalsecret/authelia-secrets" + }, + { + "checks": [ + { + "id": "synthetic-journey", + "result": "pass" + } + ], + "consumer": "authelia", + "id": "restart-again", + "kind": "revert", + "operation": "k8s.rollout-restart", + "outcome": "done", + "target": "k8s:sso/deployment/authelia" + } + ], + "target": "admission:BEA-2026-0001", + "tenant": "platform", + "time_interval": { + "clock_source": "system-utc", + "end": "2026-11-03T09:30:56Z", + "start": "2026-11-03T09:30:00Z" + } + }, + "id": "run:1c8f5b63-4d2e-4a7f-9ba1-2c3d4e5f6071:receipt", + "occurred_at": "2026-11-03T09:30:56+00:00", + "source": "net-kingdom.bounded-executor", + "subject": "admission:BEA-2026-0001", + "tenant": "platform", + "type": "execution.attribution.receipt" +} diff --git a/tools/execution-attribution-receipt/fixtures/valid/reverted-started.json b/tools/execution-attribution-receipt/fixtures/valid/reverted-started.json new file mode 100644 index 0000000..0ec387d --- /dev/null +++ b/tools/execution-attribution-receipt/fixtures/valid/reverted-started.json @@ -0,0 +1,24 @@ +{ + "correlation_id": "1c8f5b63-4d2e-4a7f-9ba1-2c3d4e5f6071", + "data": { + "admission": "BEA-2026-0001", + "admission_digest": "sha256:bbaa3afb2885cc1dd8a4b00f48f3f7265aaf05d4c2f03479fb935c44e5bf013b", + "approval": "6f1c2b9e-0a4d-4e3b-9c51-7d2f8a6b3e10", + "consumed_at": "2026-11-03T09:30:00Z", + "executing_workload": "service:nk-bind-executor", + "plan_digest": "sha256:df214e02c24e70c6ac799bee873862f0d24d9bd537bf77cfb845a7b880c7565b", + "receipt_version": "0.1", + "run_id": "1c8f5b63-4d2e-4a7f-9ba1-2c3d4e5f6071", + "runtime_principal": "system:serviceaccount:sso:nk-bind-executor", + "steps_planned": 4, + "window": { + "not_after": "2026-11-03T11:00:00Z" + } + }, + "id": "run:1c8f5b63-4d2e-4a7f-9ba1-2c3d4e5f6071:started", + "occurred_at": "2026-11-03T09:30:00+00:00", + "source": "net-kingdom.bounded-executor", + "subject": "admission:BEA-2026-0001", + "tenant": "platform", + "type": "execution.run.started" +} diff --git a/tools/execution-attribution-receipt/generate_fixtures.py b/tools/execution-attribution-receipt/generate_fixtures.py new file mode 100644 index 0000000..d26c07d --- /dev/null +++ b/tools/execution-attribution-receipt/generate_fixtures.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Regenerate the valid fixtures from the real executor (bounded-executor/executor.py) on in-memory fakes. + +Each scenario runs executor.Run.execute() on the pilot example admission with fixed clocks and a fixed run id, and +writes the events it emitted to fixtures/valid/-{started,receipt}.json. The fixtures therefore always +have the executor's exact shape; tests fail when the executor and the schema drift apart. No value an operation +handles can reach them: the fake operation holds a marker value, and the generator refuses if it appears. + + python3 tools/execution-attribution-receipt/generate_fixtures.py [--check] +""" +from __future__ import annotations + +import argparse +import copy +import datetime as dt +import importlib.util +import json +import pathlib +import sys + +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parents[1] +OUT = HERE / "fixtures/valid" +MARKER = "synthetic-value-never-emitted" +APPROVAL = "6f1c2b9e-0a4d-4e3b-9c51-7d2f8a6b3e10" +NOW = dt.datetime(2026, 11, 3, 9, 30, tzinfo=dt.UTC) + + +def _executor(): + spec = importlib.util.spec_from_file_location("executor", ROOT / "bounded-executor/executor.py") + module = importlib.util.module_from_spec(spec) + sys.modules["executor"] = module + spec.loader.exec_module(module) + return module + + +X = _executor() + + +class Fake: + def __init__(self, doc, fail_checks=()): + spec = doc["spec"] + self.t = 0.0 + self.fail = list(fail_checks) + self.events = [] + self.approval = { + "id": APPROVAL, "status": "approved", + "binding": {"action": X.ACTION, "actor": spec["executor"]["identity"], "principal": "admin", + "purpose": spec["purpose"], "human_control": True, "pdp_path": False, + "target": {"admission_id": spec["id"], "admission_digest": X.bea.admission_digest(doc)}}, + "validity": {"not_before": spec["window"]["not_before"], "expires_at": spec["window"]["not_after"]}, + "entries": [{"subject_id": "kc-sub-0f3a", "principal_type": "human", "approved_at": "2026-11-03T09:10:00Z", + "assurance": json.dumps({"level": "aal2", "mfa": True, "source": "key-cape"})}]} + + def get(self, _id): + return copy.deepcopy(self.approval) + + def consume(self, _id, digest, decision_id): + self.approval.update(status="consumed", consumption={"request_digest": digest, "decision_id": decision_id, + "consumed_at": "2026-11-03T09:30:00Z"}) + return {"status": "consumed", "request_digest": digest, "decision_id": decision_id, + "consumed_at": "2026-11-03T09:30:00Z", "idempotent": False} + + def op(self, _step, ctx): + ctx.data["value"] = MARKER + self.t += 5 + + def check(self, check, _ctx): + self.t += 2 + if self.fail and self.fail[0] == check["id"]: + self.fail.pop(0) + return False + return True + + def sleep(self, seconds): + self.t += seconds + + +def scenario(name: str, fail_checks=(), attempts=1) -> list[dict]: + doc = X.bea.load(ROOT / "examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml") + doc["spec"]["budget"]["attempts"] = attempts + doc["spec"]["plan_digest"] = X.bea.plan_digest(doc) + fake = Fake(doc, fail_checks) + plan = doc["spec"]["plan"] + deps = X.Deps(carrier=fake, operations={s["operation"]: fake.op for s in plan["steps"] + plan["revert_steps"]}, + checks={c["id"]: fake.check for c in doc["spec"]["checks"]}, trust=lambda _s: True, + audit=type("Sink", (), {"emit": lambda _s, e: fake.events.append(e)})(), alert=lambda *_a: None, + wall=lambda: NOW + dt.timedelta(seconds=fake.t), monotonic=lambda: fake.t, sleep=fake.sleep) + cfg = X.Config(executor_identity=doc["spec"]["executor"]["identity"], + runtime_principal="system:serviceaccount:sso:nk-bind-executor", tenant="platform", + environment="PRODUCTION", approver_subjects={"admin": "kc-sub-0f3a"}, + approver_issuer="https://kc.coulomb.social") + run = X.Run(doc, APPROVAL, cfg, deps) + run.run_id = {"completed": "0b7e4a52-3c1d-4f6e-8a90-1b2c3d4e5f60", + "reverted": "1c8f5b63-4d2e-4a7f-9ba1-2c3d4e5f6071", + "completed-on-retry": "2d906c74-5e3f-4b80-8cb2-3d4e5f607182"}[name] + run.preflight = lambda: run._check_approval(fake.get(APPROVAL), consumed_ok=False) # pins: example, not repo + result = run.execute() + assert result["outcome"] in ("completed", "reverted"), result + text = json.dumps(fake.events) + if MARKER in text: + raise SystemExit("an operation value reached an event") + return fake.events + + +SCENARIOS = { + "completed": {}, + "reverted": {"fail_checks": ["synthetic-journey"]}, + "completed-on-retry": {"fail_checks": ["synthetic-journey"], "attempts": 2}, +} + + +def render() -> dict[str, str]: + files = {} + for name, kwargs in SCENARIOS.items(): + started, receipt = scenario(name, **kwargs) + for kind, event in (("started", started), ("receipt", receipt)): + files[f"{name}-{kind}.json"] = json.dumps(event, indent=2, sort_keys=True) + "\n" + return files + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument("--check", action="store_true", help="fail if the committed fixtures differ") + args = parser.parse_args(argv) + files = render() + if args.check: + stale = [n for n, text in files.items() if not (OUT / n).is_file() or (OUT / n).read_text() != text] + print(json.dumps({"stale": stale})) + return 1 if stale else 0 + OUT.mkdir(parents=True, exist_ok=True) + for name, text in files.items(): + (OUT / name).write_text(text) + print(f"wrote {len(files)} fixtures to {OUT.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/execution-attribution-receipt/tests/test_validate.py b/tools/execution-attribution-receipt/tests/test_validate.py new file mode 100644 index 0000000..dbadccf --- /dev/null +++ b/tools/execution-attribution-receipt/tests/test_validate.py @@ -0,0 +1,132 @@ +"""Execution Attribution Receipt validator (NK-WP-0056-T15). Fixtures come from the real executor.""" +from __future__ import annotations + +import copy +import importlib.util +import json +import pathlib +import sys +import unittest + +HERE = pathlib.Path(__file__).resolve().parent +TOOL = HERE.parent + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +V = load("ear_validate", TOOL / "validate.py") +G = load("ear_generate", TOOL / "generate_fixtures.py") +VALID = TOOL / "fixtures/valid" +SCENARIOS = ("completed", "reverted", "completed-on-retry") + + +def fixture(name): + return json.loads((VALID / name).read_text()) + + +class Fixtures(unittest.TestCase): + def test_committed_fixtures_are_what_the_executor_emits(self): + self.assertEqual(G.main(["--check"]), 0) + + def test_every_valid_fixture_and_pair_passes(self): + for s in SCENARIOS: + with self.subTest(scenario=s): + started, receipt = fixture(f"{s}-started.json"), fixture(f"{s}-receipt.json") + for event in (started, receipt): + self.assertEqual(V.evaluate(event)["findings"], []) + self.assertEqual(V.evaluate_pair(started, receipt), {"valid": True, "findings": []}) + + def test_started_carries_the_window_audit_core_asked_for(self): + data = fixture("completed-started.json")["data"] + self.assertEqual(data["window"], {"not_after": "2026-11-03T11:00:00Z"}) + + +class Rules(unittest.TestCase): + def assert_rule(self, event, rule, at=None): + report = V.evaluate(event) + self.assertFalse(report["valid"]) + self.assertIn(rule, {f["rule"] for f in report["findings"]}, report) + if at: + self.assertIn(at, {f["at"] for f in report["findings"]}) + + def receipt(self, name="completed"): + return copy.deepcopy(fixture(f"{name}-receipt.json")) + + def test_schema_refusals(self): + cases = { + "extra field (a value could hide there)": lambda e: e["data"].update(bind_password="x"), + "extra step field": lambda e: e["data"]["steps"][0].update(value="x"), + "wrong source": lambda e: e.update(source="someone-else"), + "naive time": lambda e: e.update(occurred_at="2026-11-03T09:31:00"), + "unknown outcome": lambda e: e["data"].update(outcome="fine"), + "issuer not https": lambda e: e["data"]["initiator"].update(iss="http://kc.local"), + "error message instead of type": lambda e: e["data"]["steps"][0].update(error_type="bind failed: x y"), + "missing receipt_version": lambda e: e["data"].pop("receipt_version"), + } + for name, mutate in cases.items(): + with self.subTest(case=name): + event = self.receipt() + mutate(event) + self.assert_rule(event, "EAR-1") + + def test_cross_field_rules(self): + cases = { + "EAR-2": lambda e: e.update(id="run:" + "0" * 8 + "-0000-4000-8000-" + "0" * 12 + ":receipt"), + "EAR-3": lambda e: e.update(subject="admission:BEA-2026-0002"), + "EAR-4": lambda e: e.update(tenant="tenant:platform"), + "EAR-5": lambda e: e["data"]["decision_refs"][0].update(ref="BEA-2026-0002"), + "EAR-6": lambda e: e.update(occurred_at="2026-11-03T09:00:00Z"), + "EAR-7": lambda e: e["data"].update(reverted=True), + "EAR-8": lambda e: e["data"]["budget"]["used"].update(attempts=2), + "EAR-9": lambda e: e["data"]["steps"][-1].update(outcome="check_failed"), + } + for rule, mutate in cases.items(): + with self.subTest(rule=rule): + event = self.receipt() + mutate(event) + self.assert_rule(event, rule) + + def test_reverted_needs_a_clean_revert(self): + event = self.receipt("reverted") + next(s for s in event["data"]["steps"] if s["kind"] == "revert").update(outcome="check_failed") + self.assert_rule(event, "EAR-9") + event = self.receipt("completed") + event["data"]["outcome"] = "reverted" + self.assert_rule(event, "EAR-9") + + def test_completed_after_revert_needs_a_retry(self): + event = self.receipt("completed-on-retry") + event["data"]["attempts"] = event["data"]["budget"]["used"]["attempts"] = 1 + self.assert_rule(event, "EAR-9") + + def test_pair_rules(self): + started, receipt = fixture("completed-started.json"), fixture("completed-receipt.json") + cases = { + "window": lambda s, r: s["data"]["window"].update(not_after="2026-11-03T09:30:10Z"), + "approval": lambda s, r: s["data"].update(approval="another-approval"), + "admission_digest": lambda s, r: s["data"].update(admission_digest="sha256:" + "0" * 64), + "order": lambda s, r: s.update(occurred_at="2026-11-03T10:59:00Z"), + } + for at, mutate in cases.items(): + with self.subTest(at=at): + s, r = copy.deepcopy(started), copy.deepcopy(receipt) + mutate(s, r) + report = V.evaluate_pair(s, r) + self.assertIn({"rule": "EAR-10", "at": at}, report["findings"]) + report = V.evaluate_pair(receipt, started) + self.assertFalse(report["valid"]) + + def test_findings_never_carry_values(self): + event = self.receipt() + event["data"]["bind_password"] = "synthetic-secret-123" + self.assertNotIn("synthetic-secret-123", json.dumps(V.evaluate(event))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/execution-attribution-receipt/validate.py b/tools/execution-attribution-receipt/validate.py new file mode 100644 index 0000000..79f34b4 --- /dev/null +++ b/tools/execution-attribution-receipt/validate.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Execution Attribution Receipt validator, bounded-executor profile (NK-WP-0056-T15, NK-WP-0040-T02 scope). + +Offline and deterministic. Checks the two audit-core events of one run against +canon/schemas/execution-attribution-receipt_v0.1.schema.json (EAR-1), then the cross-field rules: + + EAR-2 envelope id is run::started|receipt, and data.run_id equals correlation_id + EAR-3 subject names the run's admission (started: data.admission; receipt: data.target) + EAR-4 receipt: envelope tenant equals data.tenant + EAR-5 receipt: decision_refs are admission, admission_digest, approval, once each; admission matches the subject + EAR-6 receipt: time_interval.start <= end, and occurred_at equals end + EAR-7 receipt: reverted is true exactly when a revert step was recorded + EAR-8 receipt: attempts equals budget.used.attempts and does not exceed budget.limit.attempts + EAR-9 receipt: outcome agrees with the steps (completed: the final attempt, after the last revert, has every + step done and every check passed, and a revert implies a retry; reverted: a revert ran and every revert + step is done) + EAR-10 pair: started and receipt share run_id, tenant, admission and approval, the started admission_digest is the + receipt's, started precedes the receipt, and the run ended inside the admission window + +Findings name a rule and a location, never a value. + + python3 tools/execution-attribution-receipt/validate.py validate EVENT.json... + python3 tools/execution-attribution-receipt/validate.py pair STARTED.json RECEIPT.json +""" +from __future__ import annotations + +import datetime as dt +import json +import pathlib +import sys +from typing import Any + +import jsonschema + +ROOT = pathlib.Path(__file__).resolve().parents[2] +SCHEMA = ROOT / "canon/schemas/execution-attribution-receipt_v0.1.schema.json" +STARTED = "execution.run.started" +RECEIPT = "execution.attribution.receipt" + + +def parse_time(value: str) -> dt.datetime: + return dt.datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _schema() -> dict: + return json.loads(SCHEMA.read_text(encoding="utf-8")) + + +def evaluate(event: Any, schema: dict | None = None) -> dict: + """One event. Returns {"valid": bool, "kind": started|receipt|None, "findings": [{"rule", "at"}]}.""" + schema = schema or _schema() + findings: list[dict] = [] + + def fail(rule: str, at: str) -> None: + findings.append({"rule": rule, "at": at}) + + validator = jsonschema.Draft202012Validator(schema, format_checker=jsonschema.FormatChecker()) + errors = sorted(validator.iter_errors(event), key=lambda e: list(e.absolute_path)) + if errors: + for error in errors[:20]: + fail("EAR-1", "/".join(str(p) for p in error.absolute_path) or "(root)") + return {"valid": False, "kind": None, "findings": findings} + + kind = "started" if event["type"] == STARTED else "receipt" + data = event["data"] + if event["id"] != f"run:{event['correlation_id']}:{kind}" or data["run_id"] != event["correlation_id"]: + fail("EAR-2", "id") + admission = data["admission"] if kind == "started" else data["target"].removeprefix("admission:") + if event["subject"] != f"admission:{admission}": + fail("EAR-3", "subject") + if kind == "receipt": + if event["tenant"] != data["tenant"]: + fail("EAR-4", "tenant") + refs = {r["label"]: r["ref"] for r in data["decision_refs"]} + if sorted(r["label"] for r in data["decision_refs"]) != ["admission", "admission_digest", "approval"] \ + or refs.get("admission") != admission or not refs.get("admission_digest", "").startswith("sha256:"): + fail("EAR-5", "data/decision_refs") + start, end = (parse_time(data["time_interval"][k]) for k in ("start", "end")) + if start > end or parse_time(event["occurred_at"]) != end: + fail("EAR-6", "data/time_interval") + reverts = [s for s in data["steps"] if s["kind"] == "revert"] + if data["reverted"] != bool(reverts): + fail("EAR-7", "data/reverted") + budget = data["budget"] + if data["attempts"] != budget["used"]["attempts"] or data["attempts"] > budget["limit"]["attempts"]: + fail("EAR-8", "data/budget") + if data["outcome"] == "completed": + # A clean revert may be retried (BEA budget.attempts): earlier attempts may hold failures and reverts. + # The final attempt is the steps after the last revert; it must be complete and clean. + last = max((i for i, s in enumerate(data["steps"]) if s["kind"] == "revert"), default=-1) + final = data["steps"][last + 1:] + if (not final or (reverts and data["attempts"] < 2) + or any(s["outcome"] != "done" or any(c["result"] != "pass" for c in s["checks"]) for s in final)): + fail("EAR-9", "data/outcome") + if data["outcome"] == "reverted" and (not reverts or any(s["outcome"] != "done" for s in reverts)): + fail("EAR-9", "data/outcome") + return {"valid": not findings, "kind": kind, "findings": findings} + + +def evaluate_pair(started: Any, receipt: Any, schema: dict | None = None) -> dict: + schema = schema or _schema() + findings = [] + for name, event, want in (("started", started, "started"), ("receipt", receipt, "receipt")): + report = evaluate(event, schema) + findings += [{"rule": f["rule"], "at": f"{name}:{f['at']}"} for f in report["findings"]] + if report["valid"] and report["kind"] != want: + findings.append({"rule": "EAR-10", "at": f"{name}:type"}) + if findings: + return {"valid": False, "findings": findings} + s, r = started["data"], receipt["data"] + refs = {x["label"]: x["ref"] for x in r["decision_refs"]} + checks = { + "run_id": s["run_id"] == r["run_id"], + "tenant": started["tenant"] == receipt["tenant"], + "admission": started["subject"] == receipt["subject"], + "admission_digest": s["admission_digest"] == refs["admission_digest"], + "approval": s["approval"] == refs["approval"], + "order": parse_time(started["occurred_at"]) <= parse_time(receipt["occurred_at"]), + "window": parse_time(r["time_interval"]["end"]) <= parse_time(s["window"]["not_after"]), + } + findings = [{"rule": "EAR-10", "at": k} for k, ok in checks.items() if not ok] + return {"valid": not findings, "findings": findings} + + +def _load(path: str) -> Any: + return json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + + +def main(argv: list[str] | None = None) -> int: + argv = sys.argv[1:] if argv is None else argv + if len(argv) >= 2 and argv[0] == "validate": + ok = True + for path in argv[1:]: + report = evaluate(_load(path)) + print(json.dumps({"file": path, **report}, sort_keys=True)) + ok &= report["valid"] + return 0 if ok else 1 + if len(argv) == 3 and argv[0] == "pair": + report = evaluate_pair(_load(argv[1]), _load(argv[2])) + print(json.dumps(report, sort_keys=True)) + return 0 if report["valid"] else 1 + print(__doc__.split("\n\n")[-1], file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main())