railiance-platform/scripts/root_grant_attended.py
codex fa958cc051
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Implement attended root custody admission and crash-safe publisher
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1029d-2c93-7500-a0e0-e3e089668b29
2026-10-03 19:44:22 +02:00

255 lines
12 KiB
Python

#!/usr/bin/env python3
"""Silent attended OpenBao admission/publisher; never emits server responses."""
import argparse
import datetime as dt
import fcntl
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
from root_grant_publish import MAX_BYTES, RECORD_ID, prepare_payload
from openbao_platform_admin_check import require_attended
ROOT = Path(__file__).resolve().parents[1]
PATH = "platform/data/custody/net-kingdom/platform-root"
META = "platform/metadata/custody/net-kingdom/platform-root"
ROLE_PATH = "auth/kubernetes/role/user-engine-root-custody-reader"
POLICY = "user-engine-root-custody-reader"
ROLE = {"bound_service_account_names": ["user-engine"],
"bound_service_account_namespaces": ["user-engine"], "audience": "openbao",
"token_policies": [POLICY], "token_no_default_policy": True,
"token_ttl": 60, "token_max_ttl": 60, "token_explicit_max_ttl": 60}
class Refused(Exception):
pass
def digest(document):
return hashlib.sha256(json.dumps(document, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest()
def atomic_json(path, document):
path = Path(path)
if path.is_symlink():
raise Refused("unsafe_state_path")
with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as out:
temporary = Path(out.name)
os.chmod(temporary, 0o600)
out.write(json.dumps(document, sort_keys=True).encode())
out.flush()
os.fsync(out.fileno())
try:
os.replace(temporary, path)
fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
finally:
temporary.unlink(missing_ok=True)
class Bao:
def call(self, command, payload=None, optional=False):
result = subprocess.run(["bao", *command], input=None if payload is None else json.dumps(payload).encode(),
capture_output=True, timeout=15)
if result.returncode:
# Distinguish a documented missing-path message from auth/outage.
if optional and result.returncode == 2 and result.stderr.strip() == ("No value found at " + command[-1]).encode():
return None
raise Refused("openbao_request_failed")
if len(result.stdout) > MAX_BYTES * 2:
raise Refused("oversize_owner_response")
return json.loads(result.stdout) if result.stdout.strip() else {}
def read(self, path, optional=False):
response = self.call(["read", "-format=json", path], optional=optional)
return None if response is None else response["data"]
def write(self, path, payload):
return self.call(["write", "-format=json", path, "-"], payload)
def check_baseline(actual, state, validator):
if state is None:
if actual is not None:
raise Refused("controlled_baseline_import_required")
return
previous = state["document"]
if validator.validate(previous) or previous["record_id"] != RECORD_ID or state["digest"] != digest(previous):
raise Refused("invalid_retained_baseline")
if actual is None or validator.validate(actual["data"]["document"]):
raise Refused("missing_or_invalid_source")
document = actual["data"]["document"]
if digest(document) != state["digest"] or actual["metadata"]["version"] != state["kv_version"]:
raise Refused("source_differs_from_retained_baseline")
def retain_history(state_path, receipt):
history = state_path.parent / "history"
history.mkdir(mode=0o700, exist_ok=True)
target = history / (str(receipt["revision"]) + "-" + receipt["digest"] + ".json")
if target.exists():
if json.loads(target.read_text()) != receipt:
raise Refused("history_receipt_differs")
return
with target.open("x") as out:
os.chmod(target, 0o600)
json.dump(receipt, out, sort_keys=True)
out.flush()
os.fsync(out.fileno())
fd = os.open(history, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def publish(bao, candidate, state_path, validator, provenance):
state_path = Path(state_path)
if not isinstance(provenance, str) or not provenance.strip() or len(provenance) > 300 or validator.SECRET_VALUE.search(provenance):
raise Refused("nonsecret_human_provenance_required")
if state_path.is_symlink():
raise Refused("unsafe_state_path")
state = json.loads(state_path.read_text()) if state_path.exists() else None
actual = bao.read(PATH, optional=True)
# Pending state is fsynced before network mutation. Recover only exact readback;
# ambiguous network failures are never retried or cleared automatically.
if state is not None and state.get("pending"):
pending = state["pending"]
if actual is None or digest(actual["data"]["document"]) != pending["digest"] or actual["metadata"]["version"] != pending["expected_kv_version"]:
raise Refused("pending_publication_requires_owner_review")
if validator.validate(pending["document"]) or pending["document"]["record_id"] != RECORD_ID:
raise Refused("invalid_pending_document")
state = {key: value for key, value in pending.items() if key != "expected_kv_version"}
retain_history(state_path, state)
atomic_json(state_path, state)
check_baseline(actual, state, validator)
previous = None if state is None else state["document"]
version = 0 if actual is None else actual["metadata"]["version"]
if previous is not None and digest(candidate) == state["digest"]:
# Exact replay is an idempotent readback, not a new custody/KV revision.
return {**{key: value for key, value in state.items() if key != "document"}, "unchanged": True}
payload = prepare_payload(candidate, previous, version, validator)
now = dt.datetime.now(dt.timezone.utc)
times = [candidate["updated_at"]]
for entry in candidate["entries"]:
times.append(entry["granted"]["at"])
if entry.get("revoked"):
times.append(entry["revoked"]["at"])
if any(validator.parse_time(value) > now for value in times):
raise Refused("future_custody_action")
receipt = {"record_id": RECORD_ID, "revision": candidate["revision"], "digest": digest(candidate),
"kv_version": version + 1, "document": candidate, "provenance_ref": provenance,
"recorded_at": dt.datetime.now(dt.timezone.utc).isoformat()}
pending = dict(receipt, expected_kv_version=version + 1)
atomic_json(state_path, {**(state or {}), "pending": pending})
bao.write(PATH, payload)
returned = bao.read(PATH)
if returned["metadata"]["version"] != version + 1 or digest(returned["data"]["document"]) != receipt["digest"]:
raise Refused("publication_readback_failed")
# Independent immutable receipts precede advancing local state. They must be
# backed up off-host; restoring only this directory is not independent proof.
retain_history(state_path, receipt)
atomic_json(state_path, receipt)
return {key: value for key, value in receipt.items() if key != "document"}
def provision(bao):
# Require existing Kubernetes auth; no auth mount or cluster reviewer mutation.
if bao.read("auth/kubernetes/config") is None:
raise Refused("kubernetes_auth_unavailable")
audit = bao.read("sys/audit")
if not audit:
raise Refused("audit_required")
policy = (ROOT / "openbao/root-grant/reader.hcl").read_text()
existing = bao.read("sys/policies/acl/" + POLICY, optional=True)
if existing is not None and existing.get("policy", existing.get("rules", "")).strip() != policy.strip():
raise Refused("existing_reader_policy_differs")
role = bao.read(ROLE_PATH, optional=True)
if role is not None and any(role.get(key) != value for key, value in ROLE.items()):
raise Refused("existing_reader_role_differs")
if existing is None:
bao.write("sys/policies/acl/" + POLICY, {"policy": policy})
if role is None:
bao.write(ROLE_PATH, ROLE)
fresh = bao.read(ROLE_PATH)
if any(fresh.get(key) != value for key, value in ROLE.items()):
raise Refused("role_readback_failed")
fresh_policy = bao.read("sys/policies/acl/" + POLICY)
if fresh_policy.get("policy", fresh_policy.get("rules", "")).strip() != policy.strip():
raise Refused("policy_readback_failed")
# Require CAS on this key without altering retention or destroying versions.
metadata = bao.read(META, optional=True)
if not metadata or not metadata.get("cas_required"):
bao.write(META, {"cas_required": True})
if bao.read(META).get("cas_required") is not True:
raise Refused("cas_required_readback_failed")
return {"reader_role": ROLE_PATH, "reader_namespace": "user-engine", "reader_service_account": "user-engine",
"audience": "openbao", "record_id": RECORD_ID, "cas_required": True, "audit_enabled": True}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("action", choices=["provision", "publish", "restore-check"])
parser.add_argument("--receipt", type=Path, required=True)
parser.add_argument("--state", type=Path)
parser.add_argument("--candidate", type=Path)
parser.add_argument("--net-kingdom", type=Path)
parser.add_argument("--provenance-ref")
parser.add_argument("--validator-digest")
parser.add_argument("--schema-digest")
args = parser.parse_args()
try:
if args.receipt.exists() or args.receipt.is_symlink():
raise Refused("unique_run_receipt_required")
require_attended()
if os.getenv("BAO_ADDR") != "http://127.0.0.1:18200":
raise Refused("approved_operator_tunnel_required")
bao = Bao()
if args.action == "provision":
receipt = provision(bao)
else:
if not args.state or not args.net_kingdom or not args.validator_digest or not args.schema_digest:
raise Refused("pinned_source_and_state_required")
source = args.net_kingdom / "tools/custody-record/validate.py"
schema = args.net_kingdom / "canon/schemas/custody-record_v0.1.schema.json"
for path, expected in [(source, args.validator_digest), (schema, args.schema_digest)]:
if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
raise Refused("canon_pin_mismatch")
spec = importlib.util.spec_from_file_location("custody_validator", source)
validator = importlib.util.module_from_spec(spec)
spec.loader.exec_module(validator)
if not args.state.parent.is_dir():
raise Refused("owner_state_directory_required")
# Serialize this publisher, including reads and pending-journal recovery.
with (args.state.parent / "publisher.lock").open("a") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
if args.action == "restore-check":
if not args.state.exists():
raise Refused("retained_baseline_required")
state = json.loads(args.state.read_text())
if state.get("pending"):
raise Refused("pending_publication_requires_owner_review")
check_baseline(bao.read(PATH, optional=True), state, validator)
receipt = {"record_id": RECORD_ID, "baseline_matches": True, "revision": state["document"]["revision"]}
else:
if not args.candidate or not args.provenance_ref:
raise Refused("candidate_and_human_provenance_required")
candidate = json.loads(args.candidate.read_bytes()[:MAX_BYTES + 1])
receipt = publish(bao, candidate, args.state, validator, args.provenance_ref)
atomic_json(args.receipt, {"ok": True, **receipt})
return 0
except Exception:
# CLI/process/validation errors may embed secrets. Never emit them.
return 1
if __name__ == "__main__":
sys.exit(main())