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:
tegwick 2026-10-31 19:01:44 +01:00
parent 74599a583d
commit 1cdc18f75f
6 changed files with 409 additions and 25 deletions

View file

@ -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"]),