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 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5502@bnt-lap001 Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
This commit is contained in:
parent
ef6284c371
commit
abed4360d4
8 changed files with 327 additions and 18 deletions
|
|
@ -44,6 +44,10 @@ without guessing.
|
||||||
`synthetic_pilot.py` runs a rotation and a deliberate revert on the T08 stack plus OpenBao:
|
`synthetic_pilot.py` runs a rotation and a deliberate revert on the T08 stack plus OpenBao:
|
||||||
`uv run bounded-executor/synthetic_pilot.py`.
|
`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`
|
`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
|
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.
|
custody. `Containerfile` builds the image, which `.forgejo/workflows/bounded-executor-image.yaml` publishes by digest.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -82,6 +89,30 @@ def build(cfg: dict, logins: list):
|
||||||
return config, deps
|
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:
|
def exit_code(result: dict) -> int:
|
||||||
if result.get("reason") in ("receipt_not_in_custody", "audit_unavailable") and result["outcome"] != "refused":
|
if result.get("reason") in ("receipt_not_in_custody", "audit_unavailable") and result["outcome"] != "refused":
|
||||||
return 6
|
return 6
|
||||||
|
|
@ -90,12 +121,21 @@ def exit_code(result: dict) -> int:
|
||||||
|
|
||||||
def main(argv=None) -> int:
|
def main(argv=None) -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
||||||
parser.add_argument("--admission", required=True)
|
parser.add_argument("--journey-only", action="store_true", help="read-only journey run, no admission (T10)")
|
||||||
parser.add_argument("--approval-id", required=True)
|
parser.add_argument("--admission")
|
||||||
|
parser.add_argument("--approval-id")
|
||||||
parser.add_argument("--config", required=True)
|
parser.add_argument("--config", required=True)
|
||||||
parser.add_argument("--result", help="also write the result here")
|
parser.add_argument("--result", help="also write the result here")
|
||||||
args = parser.parse_args(argv)
|
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())
|
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))
|
admission = X.bea.load(pathlib.Path(args.admission))
|
||||||
logins: list = []
|
logins: list = []
|
||||||
try:
|
try:
|
||||||
|
|
@ -106,12 +146,16 @@ def main(argv=None) -> int:
|
||||||
finally:
|
finally:
|
||||||
for client in logins:
|
for client in logins:
|
||||||
client.revoke_self()
|
client.revoke_self()
|
||||||
text = json.dumps(result, indent=2, sort_keys=True)
|
_emit(result, args.result)
|
||||||
if args.result:
|
|
||||||
pathlib.Path(args.result).write_text(text + "\n")
|
|
||||||
print(text)
|
|
||||||
return exit_code(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__":
|
if __name__ == "__main__":
|
||||||
sys.exit(main())
|
sys.exit(main())
|
||||||
|
|
|
||||||
|
|
@ -324,13 +324,7 @@ def pilot_registry(*, bao: OpenBao, journey_bao: OpenBao, directory: Directory,
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
def check_journey(check, ctx):
|
def check_journey(check, ctx):
|
||||||
creds, _ = journey_bao.read(journey_kv)
|
receipt = journey_from_kv(journey_bao, journey_kv, journey_config, shared)
|
||||||
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"]
|
|
||||||
ctx.data.setdefault("journeys", []).append({k: receipt.get(k) for k in ("result", "failed_step", "reason")})
|
ctx.data.setdefault("journeys", []).append({k: receipt.get(k) for k in ("result", "failed_step", "reason")})
|
||||||
return receipt["result"] == "pass"
|
return receipt["result"] == "pass"
|
||||||
|
|
||||||
|
|
@ -346,6 +340,24 @@ def pilot_registry(*, bao: OpenBao, journey_bao: OpenBao, directory: Directory,
|
||||||
return operations, checks
|
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:
|
def _load_config(config: dict) -> dict:
|
||||||
import tempfile
|
import tempfile
|
||||||
fd, path = tempfile.mkstemp(suffix=".json", prefix="journey-")
|
fd, path = tempfile.mkstemp(suffix=".json", prefix="journey-")
|
||||||
|
|
|
||||||
105
bounded-executor/synthetic_journey_only.py
Normal file
105
bounded-executor/synthetic_journey_only.py
Normal file
|
|
@ -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())
|
||||||
|
|
@ -60,7 +60,7 @@ def serve(bao: Bao):
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
bao.reads.append((self.path, self.headers.get("X-Vault-Token")))
|
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):
|
def log_message(self, *a):
|
||||||
return
|
return
|
||||||
|
|
@ -108,6 +108,60 @@ class CliLanes(Base):
|
||||||
self.assertEqual(self.bao.logins, [])
|
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):
|
class Renewal(Base):
|
||||||
def test_logs_in_again_before_the_lease_ends_and_revokes_the_old_token(self):
|
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)
|
self.bao.lease = 1 # renew_at = max(1 - margin, 0.5 s)
|
||||||
|
|
|
||||||
24
docs/evidence/2026-10-31-nk56-t10-journey-only/README.md
Normal file
24
docs/evidence/2026-10-31-nk56-t10-journey-only/README.md
Normal file
|
|
@ -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`.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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
|
- `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.
|
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:
|
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
|
- the image for this commit, and its pin;
|
||||||
role, no Kubernetes mutation, and a value-free receipt. The CLI today runs only admissions.
|
- the first `make journey` against live (founder starts the Job; no person in the journey itself).
|
||||||
- Its unattended live run.
|
|
||||||
- The fail-closed proof against a broken bind on the T08 synthetic directory.
|
|
||||||
|
|
||||||
For T06, separately: the approval-engine and audit-core replies, and the token Secret.
|
For T06, separately: the approval-engine and audit-core replies, and the token Secret.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue