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
|
|
@ -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"]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue