From abed4360d495b348c9a9aac541c10a48e56bedf3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 19:56:52 +0100 Subject: [PATCH] NK-WP-0056-T10: journey-only executor mode; fail-closed proof on the T08 stack cli.py --journey-only: read-only, no admission; journey-read role only (CCR-2026-0059), no Kubernetes/directory client, no approval or audit call; exit 0 pass / 1 fail closed / 4 refused. Shared operations.journey_from_kv with the admission check. Synthetic proof: healthy pass, broken svc-authelia bind fails closed at authelia_first_factor, restored pass, journey token cannot read the bind; all_met, no values. 45 executor tests. 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/README.md | 4 + bounded-executor/cli.py | 56 +++++++++- bounded-executor/operations.py | 26 +++-- bounded-executor/synthetic_journey_only.py | 105 ++++++++++++++++++ bounded-executor/tests/test_openbao_lanes.py | 56 +++++++++- .../README.md | 24 ++++ .../synthetic-report.json | 48 ++++++++ workplans/NK-WP-0056-bounded-executor-path.md | 26 ++++- 8 files changed, 327 insertions(+), 18 deletions(-) create mode 100644 bounded-executor/synthetic_journey_only.py create mode 100644 docs/evidence/2026-10-31-nk56-t10-journey-only/README.md create mode 100644 docs/evidence/2026-10-31-nk56-t10-journey-only/synthetic-report.json diff --git a/bounded-executor/README.md b/bounded-executor/README.md index d91d73d..6bad9ab 100644 --- a/bounded-executor/README.md +++ b/bounded-executor/README.md @@ -44,6 +44,10 @@ without guessing. `synthetic_pilot.py` runs a rotation and a deliberate revert on the T08 stack plus OpenBao: `uv run bounded-executor/synthetic_pilot.py`. +`cli.py --journey-only` (T10) is a read-only run with no admission. It uses the journey-read role only (CCR-2026-0059), +runs the synthetic journey once and prints its value-free receipt. Exit codes: `0` pass, `1` fail closed, `4` refused. +`synthetic_journey_only.py` proves it on the T08 stack: it passes when healthy and fails closed on a broken bind. + `cli.py` (T13) is the Job's entry point: one admission, one approval id, a non-secret config. Exit codes: `0` completed, `3` reverted, `4` refused before any effect, `5` stopped, unknown or failed revert, `6` consumed but not in custody. `Containerfile` builds the image, which `.forgejo/workflows/bounded-executor-image.yaml` publishes by digest. diff --git a/bounded-executor/cli.py b/bounded-executor/cli.py index c230e47..09c800c 100644 --- a/bounded-executor/cli.py +++ b/bounded-executor/cli.py @@ -16,6 +16,13 @@ The config (JSON, non-secret) names endpoints, token files and the pilot's targe 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. + + python3 bounded-executor/cli.py --journey-only --config FILE [--result FILE] + +Journey-only (NK-WP-0056-T10): read-only, no admission. Logs in with the journey role only (journey.k8s_role, the +openbao-audience token), runs the synthetic journey once and prints its value-free receipt. No write role, no +Kubernetes or directory client, no approval or audit call. Needs only openbao{addr, jwt_file, ca_file?} and +journey{kv, k8s_role, config}. Exit codes: 0 pass, 1 journey failed (fail closed), 4 refused before the journey. """ from __future__ import annotations @@ -82,6 +89,30 @@ def build(cfg: dict, logins: list): return config, deps +def journey_only(cfg: dict, logins: list) -> dict: + o, j = cfg["openbao"], cfg["journey"] + client = OPS.OpenBao(o["addr"], k8s_role=j["k8s_role"], jwt_file=o["jwt_file"], ca_file=o.get("ca_file")) + logins.append(client) + receipt = OPS.journey_from_kv(client, j["kv"], j["config"]) + return {"mode": "journey-only", "outcome": "pass" if receipt["result"] == "pass" else "fail", + "effects": False, "journey": receipt} + + +def run_journey_only(cfg: dict) -> tuple[dict, int]: + logins: list = [] + try: + result = journey_only(cfg, logins) + code = 0 if result["outcome"] == "pass" else 1 + except Exception as error: # nothing was changed: the journey only reads and signs in + result = {"mode": "journey-only", "outcome": "refused", "reason": "setup:" + type(error).__name__, + "effects": False} + code = 4 + finally: + for client in logins: + client.revoke_self() + return result, code + + def exit_code(result: dict) -> int: if result.get("reason") in ("receipt_not_in_custody", "audit_unavailable") and result["outcome"] != "refused": return 6 @@ -90,12 +121,21 @@ def exit_code(result: dict) -> int: def main(argv=None) -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) - parser.add_argument("--admission", required=True) - parser.add_argument("--approval-id", required=True) + parser.add_argument("--journey-only", action="store_true", help="read-only journey run, no admission (T10)") + parser.add_argument("--admission") + parser.add_argument("--approval-id") parser.add_argument("--config", required=True) parser.add_argument("--result", help="also write the result here") args = parser.parse_args(argv) + if args.journey_only == bool(args.admission or args.approval_id): + parser.error("give either --journey-only, or --admission with --approval-id") + if not args.journey_only and not (args.admission and args.approval_id): + parser.error("--admission and --approval-id go together") cfg = json.loads(pathlib.Path(args.config).read_text()) + if args.journey_only: + result, code = run_journey_only(cfg) + _emit(result, args.result) + return code admission = X.bea.load(pathlib.Path(args.admission)) logins: list = [] try: @@ -106,12 +146,16 @@ def main(argv=None) -> int: finally: 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") - print(text) + _emit(result, args.result) return exit_code(result) +def _emit(result: dict, path: str | None) -> None: + text = json.dumps(result, indent=2, sort_keys=True) + if path: + pathlib.Path(path).write_text(text + "\n") + print(text) + + if __name__ == "__main__": sys.exit(main()) diff --git a/bounded-executor/operations.py b/bounded-executor/operations.py index 10ba231..5130794 100644 --- a/bounded-executor/operations.py +++ b/bounded-executor/operations.py @@ -324,13 +324,7 @@ def pilot_registry(*, bao: OpenBao, journey_bao: OpenBao, directory: Directory, time.sleep(2) def check_journey(check, ctx): - 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"]), - min_time_step=None if last is None else last + 1) - if receipt.get("otp_time_step") is not None: - shared["journey_step"] = receipt["otp_time_step"] + receipt = journey_from_kv(journey_bao, journey_kv, journey_config, shared) ctx.data.setdefault("journeys", []).append({k: receipt.get(k) for k in ("result", "failed_step", "reason")}) return receipt["result"] == "pass" @@ -346,6 +340,24 @@ def pilot_registry(*, bao: OpenBao, journey_bao: OpenBao, directory: Directory, return operations, checks +def journey_from_kv(journey_bao: OpenBao, journey_kv: str, journey_config, state: dict | None = None) -> dict: + """Read the synthetic identity through the journey role and run the journey once. Shared by the admission check + (`synthetic-journey`) and the journey-only run (NK-WP-0056-T10). The values stay in this frame; the returned + receipt is the journey runner's value-free receipt. `state` carries the last one-time-code time step, because + privacyIDEA accepts each 30 s step only once.""" + state = state if state is not None else {} + creds, _ = journey_bao.read(journey_kv) + if set(creds) != {"PASSWORD", "TOTP_SEED"}: + raise OperationError("journey kv shape") + cfg = _load_config(journey_config() if callable(journey_config) else journey_config) + last = state.get("journey_step") + receipt = journey.run_journey(cfg, creds["PASSWORD"], journey.decode_seed(creds["TOTP_SEED"]), + min_time_step=None if last is None else last + 1) + if receipt.get("otp_time_step") is not None: + state["journey_step"] = receipt["otp_time_step"] + return receipt + + def _load_config(config: dict) -> dict: import tempfile fd, path = tempfile.mkstemp(suffix=".json", prefix="journey-") diff --git a/bounded-executor/synthetic_journey_only.py b/bounded-executor/synthetic_journey_only.py new file mode 100644 index 0000000..2e4aa51 --- /dev/null +++ b/bounded-executor/synthetic_journey_only.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["PyYAML>=6,<7", "jsonschema>=4,<5"] +# /// +"""Journey-only run on the T08 synthetic stack: passes when healthy, fails closed on a broken bind (NK-WP-0056-T10). + +The same code path the Job runs with `cli.py --journey-only` (`operations.journey_from_kv`). The difference is the +login: here a dev-OpenBao token that holds only the journey-read policy, instead of Kubernetes auth. Scenarios: + + healthy the journey passes + broken bind svc-authelia's password is changed in LLDAP while Authelia keeps the old one (a half-done + rotation). The journey must fail closed at authelia_first_factor. + restored the password is set back and the journey passes again + lane boundary the journey token cannot read the svc-authelia bind + +Run: uv run bounded-executor/synthetic_journey_only.py [--report FILE]. Exit 0 when every expectation is met. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import secrets +import sys +import tempfile + +HERE = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(HERE)) +import synthetic_pilot as P # noqa: E402 (loads executor, operations and the T08 stack) + +OPS, stack_mod = P.OPS, P.stack_mod + + +def summary(receipt: dict) -> dict: + return {k: receipt.get(k) for k in ("result", "failed_step", "reason")} + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument("--report") + args = parser.parse_args(argv) + report = {"schema": "netkingdom.bounded-executor.synthetic-journey-only/v1", + "started_at": OPS.now_utc().isoformat(), "images": {"openbao": P.OPENBAO_IMAGE}, + "code_path": "operations.journey_from_kv (cli.py --journey-only)", "results": []} + ok = False + with tempfile.TemporaryDirectory(prefix="nk56-journey-only-") as tmp: + stack = stack_mod.Stack(pathlib.Path(tmp), pi_cache_timeout=120) + bao = None + secrets_in_report = [] + try: + stack.up() + bao = P.OpenBaoDev(stack.network, stack.tag) + write_token, journey_token = bao.setup(stack.pw["svc-authelia"], stack.pw[stack_mod.USER], stack.seed) + secrets_in_report = [stack.pw["svc-authelia"], stack.pw[stack_mod.USER], write_token, journey_token, bao.root] + journey_bao = OPS.OpenBao(bao.addr, token=journey_token) + state: dict = {} + + def run(): + return OPS.journey_from_kv(journey_bao, P.JOURNEY_KV, stack.journey_config(), state) + + healthy = run() + report["results"].append({"scenario": "healthy", "expected": "pass", "journey": summary(healthy), + "met": healthy["result"] == "pass"}) + + stack.set_password(stack.lldap_token(), "svc-authelia", secrets.token_urlsafe(24)) + broken = run() + report["results"].append({"scenario": "broken bind (svc-authelia)", + "expected": "fail at authelia_first_factor", "journey": summary(broken), + "met": broken["result"] == "fail" + and broken["failed_step"] == "authelia_first_factor"}) + + stack.set_password(stack.lldap_token(), "svc-authelia", stack.pw["svc-authelia"]) + restored = run() + report["results"].append({"scenario": "restored", "expected": "pass", "journey": summary(restored), + "met": restored["result"] == "pass"}) + + try: + journey_bao.read(P.BIND_KV) + denied = False + except OPS.OperationError: + denied = True + report["results"].append({"scenario": "lane boundary", "expected": "journey token cannot read the bind", + "met": denied}) + journey_bao.revoke_self() + ok = all(r["met"] for r in report["results"]) + except Exception as error: # report the type only: messages could carry context + report["error"] = type(error).__name__ + finally: + if bao: + bao.remove() + stack.down() + text = json.dumps(report, sort_keys=True) + report["no_values_in_report"] = not any(s and s in text for s in secrets_in_report) + report["all_met"] = ok and report["no_values_in_report"] + report["finished_at"] = OPS.now_utc().isoformat() + out = json.dumps(report, indent=2, sort_keys=True) + if args.report: + pathlib.Path(args.report).write_text(out + "\n") + print(out) + return 0 if report["all_met"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bounded-executor/tests/test_openbao_lanes.py b/bounded-executor/tests/test_openbao_lanes.py index 2425453..71530ca 100644 --- a/bounded-executor/tests/test_openbao_lanes.py +++ b/bounded-executor/tests/test_openbao_lanes.py @@ -60,7 +60,7 @@ def serve(bao: Bao): 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}}}) + self._send({"data": {"data": {"PASSWORD": "p", "TOTP_SEED": "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"}, "metadata": {"version": 3}}}) def log_message(self, *a): return @@ -108,6 +108,60 @@ class CliLanes(Base): self.assertEqual(self.bao.logins, []) +class JourneyOnly(Base): + SEED = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + + def setUp(self): + super().setUp() + self.calls = [] + original = (OPS.journey.run_journey, OPS._load_config) + self.addCleanup(lambda: (setattr(OPS.journey, "run_journey", original[0]), + setattr(OPS, "_load_config", original[1]))) + OPS._load_config = lambda cfg: cfg + self.result = "pass" + + def run_journey(cfg, password, seed, min_time_step=None): + self.calls.append(cfg) + return {"result": self.result, "failed_step": None if self.result == "pass" else "authelia_first_factor", + "reason": None, "otp_time_step": 1} + OPS.journey.run_journey = run_journey + + def cfg(self, **journey): + # write role, approval-engine, audit-core, lldap and kubernetes are absent on purpose: not needed + return {"openbao": {"addr": self.addr, "k8s_role": "bind-write", "jwt_file": str(self.root / "jwt")}, + "journey": {"kv": "platform/workloads/x", "k8s_role": "journey-read", "config": {"c": 1}, **journey}} + + def run_cli(self, cfg, *extra): + (self.root / "cfg.json").write_text(json.dumps(cfg)) + with contextlib.redirect_stdout(io.StringIO()) as out: + code = CLI.main(["--journey-only", "--config", str(self.root / "cfg.json"), *extra]) + return code, json.loads(out.getvalue()) + + def test_pass_logs_in_with_the_journey_role_only_and_revokes(self): + code, result = self.run_cli(self.cfg()) + self.assertEqual((code, result["outcome"], result["effects"]), (0, "pass", False)) + self.assertEqual([role for role, _ in self.bao.logins], ["journey-read"]) + self.assertEqual(self.bao.revoked, ["tok-journey-read-1"]) + self.assertEqual(self.calls, [{"c": 1}]) + self.assertNotIn(self.SEED, json.dumps(result)) + + def test_failed_journey_fails_closed_with_exit_1(self): + self.result = "fail" + code, result = self.run_cli(self.cfg()) + self.assertEqual((code, result["outcome"], result["journey"]["failed_step"]), (1, "fail", "authelia_first_factor")) + + def test_incomplete_config_is_refused_before_any_login(self): + cfg = self.cfg() + del cfg["journey"]["k8s_role"] + code, result = self.run_cli(cfg) + self.assertEqual((code, result["reason"]), (4, "setup:KeyError")) + self.assertEqual(self.bao.logins, []) + + def test_admission_arguments_conflict_with_journey_only(self): + with self.assertRaises(SystemExit), contextlib.redirect_stderr(io.StringIO()): + self.run_cli(self.cfg(), "--approval-id", "apr-1") + + 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) diff --git a/docs/evidence/2026-10-31-nk56-t10-journey-only/README.md b/docs/evidence/2026-10-31-nk56-t10-journey-only/README.md new file mode 100644 index 0000000..7532a9a --- /dev/null +++ b/docs/evidence/2026-10-31-nk56-t10-journey-only/README.md @@ -0,0 +1,24 @@ +# NK-WP-0056-T10: journey-only run, synthetic proof (2026-10-31) + +`bounded-executor/cli.py --journey-only` is a read-only run that needs no admission: + +- it logs in with the journey-read role only (CCR-2026-0059); +- it reads the syn-journey identity and runs the synthetic MFA journey once; +- it prints the journey's value-free receipt. + +It has no write role, no Kubernetes or directory client, and no approval or audit call. Exit codes: `0` pass, `1` +fail closed, `4` refused before the journey. + +`synthetic-report.json` comes from `uv run bounded-executor/synthetic_journey_only.py`. It runs the same code path +(`operations.journey_from_kv`) on the T08 stack (pinned LLDAP, privacyIDEA, Authelia and KeyCape) plus a dev OpenBao +token that holds only the journey-read policy: + +| Scenario | Expected | Result | +| --- | --- | --- | +| healthy | pass | pass | +| broken bind: svc-authelia changed in LLDAP, Authelia keeps the old value | fail closed at `authelia_first_factor` | fail at `authelia_first_factor` (`first_factor_rejected`) | +| restored | pass | pass | +| lane boundary | journey token cannot read the svc-authelia bind | denied | + +`all_met: true`, `no_values_in_report: true`. This is T10's fail-closed proof. The unattended live run is the +rapp's `make journey`. diff --git a/docs/evidence/2026-10-31-nk56-t10-journey-only/synthetic-report.json b/docs/evidence/2026-10-31-nk56-t10-journey-only/synthetic-report.json new file mode 100644 index 0000000..6b3f231 --- /dev/null +++ b/docs/evidence/2026-10-31-nk56-t10-journey-only/synthetic-report.json @@ -0,0 +1,48 @@ +{ + "all_met": true, + "code_path": "operations.journey_from_kv (cli.py --journey-only)", + "finished_at": "2026-10-31T18:56:06+00:00", + "images": { + "openbao": "quay.io/openbao/openbao@sha256:436eaf9778cad75507ff70ea26ace30dcbe15606e619ac3823495663d7f7c115" + }, + "no_values_in_report": true, + "results": [ + { + "expected": "pass", + "journey": { + "failed_step": null, + "reason": null, + "result": "pass" + }, + "met": true, + "scenario": "healthy" + }, + { + "expected": "fail at authelia_first_factor", + "journey": { + "failed_step": "authelia_first_factor", + "reason": "first_factor_rejected", + "result": "fail" + }, + "met": true, + "scenario": "broken bind (svc-authelia)" + }, + { + "expected": "pass", + "journey": { + "failed_step": null, + "reason": null, + "result": "pass" + }, + "met": true, + "scenario": "restored" + }, + { + "expected": "journey token cannot read the bind", + "met": true, + "scenario": "lane boundary" + } + ], + "schema": "netkingdom.bounded-executor.synthetic-journey-only/v1", + "started_at": "2026-10-31T18:55:19+00:00" +} diff --git a/workplans/NK-WP-0056-bounded-executor-path.md b/workplans/NK-WP-0056-bounded-executor-path.md index 64e1c0b..4ea7f87 100644 --- a/workplans/NK-WP-0056-bounded-executor-path.md +++ b/workplans/NK-WP-0056-bounded-executor-path.md @@ -461,12 +461,30 @@ with `T10-LANES-RUNBOOK.md`: - `verify`: the journey token reads only its path, with a lease of at most 15m. A wrong ServiceAccount, a wrong audience and a login to the write role are all denied. +**2026-10-31, journey-only mode** ([evidence](../docs/evidence/2026-10-31-nk56-t10-journey-only/README.md)): + +- `cli.py --journey-only` is read-only and needs no admission: + - it logs in with the journey-read role only; + - it builds no Kubernetes or directory client and makes no approval or audit call; + - exit codes: `0` pass, `1` fail closed, `4` refused. +- The admission check and this mode share `operations.journey_from_kv`. 45 executor tests. +- Synthetic proof on the T08 stack with a journey-only OpenBao token: + - healthy: pass; + - broken svc-authelia bind: fail closed at `authelia_first_factor`; + - restored: pass; + - the journey token cannot read the bind; + - `all_met`, no values. + + This is T10's fail-closed proof. +- rapp: `templates/journey-job.yaml` and `render_job.py --journey-only`: + - the config holds only `openbao` (no role) and `journey`; + - the Job mounts only the openbao-audience token; + - founder-run with `make journey`. + Left for T10: -- An executor run mode for the journey alone. It is a read-only Job with no admission: journey role only, no write - role, no Kubernetes mutation, and a value-free receipt. The CLI today runs only admissions. -- Its unattended live run. -- The fail-closed proof against a broken bind on the T08 synthetic directory. +- the image for this commit, and its pin; +- the first `make journey` against live (founder starts the Job; no person in the journey itself). For T06, separately: the approval-engine and audit-core replies, and the token Secret.