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 <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
74599a583d
commit
1cdc18f75f
6 changed files with 409 additions and 25 deletions
|
|
@ -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)
|
trust_satisfied[...] trust states the operator attests for this run (recorded)
|
||||||
approval_engine{url, token_file, ca_file?}
|
approval_engine{url, token_file, ca_file?}
|
||||||
audit_core{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"}
|
lldap{url, ca_file?, set_password: "/app/lldap_set_password"}
|
||||||
kubernetes{namespace, external_secret, deployment}
|
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,
|
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.
|
||||||
|
|
@ -43,15 +43,26 @@ C = _load("clients")
|
||||||
EXIT = {"completed": 0, "reverted": 3, "refused": 4}
|
EXIT = {"completed": 0, "reverted": 3, "refused": 4}
|
||||||
|
|
||||||
|
|
||||||
def build(cfg: dict):
|
def build(cfg: dict, logins: list):
|
||||||
bao = OPS.OpenBao(cfg["openbao"]["addr"], k8s_role=cfg["openbao"]["k8s_role"],
|
"""`logins` collects every OpenBao client as soon as it exists, so the caller revokes them all, even when a
|
||||||
jwt_file=cfg["openbao"]["jwt_file"], ca_file=cfg["openbao"].get("ca_file"))
|
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")],
|
directory = OPS.Directory(cfg["lldap"]["url"], [cfg["lldap"].get("set_password", "/app/lldap_set_password")],
|
||||||
ca_file=cfg["lldap"].get("ca_file"))
|
ca_file=cfg["lldap"].get("ca_file"))
|
||||||
kube = OPS.Kube()
|
kube = OPS.Kube()
|
||||||
k = cfg["kubernetes"]
|
k = cfg["kubernetes"]
|
||||||
operations, checks = OPS.pilot_registry(
|
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"],
|
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"])
|
bind_user=cfg["bind"]["user"], journey_kv=cfg["journey"]["kv"], journey_config=cfg["journey"]["config"])
|
||||||
trusted = set(cfg.get("trust_satisfied", []))
|
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"],
|
config = X.Config(executor_identity=cfg["executor_identity"], runtime_principal=cfg["runtime_principal"],
|
||||||
tenant=cfg["tenant"], environment=cfg["environment"],
|
tenant=cfg["tenant"], environment=cfg["environment"],
|
||||||
approver_subjects=dict(cfg["approver_subjects"]))
|
approver_subjects=dict(cfg["approver_subjects"]))
|
||||||
return config, deps, bao
|
return config, deps
|
||||||
|
|
||||||
|
|
||||||
def exit_code(result: dict) -> int:
|
def exit_code(result: dict) -> int:
|
||||||
|
|
@ -86,15 +97,15 @@ def main(argv=None) -> int:
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
cfg = json.loads(pathlib.Path(args.config).read_text())
|
cfg = json.loads(pathlib.Path(args.config).read_text())
|
||||||
admission = X.bea.load(pathlib.Path(args.admission))
|
admission = X.bea.load(pathlib.Path(args.admission))
|
||||||
bao = None
|
logins: list = []
|
||||||
try:
|
try:
|
||||||
config, deps, bao = build(cfg)
|
config, deps = build(cfg, logins)
|
||||||
result = X.Run(admission, args.approval_id, config, deps).execute()
|
result = X.Run(admission, args.approval_id, config, deps).execute()
|
||||||
except Exception as error: # setup failed before preflight: nothing was consumed
|
except Exception as error: # setup failed before preflight: nothing was consumed
|
||||||
result = {"outcome": "refused", "reason": "setup:" + type(error).__name__, "run_id": None, "effects": False}
|
result = {"outcome": "refused", "reason": "setup:" + type(error).__name__, "run_id": None, "effects": False}
|
||||||
finally:
|
finally:
|
||||||
if bao is not None:
|
for client in logins:
|
||||||
bao.revoke_self()
|
client.revoke_self()
|
||||||
text = json.dumps(result, indent=2, sort_keys=True)
|
text = json.dumps(result, indent=2, sort_keys=True)
|
||||||
if args.result:
|
if args.result:
|
||||||
pathlib.Path(args.result).write_text(text + "\n")
|
pathlib.Path(args.result).write_text(text + "\n")
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,29 @@ class OpenBao:
|
||||||
self.open = _opener(ca_file).open
|
self.open = _opener(ca_file).open
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.token = token
|
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:
|
if self.token is None:
|
||||||
jwt = pathlib.Path(jwt_file).read_text().strip()
|
self._login()
|
||||||
auth = self._call("POST", f"/v1/auth/{k8s_mount}/login", {"role": k8s_role, "jwt": jwt}, auth=False)
|
|
||||||
self.token = auth["auth"]["client_token"]
|
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:
|
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"}
|
headers = {"Content-Type": "application/json"}
|
||||||
if auth:
|
if auth:
|
||||||
headers["X-Vault-Token"] = self.token
|
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"])
|
return int(self._call("POST", self._api(kv_path), {"options": {"cas": cas}, "data": data})["data"]["version"])
|
||||||
|
|
||||||
def revoke_self(self) -> None:
|
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:
|
try:
|
||||||
self._call("POST", "/v1/auth/token/revoke-self", {})
|
with self.open(request, timeout=self.timeout):
|
||||||
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -232,9 +256,10 @@ class KubeRestart:
|
||||||
# --------------------------------------------------------------------------------------------------
|
# --------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def pilot_registry(*, bao: OpenBao, directory: Directory, delivery, restart, bind_kv: str, bind_user: str,
|
def pilot_registry(*, bao: OpenBao, journey_bao: OpenBao, directory: Directory, delivery, restart, bind_kv: str,
|
||||||
journey_kv: str, journey_config, journey_state: dict | None = None):
|
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);
|
"""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
|
`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)."""
|
across runs (privacyIDEA accepts each 30 s step once)."""
|
||||||
shared = journey_state if journey_state is not None else {}
|
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)
|
time.sleep(2)
|
||||||
|
|
||||||
def check_journey(check, ctx):
|
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)
|
cfg = _load_config(journey_config() if callable(journey_config) else journey_config)
|
||||||
last = shared.get("journey_step")
|
last = shared.get("journey_step")
|
||||||
receipt = journey.run_journey(cfg, creds["PASSWORD"], journey.decode_seed(creds["TOTP_SEED"]),
|
receipt = journey.run_journey(cfg, creds["PASSWORD"], journey.decode_seed(creds["TOTP_SEED"]),
|
||||||
|
|
|
||||||
|
|
@ -89,9 +89,11 @@ class OpenBaoDev:
|
||||||
"TOTP_SEED": base64.b32encode(seed).decode()}, cas=0)
|
"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/bind-executor-write", {"policy": POLICY_FILE.read_text()})
|
||||||
self.call("PUT", "/v1/sys/policies/acl/journey-read", {"policy": JOURNEY_POLICY})
|
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"],
|
tokens = []
|
||||||
"no_default_policy": True, "ttl": "15m"})
|
for policy in ("bind-executor-write", "journey-read"): # one token per lane, as the live roles
|
||||||
return token["auth"]["client_token"]
|
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):
|
def remove(self):
|
||||||
stack_mod.docker("rm", "--force", self.name, check=False)
|
stack_mod.docker("rm", "--force", self.name, check=False)
|
||||||
|
|
@ -210,13 +212,14 @@ def main(argv=None):
|
||||||
try:
|
try:
|
||||||
stack.up()
|
stack.up()
|
||||||
bao = OpenBaoDev(stack.network, stack.tag)
|
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)
|
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())
|
directory = OPS.Directory(stack.lldap, lldap_command())
|
||||||
delivery = Delivery(stack, bao.admin)
|
delivery = Delivery(stack, bao.admin)
|
||||||
journey_state = {}
|
journey_state = {}
|
||||||
operations, checks = OPS.pilot_registry(
|
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,
|
bind_kv=BIND_KV, bind_user="svc-authelia", journey_kv=JOURNEY_KV,
|
||||||
journey_config=stack.journey_config, journey_state=journey_state)
|
journey_config=stack.journey_config, journey_state=journey_state)
|
||||||
carrier, events, alerts = MemoryCarrier(), [], []
|
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"))
|
and all(s[2] == "done" for s in revert["steps"] if s[1] == "revert"))
|
||||||
report["runs"].append(revert)
|
report["runs"].append(revert)
|
||||||
report["alerts"] = alerts
|
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)
|
text = json.dumps(report)
|
||||||
report["no_values_in_report"] = not any(v in text for v in secrets_in_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"]
|
ok = rotation["met"] and revert["met"] and report["no_values_in_report"]
|
||||||
|
|
|
||||||
159
bounded-executor/tests/test_openbao_lanes.py
Normal file
159
bounded-executor/tests/test_openbao_lanes.py
Normal file
|
|
@ -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()
|
||||||
24
docs/evidence/2026-10-31-nk56-t10-openbao-lanes/README.md
Normal file
24
docs/evidence/2026-10-31-nk56-t10-openbao-lanes/README.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue