From 46d55ba8e068d60a9be2302c72d0d45ab0c2cf95 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 15 Sep 2026 21:35:28 +0200 Subject: [PATCH] Add silent attended mint for the tenant-engine sender AUDIT-WP-0010-T02. Contained platform-admin login writes tenant-engine into the OpenBao senders registry with CAS, force-syncs the ExternalSecret, and recreates the receiver so it re-reads identities. No token value is printed; metadata evidence only. Assistant: grok Assistant-Session: 01a0a182-bab7-7f11-b32b-d06f3af52082 --- scripts/mint-tenant-engine-sender.py | 293 +++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100755 scripts/mint-tenant-engine-sender.py diff --git a/scripts/mint-tenant-engine-sender.py b/scripts/mint-tenant-engine-sender.py new file mode 100755 index 0000000..8bb1e6a --- /dev/null +++ b/scripts/mint-tenant-engine-sender.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""Attended mint of the tenant-engine sender into the audit-core registry. + +Silent: warden access --exec fails closed on any child stdout/stderr. +Never prints token values. Persist metadata evidence only. + + warden access openbao-platform-admin-login --exec -- \ + env RAILIANCE01_KUBECONFIG="$HOME/.kube/config-railiance01" \ + BAO_ADDR="${BAO_ADDR:-https://bao.coulomb.social}" \ + /home/worsch/audit-core/scripts/mint-tenant-engine-sender.py +""" +from __future__ import annotations + +import base64 +import json +import os +import secrets +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +REGISTRY = "platform/data/workloads/audit-core/senders" +SENDER = "tenant-engine" +TRADE = ( + "outbox drains after commit, not inside the mutation transaction; " + "declared at tenant-engine/docs/evidence-emission.md" +) +EVIDENCE = Path("/home/worsch/audit-core/docs/evidence/2026-09-15-tenant-engine-sender-mint.json") +KUBECONFIG = os.environ.get("RAILIANCE01_KUBECONFIG", "/home/worsch/.kube/config-railiance01") +RECEIVER_IMAGE = ( + "forgejo.coulomb.social/coulomb/audit-core@" + "sha256:ec15f63d49226bfe507af2bc38ffbd5e83f549ba2c6d1ae7a338e7e053f34615" +) + + +def _now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def _evidence(step: str, extra: dict | None = None) -> None: + doc = { + "step": step, + "observed_at": _now(), + "sender": SENDER, + "registry": "platform/workloads/audit-core/senders", + "credential_values_emitted": False, + } + if extra: + doc.update(extra) + EVIDENCE.parent.mkdir(parents=True, exist_ok=True) + EVIDENCE.write_text(json.dumps(doc, indent=2) + "\n") + + +def _fail(reason: str, extra: dict | None = None) -> int: + payload = {"reason": reason} + if extra: + payload.update(extra) + _evidence("failed", payload) + return 1 + + +def bao(args: list[str], *, payload: dict | None = None) -> dict: + env = os.environ.copy() + env.pop("BAO_TOKEN", None) + env.pop("VAULT_TOKEN", None) + env.pop("OPENBAO_TOKEN", None) + proc = subprocess.run( + ["bao", *args], + input=None if payload is None else json.dumps(payload).encode(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + check=False, + ) + if proc.returncode != 0: + raise RuntimeError("bao_failed") + return json.loads(proc.stdout.decode() or "{}") + + +def kube(args: list[str]) -> subprocess.CompletedProcess: + return subprocess.run( + ["kubectl", "--kubeconfig", KUBECONFIG, "--request-timeout=30s", *args], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + + +def kube_json(args: list[str]) -> dict: + proc = kube(args) + if proc.returncode != 0: + raise RuntimeError("kubectl_failed") + return json.loads(proc.stdout.decode()) + + +def sender_names(rows: list) -> list[str]: + return [row.get("name") for row in rows if isinstance(row, dict) and row.get("name")] + + +def secret_sender_names() -> list[str]: + secret = kube_json(["-n", "audit-core", "get", "secret", "audit-core-senders", "-o", "json"]) + raw = (secret.get("data") or {}).get("senders.json") + if not raw: + return [] + doc = json.loads(base64.b64decode(raw)) + if not isinstance(doc, list): + return [] + return sender_names(doc) + + +def desired(token: str) -> dict: + return { + "name": SENDER, + "tokens": [token], + "sources": [SENDER], + "tenants": ["*"], + "may_write": True, + "may_read": False, + "secret_policy": "redact", + "evidence_kind": "attributive", + "completeness_trade": TRADE, + } + + +def wait_secret_has_sender(timeout: int = 90) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + if SENDER in secret_sender_names(): + return True + except Exception: + pass + time.sleep(3) + return False + + +def wait_receiver_ready(timeout: int = 180) -> bool: + deadline = time.time() + timeout + while time.time() < deadline: + try: + dep = kube_json(["-n", "audit-core", "get", "deploy", "audit-core", "-o", "json"]) + ready = dep.get("status", {}).get("readyReplicas") + image = dep["spec"]["template"]["spec"]["containers"][0]["image"] + if ready == 1 and image == RECEIVER_IMAGE: + pods = kube_json( + [ + "-n", + "audit-core", + "get", + "pods", + "-l", + "app.kubernetes.io/name=audit-core,app.kubernetes.io/component=receiver", + "-o", + "json", + ] + )["items"] + if ( + len(pods) == 1 + and not pods[0]["metadata"].get("deletionTimestamp") + and all(cs.get("ready") for cs in pods[0].get("status", {}).get("containerStatuses") or []) + ): + return True + except Exception: + pass + time.sleep(4) + return False + + +def main() -> int: + _evidence("started") + home = Path.home() + if home.parent.name != ".warden-attended-login": + return _fail("attended_warden_envelope_required") + if os.environ.get("BAO_TOKEN") or os.environ.get("VAULT_TOKEN"): + return _fail("token_env_must_be_unset") + try: + identity = bao(["token", "lookup", "-format=json"])["data"] + except Exception: + return _fail("token_lookup_failed") + policies = identity.get("policies") or [] + if "platform-admin" not in policies or "root" in policies: + return _fail("attended_platform_admin_required") + if kube(["get", "ns", "audit-core", "-o", "name"]).returncode != 0: + return _fail("not_railiance01") + if kube(["get", "ns", "core-hub-staging", "-o", "name"]).returncode == 0: + return _fail("coulombcore_kubeconfig") + + try: + raw = bao(["read", "-format=json", REGISTRY])["data"] + version = raw["metadata"]["version"] + body = raw["data"] + rows = json.loads(body["senders.json"]) + except Exception: + return _fail("registry_read_failed") + if not isinstance(rows, list) or not rows: + return _fail("registry_must_preserve_existing_senders") + names = sender_names(rows) + before = {"registry_version": version, "senders": names} + _evidence("registry_read", before) + + existing = next((row for row in rows if row.get("name") == SENDER), None) + minted = False + if existing is not None: + tokens = existing.get("tokens") or [] + if not tokens: + return _fail("existing_tenant_engine_has_no_tokens") + _evidence("already_present", {"registry_version": version, "senders": names}) + else: + used = set() + for row in rows: + for token in row.get("tokens") or []: + used.add(token) + token = secrets.token_urlsafe(48) + if token in used: + return _fail("sender_token_collision") + updated = list(rows) + updated.append(desired(token)) + payload = dict(body, **{"senders.json": json.dumps(updated, separators=(",", ":"))}) + try: + result = bao( + ["write", "-format=json", REGISTRY, "-"], + payload={"options": {"cas": version}, "data": payload}, + ) + except Exception: + return _fail("registry_cas_failed") + minted = True + try: + after = bao(["read", "-format=json", REGISTRY])["data"] + after_rows = json.loads(after["data"]["senders.json"]) + after_names = sender_names(after_rows) + after_version = after["metadata"]["version"] + except Exception: + return _fail("registry_readback_failed") + if SENDER not in after_names or set(names) - {SENDER} != set(after_names) - {SENDER}: + return _fail("registry_readback_mismatch", {"senders": after_names}) + names = after_names + version = after_version + _evidence( + "registry_written", + { + "registry_version": version, + "senders": names, + "write_request_id": result.get("request_id"), + "minted": True, + }, + ) + + if kube( + [ + "-n", + "audit-core", + "annotate", + "externalsecret", + "audit-core-senders", + f"force-sync={int(time.time())}", + "--overwrite", + ] + ).returncode != 0: + return _fail("externalsecret_annotate_failed") + if not wait_secret_has_sender(): + return _fail("secret_sync_timeout", {"senders": names}) + _evidence("secret_synced", {"senders": secret_sender_names(), "minted": minted}) + + if kube(["-n", "audit-core", "rollout", "restart", "deploy/audit-core"]).returncode != 0: + return _fail("receiver_restart_failed") + if not wait_receiver_ready(): + return _fail("receiver_not_ready_after_restart") + live = secret_sender_names() + if SENDER not in live: + return _fail("secret_lost_tenant_engine", {"senders": live}) + _evidence( + "done", + { + "senders": live, + "has_tenant_engine_tokens": True, + "receiver_ready": True, + "minted": minted, + "registry_version": version, + }, + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except SystemExit: + raise + except Exception: + _fail("contained_operation_failed") + raise SystemExit(1)