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 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 523928@bnt-lap001 Assistant-Session: 6c737afb-9c14-4152-9f02-477214b9c350
This commit is contained in:
parent
1d4b7e84d7
commit
d7133e3a04
15 changed files with 1506 additions and 7 deletions
5
Makefile
5
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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
289
bounded-executor/exercise_audit_sender.py
Normal file
289
bounded-executor/exercise_audit_sender.py
Normal file
|
|
@ -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())
|
||||
152
canon/schemas/execution-attribution-receipt_v0.1.schema.json
Normal file
152
canon/schemas/execution-attribution-receipt_v0.1.schema.json
Normal file
|
|
@ -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"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
tools/execution-attribution-receipt/README.md
Normal file
36
tools/execution-attribution-receipt/README.md
Normal file
|
|
@ -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:<id>` 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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
139
tools/execution-attribution-receipt/generate_fixtures.py
Normal file
139
tools/execution-attribution-receipt/generate_fixtures.py
Normal file
|
|
@ -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/<scenario>-{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())
|
||||
132
tools/execution-attribution-receipt/tests/test_validate.py
Normal file
132
tools/execution-attribution-receipt/tests/test_validate.py
Normal file
|
|
@ -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()
|
||||
147
tools/execution-attribution-receipt/validate.py
Normal file
147
tools/execution-attribution-receipt/validate.py
Normal file
|
|
@ -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:<correlation_id>: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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue