Prepare bounded independent cadence observer custody readback
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
This commit is contained in:
parent
7ec54d59e0
commit
4f054046dc
3 changed files with 511 additions and 0 deletions
316
scripts/cadence_observer_readback.py
Normal file
316
scripts/cadence_observer_readback.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Bounded independent operator readback for NK-WP-0035; no credential output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import ProxyHandler, Request, build_opener
|
||||
|
||||
from cadence_receiver_custody import compatible
|
||||
from factory_audit_custody import require, snapshot
|
||||
from native_factory_acceptance import NoRedirect, operator_identity
|
||||
from state_hub_preflight_lane import LaneError, bao, data
|
||||
|
||||
SCOPES = {
|
||||
"qonto-assistant": "tenant:friendly:binky",
|
||||
"approval-engine": "tenant:platform",
|
||||
}
|
||||
|
||||
|
||||
def stamp(value):
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
require(parsed.tzinfo is not None, "timezone_required")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def paths(source, tenant, instance, since, until, event_ids):
|
||||
require(SCOPES.get(source) == tenant, "exact_source_tenant_required")
|
||||
require(
|
||||
isinstance(instance, str)
|
||||
and 1 <= len(instance) <= 256
|
||||
and instance.strip() == instance,
|
||||
"exact_instance_required",
|
||||
)
|
||||
require(
|
||||
stamp(since) <= stamp(until) <= datetime.now(timezone.utc),
|
||||
"invalid_acceptance_window",
|
||||
)
|
||||
require(
|
||||
1 <= len(event_ids) <= 256 and len(set(event_ids)) == len(event_ids),
|
||||
"bounded_unique_event_ids_required",
|
||||
)
|
||||
require(
|
||||
all(
|
||||
isinstance(x, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,200}", x)
|
||||
for x in event_ids
|
||||
),
|
||||
"invalid_event_id",
|
||||
)
|
||||
query = urlencode(
|
||||
dict(
|
||||
source=source,
|
||||
tenant=tenant,
|
||||
stream_instance_id=instance,
|
||||
since=since,
|
||||
until=until,
|
||||
)
|
||||
)
|
||||
return ["/v1/reconciliation?" + query, "/v1/stream-findings"] + [
|
||||
"/v1/events/" + x for x in event_ids
|
||||
]
|
||||
|
||||
|
||||
def read_http(origin, token, path, allowed):
|
||||
require(path in allowed, "read_path_not_in_exact_packet")
|
||||
request = Request(origin + path, headers={"Authorization": "Bearer " + token})
|
||||
try:
|
||||
response = build_opener(ProxyHandler({}), NoRedirect()).open(
|
||||
request, timeout=10
|
||||
)
|
||||
except HTTPError as exc:
|
||||
response = exc
|
||||
with response:
|
||||
raw = response.read(1048577)
|
||||
require(len(raw) <= 1048576, "readback_too_large")
|
||||
require(response.status == 200, "independent_readback_failed")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def sanitize_record(record, source, tenant, instance, event_id, since, until):
|
||||
require(
|
||||
record.get("event_id") == event_id
|
||||
and record.get("source") == source
|
||||
and record.get("tenant") == tenant,
|
||||
"record_source_tenant_id_mismatch",
|
||||
)
|
||||
detail = record["details"]["data"]
|
||||
require(detail.get("stream_instance_id") == instance, "record_instance_mismatch")
|
||||
require(
|
||||
type(detail.get("stream_sequence")) is int and detail["stream_sequence"] > 0,
|
||||
"record_sequence_missing",
|
||||
)
|
||||
accepted = record["accepted_at"]
|
||||
require(
|
||||
stamp(since) <= stamp(accepted) <= stamp(until),
|
||||
"record_outside_acceptance_window",
|
||||
)
|
||||
action = record["action"]
|
||||
coverage = (
|
||||
[detail["class"]]
|
||||
if action == "audit-core.heartbeat" and "class" in detail
|
||||
else []
|
||||
)
|
||||
if source == "qonto-assistant":
|
||||
require(
|
||||
action in {"audit.allow", "audit.deny", "audit-core.heartbeat"},
|
||||
"record_undeclared_class",
|
||||
)
|
||||
if action == "audit-core.heartbeat":
|
||||
require(coverage == ["audit.deny"], "record_heartbeat_scope_mismatch")
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"source": source,
|
||||
"tenant": tenant,
|
||||
"stream_instance_id": instance,
|
||||
"stream_sequence": detail["stream_sequence"],
|
||||
"type": action,
|
||||
"accepted_at": accepted,
|
||||
"observed_at": record["observed_at"],
|
||||
"covered_event_classes": coverage,
|
||||
"independent_custody_readback": True,
|
||||
}
|
||||
|
||||
|
||||
def readback(origin, token, source, tenant, instance, since, until, event_ids):
|
||||
allowed = paths(source, tenant, instance, since, until, event_ids)
|
||||
reconciliation = read_http(origin, token, allowed[0], allowed)
|
||||
require(
|
||||
all(
|
||||
reconciliation.get(k) == v
|
||||
for k, v in (
|
||||
("source", source),
|
||||
("tenant", tenant),
|
||||
("stream_instance_id", instance),
|
||||
("since", since),
|
||||
("until", until),
|
||||
)
|
||||
),
|
||||
"reconciliation_scope_window_mismatch",
|
||||
)
|
||||
raw_findings = read_http(origin, token, allowed[1], allowed)
|
||||
findings = raw_findings.get("stream_findings", raw_findings.get("findings"))
|
||||
require(isinstance(findings, list), "unexpected_findings_shape")
|
||||
safe_findings = [
|
||||
{
|
||||
key: row.get(key)
|
||||
for key in (
|
||||
"kind",
|
||||
"sender",
|
||||
"source",
|
||||
"class",
|
||||
"expected_within_seconds",
|
||||
"last_seen",
|
||||
"overdue_seconds",
|
||||
)
|
||||
}
|
||||
for row in findings
|
||||
if row.get("source") == source
|
||||
]
|
||||
records = [
|
||||
sanitize_record(
|
||||
read_http(origin, token, path, allowed),
|
||||
source,
|
||||
tenant,
|
||||
instance,
|
||||
event_id,
|
||||
since,
|
||||
until,
|
||||
)
|
||||
for path, event_id in zip(allowed[2:], event_ids, strict=True)
|
||||
]
|
||||
return {
|
||||
"reconciliation": reconciliation,
|
||||
"stream_findings": safe_findings,
|
||||
"events": records,
|
||||
"scope": "bounded same-instance accepted custody; source-global findings require active-instance interpretation",
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--source", choices=SCOPES, required=True)
|
||||
for key in (
|
||||
"tenant",
|
||||
"instance",
|
||||
"since",
|
||||
"until",
|
||||
"kubeconfig",
|
||||
"server",
|
||||
"expected-receiver-image",
|
||||
):
|
||||
p.add_argument("--" + key, required=True)
|
||||
p.add_argument("--event-id", action="append", required=True)
|
||||
p.add_argument("--receipt", type=Path, required=True)
|
||||
a = p.parse_args()
|
||||
receipt = {
|
||||
"schema_version": "platform.independent-cadence-readback/v1",
|
||||
"status": "refused",
|
||||
"credential_values_emitted": False,
|
||||
"observed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
fd = None
|
||||
forward = None
|
||||
try:
|
||||
paths(a.source, a.tenant, a.instance, a.since, a.until, a.event_id)
|
||||
require(
|
||||
Path.home().parent.name == ".warden-attended-login"
|
||||
and not any(
|
||||
os.getenv(x) for x in ("BAO_TOKEN", "VAULT_TOKEN", "OPENBAO_TOKEN")
|
||||
),
|
||||
"fresh_attended_warden_envelope_required",
|
||||
)
|
||||
fd = os.open(
|
||||
a.receipt, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600
|
||||
)
|
||||
|
||||
def save():
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
os.ftruncate(fd, 0)
|
||||
os.write(fd, (json.dumps(receipt, indent=2) + "\n").encode())
|
||||
os.fsync(fd)
|
||||
|
||||
kube = [
|
||||
"kubectl",
|
||||
"--kubeconfig",
|
||||
a.kubeconfig,
|
||||
"--server",
|
||||
a.server,
|
||||
"--request-timeout=20s",
|
||||
]
|
||||
receipt["receiver"] = compatible(kube, a.expected_receiver_image)
|
||||
save()
|
||||
identity = data(bao(["token", "lookup", "-format=json"]))["data"]
|
||||
require(
|
||||
"platform-admin" in identity["policies"]
|
||||
and "root" not in identity["policies"],
|
||||
"attended_platform_admin_required",
|
||||
)
|
||||
version, _, rows, _ = snapshot()
|
||||
operator = operator_identity(rows)
|
||||
token = (operator.get("tokens") or [operator["token"]])[0]
|
||||
receipt.update(reader_name=operator["name"], registry_version=version)
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = sock.getsockname()[1]
|
||||
forward = subprocess.Popen(
|
||||
kube
|
||||
+ [
|
||||
"-n",
|
||||
"audit-core",
|
||||
"port-forward",
|
||||
"--address=127.0.0.1",
|
||||
"service/audit-core",
|
||||
str(port) + ":8080",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
for _ in range(40):
|
||||
require(forward.poll() is None, "port_forward_failed")
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
break
|
||||
except OSError:
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
raise LaneError("port_forward_not_ready")
|
||||
receipt.update(
|
||||
readback(
|
||||
"http://127.0.0.1:" + str(port),
|
||||
token,
|
||||
a.source,
|
||||
a.tenant,
|
||||
a.instance,
|
||||
a.since,
|
||||
a.until,
|
||||
a.event_id,
|
||||
)
|
||||
)
|
||||
receipt.update(
|
||||
status="independent_custody_readback_complete",
|
||||
helper_sha256=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
)
|
||||
save()
|
||||
return 0
|
||||
except Exception as exc:
|
||||
receipt["error"] = (
|
||||
str(exc) if isinstance(exc, LaneError) else "contained_readback_failed"
|
||||
)
|
||||
if fd is not None:
|
||||
save()
|
||||
return 1
|
||||
finally:
|
||||
if forward is not None:
|
||||
forward.terminate()
|
||||
try:
|
||||
forward.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
forward.kill()
|
||||
forward.wait(timeout=5)
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue