From 1cdc18f75f7e71935aba1f3dd6ba3a4bf56674f4 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 19:01:44 +0100 Subject: [PATCH] NK-WP-0056-T10: one OpenBao login per lane; renew before the 15m lease ends The executor logs in separately for the bind write (CCR-2026-0058) and the journey read (CCR-2026-0059) and refuses one role for both; every login is revoked at exit, also when setup fails. Clients log in again (re-reading the rotating projected JWT) before the lease ends, since a run's budget (20m) outlasts a 15m token. Synthetic pilot with per-lane tokens: completed and reverted, all_met. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 5502@bnt-lap001 Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196 --- bounded-executor/cli.py | 33 ++-- bounded-executor/operations.py | 41 ++++- bounded-executor/synthetic_pilot.py | 15 +- bounded-executor/tests/test_openbao_lanes.py | 159 +++++++++++++++++ .../README.md | 24 +++ .../pilot-report.json | 162 ++++++++++++++++++ 6 files changed, 409 insertions(+), 25 deletions(-) create mode 100644 bounded-executor/tests/test_openbao_lanes.py create mode 100644 docs/evidence/2026-10-31-nk56-t10-openbao-lanes/README.md create mode 100644 docs/evidence/2026-10-31-nk56-t10-openbao-lanes/pilot-report.json diff --git a/bounded-executor/cli.py b/bounded-executor/cli.py index 09ef089..c230e47 100644 --- a/bounded-executor/cli.py +++ b/bounded-executor/cli.py @@ -9,10 +9,10 @@ The config (JSON, non-secret) names endpoints, token files and the pilot's targe trust_satisfied[...] trust states the operator attests for this run (recorded) approval_engine{url, token_file, ca_file?} audit_core{url, token_file, ca_file?} - openbao{addr, k8s_role, jwt_file, ca_file?} + openbao{addr, k8s_role, jwt_file, ca_file?} bind write role (CCR-2026-0058); jwt_file: audience-openbao token lldap{url, ca_file?, set_password: "/app/lldap_set_password"} kubernetes{namespace, external_secret, deployment} - bind{kv, user}, journey{kv, config{...}} + bind{kv, user}, journey{kv, k8s_role, config{...}} journey read role (CCR-2026-0059): one login per lane Exit codes: 0 completed, 3 reverted, 4 refused before any effect, 5 stopped / unknown / revert failed / budget, 6 consumed but not in custody. The result printed is value-free. @@ -43,15 +43,26 @@ C = _load("clients") EXIT = {"completed": 0, "reverted": 3, "refused": 4} -def build(cfg: dict): - bao = OPS.OpenBao(cfg["openbao"]["addr"], k8s_role=cfg["openbao"]["k8s_role"], - jwt_file=cfg["openbao"]["jwt_file"], ca_file=cfg["openbao"].get("ca_file")) +def build(cfg: dict, logins: list): + """`logins` collects every OpenBao client as soon as it exists, so the caller revokes them all, even when a + later step of setup fails.""" + o = cfg["openbao"] + if cfg["journey"]["k8s_role"] == o["k8s_role"]: + raise ValueError("the journey read and the bind write must use separate roles") + + def login(role: str): + client = OPS.OpenBao(o["addr"], k8s_role=role, jwt_file=o["jwt_file"], ca_file=o.get("ca_file")) + logins.append(client) + return client + + bao = login(o["k8s_role"]) + journey_bao = login(cfg["journey"]["k8s_role"]) directory = OPS.Directory(cfg["lldap"]["url"], [cfg["lldap"].get("set_password", "/app/lldap_set_password")], ca_file=cfg["lldap"].get("ca_file")) kube = OPS.Kube() k = cfg["kubernetes"] operations, checks = OPS.pilot_registry( - bao=bao, directory=directory, delivery=OPS.KubeDelivery(kube, k["namespace"], k["external_secret"]), + bao=bao, journey_bao=journey_bao, directory=directory, delivery=OPS.KubeDelivery(kube, k["namespace"], k["external_secret"]), restart=OPS.KubeRestart(kube, k["namespace"], k["deployment"]), bind_kv=cfg["bind"]["kv"], bind_user=cfg["bind"]["user"], journey_kv=cfg["journey"]["kv"], journey_config=cfg["journey"]["config"]) trusted = set(cfg.get("trust_satisfied", [])) @@ -68,7 +79,7 @@ def build(cfg: dict): 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"])) - return config, deps, bao + return config, deps def exit_code(result: dict) -> int: @@ -86,15 +97,15 @@ def main(argv=None) -> int: args = parser.parse_args(argv) cfg = json.loads(pathlib.Path(args.config).read_text()) admission = X.bea.load(pathlib.Path(args.admission)) - bao = None + logins: list = [] try: - config, deps, bao = build(cfg) + config, deps = build(cfg, logins) result = X.Run(admission, args.approval_id, config, deps).execute() except Exception as error: # setup failed before preflight: nothing was consumed result = {"outcome": "refused", "reason": "setup:" + type(error).__name__, "run_id": None, "effects": False} finally: - if bao is not None: - bao.revoke_self() + for client in logins: + client.revoke_self() text = json.dumps(result, indent=2, sort_keys=True) if args.result: pathlib.Path(args.result).write_text(text + "\n") diff --git a/bounded-executor/operations.py b/bounded-executor/operations.py index 7018f14..10ba231 100644 --- a/bounded-executor/operations.py +++ b/bounded-executor/operations.py @@ -69,12 +69,29 @@ class OpenBao: self.open = _opener(ca_file).open self.timeout = timeout self.token = token + self.login_args = None if token is not None else (k8s_mount, k8s_role, jwt_file) + self.renew_at = None # monotonic time after which the next call logs in again if self.token is None: - jwt = pathlib.Path(jwt_file).read_text().strip() - auth = self._call("POST", f"/v1/auth/{k8s_mount}/login", {"role": k8s_role, "jwt": jwt}, auth=False) - self.token = auth["auth"]["client_token"] + self._login() + + RENEW_MARGIN = 120 # seconds before the lease ends + + def _login(self) -> None: + """Kubernetes-auth login. The role's tokens live 15m and a run's budget may be longer, so the client logs + in again before the lease ends (and revokes the old token). The projected JWT is re-read every time: + kubelet rotates it.""" + mount, role, jwt_file = self.login_args + jwt = pathlib.Path(jwt_file).read_text().strip() + auth = self._call("POST", f"/v1/auth/{mount}/login", {"role": role, "jwt": jwt}, auth=False)["auth"] + old, self.token = self.token, auth["client_token"] + lease = int(auth.get("lease_duration") or 0) + self.renew_at = time.monotonic() + max(lease - self.RENEW_MARGIN, lease / 2) if lease else None + if old is not None: + self._revoke(old) def _call(self, method: str, path: str, body: dict | None = None, auth: bool = True) -> dict: + if auth and self.login_args and self.renew_at is not None and time.monotonic() >= self.renew_at: + self._login() headers = {"Content-Type": "application/json"} if auth: headers["X-Vault-Token"] = self.token @@ -101,8 +118,15 @@ class OpenBao: return int(self._call("POST", self._api(kv_path), {"options": {"cas": cas}, "data": data})["data"]["version"]) def revoke_self(self) -> None: + if self.token is not None: + self._revoke(self.token) + + def _revoke(self, token: str) -> None: + request = urllib.request.Request(self.addr + "/v1/auth/token/revoke-self", method="POST", data=b"{}", + headers={"Content-Type": "application/json", "X-Vault-Token": token}) try: - self._call("POST", "/v1/auth/token/revoke-self", {}) + with self.open(request, timeout=self.timeout): + pass except Exception: pass @@ -232,9 +256,10 @@ class KubeRestart: # -------------------------------------------------------------------------------------------------- -def pilot_registry(*, bao: OpenBao, directory: Directory, delivery, restart, bind_kv: str, bind_user: str, - journey_kv: str, journey_config, journey_state: dict | None = None): - """Return (operations, checks) for the bind-rotation admission. `delivery` has force_sync()/synced(since); +def pilot_registry(*, bao: OpenBao, journey_bao: OpenBao, directory: Directory, delivery, restart, bind_kv: str, + bind_user: str, journey_kv: str, journey_config, journey_state: dict | None = None): + """Return (operations, checks) for the bind-rotation admission. `bao` holds only the bind write role + (CCR-2026-0058); `journey_bao` only the journey read role (CCR-2026-0059): one role per lane. `delivery` has force_sync()/synced(since); `restart` has restart(). Values stay in ctx.data. `journey_state` carries the last one-time-code time step across runs (privacyIDEA accepts each 30 s step once).""" shared = journey_state if journey_state is not None else {} @@ -299,7 +324,7 @@ def pilot_registry(*, bao: OpenBao, directory: Directory, delivery, restart, bin time.sleep(2) def check_journey(check, ctx): - creds, _ = bao.read(journey_kv) + creds, _ = journey_bao.read(journey_kv) cfg = _load_config(journey_config() if callable(journey_config) else journey_config) last = shared.get("journey_step") receipt = journey.run_journey(cfg, creds["PASSWORD"], journey.decode_seed(creds["TOTP_SEED"]), diff --git a/bounded-executor/synthetic_pilot.py b/bounded-executor/synthetic_pilot.py index 41f1c07..8515d13 100644 --- a/bounded-executor/synthetic_pilot.py +++ b/bounded-executor/synthetic_pilot.py @@ -89,9 +89,11 @@ class OpenBaoDev: "TOTP_SEED": base64.b32encode(seed).decode()}, cas=0) self.call("PUT", "/v1/sys/policies/acl/bind-executor-write", {"policy": POLICY_FILE.read_text()}) self.call("PUT", "/v1/sys/policies/acl/journey-read", {"policy": JOURNEY_POLICY}) - token = self.call("POST", "/v1/auth/token/create", {"policies": ["bind-executor-write", "journey-read"], - "no_default_policy": True, "ttl": "15m"}) - return token["auth"]["client_token"] + tokens = [] + for policy in ("bind-executor-write", "journey-read"): # one token per lane, as the live roles + tokens.append(self.call("POST", "/v1/auth/token/create", {"policies": [policy], "no_default_policy": True, + "ttl": "15m"})["auth"]["client_token"]) + return tuple(tokens) def remove(self): stack_mod.docker("rm", "--force", self.name, check=False) @@ -210,13 +212,14 @@ def main(argv=None): try: stack.up() bao = OpenBaoDev(stack.network, stack.tag) - executor_token = bao.setup(stack.pw["svc-authelia"], stack.pw[stack_mod.USER], stack.seed) + executor_token, journey_token = bao.setup(stack.pw["svc-authelia"], stack.pw[stack_mod.USER], stack.seed) executor_bao = OPS.OpenBao(bao.addr, token=executor_token) + journey_bao = OPS.OpenBao(bao.addr, token=journey_token) directory = OPS.Directory(stack.lldap, lldap_command()) delivery = Delivery(stack, bao.admin) journey_state = {} operations, checks = OPS.pilot_registry( - bao=executor_bao, directory=directory, delivery=delivery, restart=Restart(stack), + bao=executor_bao, journey_bao=journey_bao, directory=directory, delivery=delivery, restart=Restart(stack), bind_kv=BIND_KV, bind_user="svc-authelia", journey_kv=JOURNEY_KV, journey_config=stack.journey_config, journey_state=journey_state) carrier, events, alerts = MemoryCarrier(), [], [] @@ -259,7 +262,7 @@ def main(argv=None): and all(s[2] == "done" for s in revert["steps"] if s[1] == "revert")) report["runs"].append(revert) report["alerts"] = alerts - secrets_in_report = [stack.pw["svc-authelia"], executor_token, bao.root] + secrets_in_report = [stack.pw["svc-authelia"], executor_token, journey_token, bao.root] text = json.dumps(report) report["no_values_in_report"] = not any(v in text for v in secrets_in_report) ok = rotation["met"] and revert["met"] and report["no_values_in_report"] diff --git a/bounded-executor/tests/test_openbao_lanes.py b/bounded-executor/tests/test_openbao_lanes.py new file mode 100644 index 0000000..2425453 --- /dev/null +++ b/bounded-executor/tests/test_openbao_lanes.py @@ -0,0 +1,159 @@ +"""One OpenBao login per lane, renewal before the lease ends, revocation on every path (NK-WP-0056-T10). Fakes only.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import pathlib +import sys +import tempfile +import threading +import time +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +HERE = pathlib.Path(__file__).resolve().parent + + +def load(name): + spec = importlib.util.spec_from_file_location(name, HERE.parent / f"{name}.py") + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +CLI = load("cli") +OPS = sys.modules["operations"] + + +class Bao: + def __init__(self, lease=900): + self.lease = lease + self.logins, self.revoked, self.reads = [], [], [] + self.n = 0 + + +def serve(bao: Bao): + class H(BaseHTTPRequestHandler): + def _send(self, body): + raw = json.dumps(body).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_POST(self): + body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}") + if self.path == "/v1/auth/kubernetes/login": + bao.n += 1 + bao.logins.append((body["role"], body["jwt"])) + return self._send({"auth": {"client_token": f"tok-{body['role']}-{bao.n}", + "lease_duration": bao.lease}}) + if self.path == "/v1/auth/token/revoke-self": + bao.revoked.append(self.headers.get("X-Vault-Token")) + return self._send({}) + self._send({}) + + def do_GET(self): + bao.reads.append((self.path, self.headers.get("X-Vault-Token"))) + self._send({"data": {"data": {"PASSWORD": "p", "TOTP_SEED": "GEZDGNBV"}, "metadata": {"version": 3}}}) + + def log_message(self, *a): + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), H) + threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.02}, daemon=True).start() + return server + + +class Base(unittest.TestCase): + def setUp(self): + self.bao = Bao() + self.server = serve(self.bao) + self.addCleanup(self.server.shutdown) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + self.root = pathlib.Path(tmp.name) + (self.root / "jwt").write_text("synthetic-openbao-audience-jwt\n") + self.addr = f"http://127.0.0.1:{self.server.server_port}" + + +class CliLanes(Base): + def run_cli(self, cfg): + (self.root / "cfg.json").write_text(json.dumps(cfg)) + admission = CLI.X.REPO / "examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml" + with contextlib.redirect_stdout(io.StringIO()) as out: + code = CLI.main(["--admission", str(admission), "--approval-id", "apr-1", + "--config", str(self.root / "cfg.json")]) + return code, json.loads(out.getvalue()) + + def cfg(self, journey_role="journey-read"): + # lldap is missing on purpose: setup fails after both logins + return {"openbao": {"addr": self.addr, "k8s_role": "bind-write", "jwt_file": str(self.root / "jwt")}, + "journey": {"kv": "x", "k8s_role": journey_role, "config": {}}} + + def test_two_lanes_two_logins_both_revoked_when_setup_fails(self): + code, result = self.run_cli(self.cfg()) + self.assertEqual((code, result["reason"]), (4, "setup:KeyError")) + self.assertEqual([role for role, _ in self.bao.logins], ["bind-write", "journey-read"]) + self.assertEqual(sorted(self.bao.revoked), ["tok-bind-write-1", "tok-journey-read-2"]) + + def test_one_role_for_both_lanes_is_refused_before_any_login(self): + code, result = self.run_cli(self.cfg(journey_role="bind-write")) + self.assertEqual((code, result["reason"]), (4, "setup:ValueError")) + self.assertEqual(self.bao.logins, []) + + +class Renewal(Base): + def test_logs_in_again_before_the_lease_ends_and_revokes_the_old_token(self): + self.bao.lease = 1 # renew_at = max(1 - margin, 0.5 s) + client = OPS.OpenBao(self.addr, k8s_role="journey-read", jwt_file=str(self.root / "jwt")) + client.read("platform/workloads/x") + time.sleep(0.6) + client.read("platform/workloads/x") + self.assertEqual(len(self.bao.logins), 2) + self.assertEqual(self.bao.revoked, ["tok-journey-read-1"]) + self.assertEqual([tok for _, tok in self.bao.reads], ["tok-journey-read-1", "tok-journey-read-2"]) + + def test_a_long_lease_is_not_renewed(self): + client = OPS.OpenBao(self.addr, k8s_role="r", jwt_file=str(self.root / "jwt")) + client.read("platform/workloads/x") + client.read("platform/workloads/x") + self.assertEqual(len(self.bao.logins), 1) + + def test_a_given_token_never_logs_in(self): + client = OPS.OpenBao(self.addr, token="static") + client.read("platform/workloads/x") + client.revoke_self() + self.assertEqual((self.bao.logins, self.bao.revoked), ([], ["static"])) + + +class JourneyUsesItsOwnLane(unittest.TestCase): + def test_journey_check_reads_only_through_the_journey_client(self): + class Recorder: + def __init__(self, name): + self.name, self.reads = name, [] + + def read(self, path): + self.reads.append(path) + return {"PASSWORD": "p", "TOTP_SEED": "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"}, 1 + + write, read = Recorder("write"), Recorder("read") + _, checks = OPS.pilot_registry(bao=write, journey_bao=read, directory=None, delivery=None, restart=None, + bind_kv="b", bind_user="svc-authelia", journey_kv="j", journey_config={}) + original = (OPS.journey.run_journey, OPS._load_config) + OPS.journey.run_journey = lambda cfg, pw, seed, min_time_step=None: {"result": "pass"} + OPS._load_config = lambda cfg: cfg + try: + self.assertTrue(checks["synthetic-journey"]({}, type("Ctx", (), {"data": {}})())) + finally: + OPS.journey.run_journey, OPS._load_config = original + self.assertEqual((write.reads, read.reads), ([], ["j"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/evidence/2026-10-31-nk56-t10-openbao-lanes/README.md b/docs/evidence/2026-10-31-nk56-t10-openbao-lanes/README.md new file mode 100644 index 0000000..28f4d80 --- /dev/null +++ b/docs/evidence/2026-10-31-nk56-t10-openbao-lanes/README.md @@ -0,0 +1,24 @@ +# NK-WP-0056-T10: one OpenBao login per lane (2026-10-31) + +The executor now holds two OpenBao clients: + +- the bind write lane, CCR-2026-0058, role `net-kingdom-bind-executor-svc-authelia-write`; +- the journey read lane, CCR-2026-0059, role `net-kingdom-bind-executor-syn-journey-read`. + +Both log in with the audience-`openbao` projected token, log in again before their 15m lease ends, and are revoked at +exit, including when setup fails. + +`pilot-report.json`: `uv run bounded-executor/synthetic_pilot.py` on the T08 stack plus a dev OpenBao, with one token +per policy, as the live roles will issue. Results: + +- the rotation `completed`; +- the deliberately wrong delivery was `reverted`; +- `all_met: true`; +- `no_values_in_report: true`. + +Unit tests are in `bounded-executor/tests/test_openbao_lanes.py`: + +- two roles, two logins, both revoked; +- a shared role is refused before any login; +- renewal revokes the old token; +- the journey check reads only through the journey client. diff --git a/docs/evidence/2026-10-31-nk56-t10-openbao-lanes/pilot-report.json b/docs/evidence/2026-10-31-nk56-t10-openbao-lanes/pilot-report.json new file mode 100644 index 0000000..fdfe4ad --- /dev/null +++ b/docs/evidence/2026-10-31-nk56-t10-openbao-lanes/pilot-report.json @@ -0,0 +1,162 @@ +{ + "schema": "netkingdom.bounded-executor.synthetic-pilot/v1", + "started_at": "2026-10-31T17:58:59+00:00", + "images": { + "openbao": "quay.io/openbao/openbao@sha256:436eaf9778cad75507ff70ea26ace30dcbe15606e619ac3823495663d7f7c115" + }, + "policy_file_sha256": "f14f0e0b885302b5931c1e84e258b5ee32a596fafab5b961d88becf7825eeeb0", + "runs": [ + { + "admission": "BEA-2026-9001", + "outcome": "completed", + "reason": null, + "kv_version": [ + 1, + 2 + ], + "kv_value_changed": true, + "previous_value_refused_by_lldap": true, + "current_kv_value_signs_in": true, + "steps": [ + [ + "generate", + "step", + "done", + [ + "pass" + ], + null + ], + [ + "set-directory", + "step", + "done", + [ + "pass" + ], + null + ], + [ + "deliver", + "step", + "done", + [ + "pass" + ], + null + ], + [ + "restart", + "step", + "done", + [ + "pass" + ], + null + ] + ], + "reverted": false, + "pdp": "not-evaluated", + "events": [ + "execution.run.started", + "execution.attribution.receipt" + ], + "met": true + }, + { + "admission": "BEA-2026-9002", + "outcome": "reverted", + "reason": null, + "kv_version": [ + 2, + 4 + ], + "kv_value_changed": false, + "previous_value_refused_by_lldap": false, + "current_kv_value_signs_in": true, + "steps": [ + [ + "generate", + "step", + "done", + [ + "pass" + ], + null + ], + [ + "set-directory", + "step", + "done", + [ + "pass" + ], + null + ], + [ + "deliver", + "step", + "done", + [ + "pass" + ], + null + ], + [ + "restart", + "step", + "failed", + [], + "RolloutFailed" + ], + [ + "restore-kv", + "revert", + "done", + [ + "pass" + ], + null + ], + [ + "restore-directory", + "revert", + "done", + [ + "pass" + ], + null + ], + [ + "redeliver", + "revert", + "done", + [ + "pass" + ], + null + ], + [ + "restart-again", + "revert", + "done", + [ + "pass" + ], + null + ] + ], + "reverted": true, + "pdp": "not-evaluated", + "events": [ + "execution.run.started", + "execution.attribution.receipt" + ], + "met": true + } + ], + "alerts": [], + "no_values_in_report": true, + "all_met": true, + "finished_at": "2026-10-31T17:59:58+00:00" +}