net-kingdom/bounded-executor/operations.py
tegwick abed4360d4 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
2026-10-31 19:56:52 +01:00

369 lines
17 KiB
Python

"""Pilot operations and checks for the bounded executor (NK-WP-0056-T12).
The T06 pilot rotates one directory bind (svc-authelia) under one admission:
openbao.kv.write-next-version read version N, write a fresh value as N+1 with cas=N
openbao.kv.write-previous-value-as-next-version revert: write the previous value as the next version (CAS)
lldap.user.change-own-password-from-kv set the bind's LLDAP password to the current KV value,
signing in as the bind with a value it still accepts
(self-rotation, founder decision 3d0c8f86); forward and revert
eso.force-sync ask the delivery to re-project the value (ExternalSecret)
k8s.rollout-restart restart the consumer and wait for the rollout
Checks: kv-advanced, bind-signs-in, eso-synced, synthetic-journey.
Values live only in Context.data (memory) and in the requests that carry them; nothing here prints, logs or
returns a value. Every adapter is stdlib HTTP so the image needs no client libraries.
"""
from __future__ import annotations
import datetime as dt
import importlib.util
import json
import os
import pathlib
import secrets
import ssl
import subprocess
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Callable
HERE = pathlib.Path(__file__).resolve().parent
REPO = HERE.parent
_spec = importlib.util.spec_from_file_location("journey", REPO / "sso-mfa/k8s/synthetic-journey/journey.py")
journey = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(journey)
FIELD = "LDAP_BIND_PASSWORD"
class OperationError(Exception):
"""A definite failure: the operation did not take effect."""
class RolloutFailed(Exception):
"""The restart was issued but the consumer never became ready: a definite, known-bad outcome that the
executor reverts (executor.StepFailed semantics)."""
definite_failure = True
def _opener(ca_file: str | None):
context = ssl.create_default_context(cafile=ca_file) if ca_file else ssl.create_default_context()
return urllib.request.build_opener(urllib.request.ProxyHandler({}), urllib.request.HTTPSHandler(context=context))
# --------------------------------------------------------------------------------------------------
# OpenBao KV v2
# --------------------------------------------------------------------------------------------------
class OpenBao:
def __init__(self, addr: str, *, token: str | None = None, k8s_role: str | None = None,
jwt_file: str | None = None, k8s_mount: str = "kubernetes", ca_file: str | None = None,
timeout: float = 15):
self.addr = addr.rstrip("/")
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:
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
request = urllib.request.Request(self.addr + path, method=method, headers=headers,
data=json.dumps(body).encode() if body is not None else None)
try:
with self.open(request, timeout=self.timeout) as response:
raw = response.read()
except urllib.error.HTTPError as error:
error.close()
raise OperationError(f"openbao {method} {error.code}") from None
return json.loads(raw) if raw else {}
@staticmethod
def _api(kv_path: str) -> str:
mount, _, rest = kv_path.partition("/")
return f"/v1/{mount}/data/{rest}"
def read(self, kv_path: str) -> tuple[dict, int]:
body = self._call("GET", self._api(kv_path))["data"]
return body["data"], int(body["metadata"]["version"])
def write(self, kv_path: str, data: dict, cas: int) -> int:
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:
with self.open(request, timeout=self.timeout):
pass
except Exception:
pass
# --------------------------------------------------------------------------------------------------
# LLDAP
# --------------------------------------------------------------------------------------------------
class Directory:
"""Sign-in over LLDAP's HTTP API; password changes through the pinned image's lldap_set_password (OPAQUE)."""
def __init__(self, base_url: str, set_password_command: list[str], *, ca_file: str | None = None,
tool_base_url: str | None = None, timeout: float = 15):
self.base = base_url.rstrip("/")
self.tool_base = (tool_base_url or base_url).rstrip("/")
self.command = set_password_command
self.open = _opener(ca_file).open
self.timeout = timeout
def login(self, user: str, password: str) -> str | None:
request = urllib.request.Request(self.base + "/auth/simple/login", method="POST",
headers={"Content-Type": "application/json"},
data=json.dumps({"username": user, "password": password}).encode())
try:
with self.open(request, timeout=self.timeout) as response:
return json.load(response).get("token")
except urllib.error.HTTPError as error:
error.close()
return None
def set_own_password(self, token: str, user: str, password: str) -> None:
done = subprocess.run([*self.command, "--base-url", self.tool_base, "--token", token, "--username", user],
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LLDAP_USER_PASSWORD": password},
capture_output=True, timeout=60, check=False)
if done.returncode:
raise OperationError("lldap_set_password refused")
# --------------------------------------------------------------------------------------------------
# Kubernetes (stdlib client, in-cluster ServiceAccount)
# --------------------------------------------------------------------------------------------------
class Kube:
def __init__(self, api: str = "https://kubernetes.default.svc",
token_file: str = "/var/run/secrets/kubernetes.io/serviceaccount/token",
ca_file: str | None = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", timeout: float = 15):
self.api = api.rstrip("/")
self.token_file = token_file
self.open = _opener(ca_file).open
self.timeout = timeout
def _call(self, method: str, path: str, body: dict | None = None) -> dict:
headers = {"Authorization": "Bearer " + pathlib.Path(self.token_file).read_text().strip(),
"Accept": "application/json"}
data = None
if body is not None:
headers["Content-Type"] = "application/merge-patch+json"
data = json.dumps(body).encode()
request = urllib.request.Request(self.api + path, method=method, headers=headers, data=data)
try:
with self.open(request, timeout=self.timeout) as response:
return json.load(response)
except urllib.error.HTTPError as error:
error.close()
raise OperationError(f"kube {method} {error.code}") from None
def get(self, path: str) -> dict:
return self._call("GET", path)
def patch(self, path: str, body: dict) -> dict:
return self._call("PATCH", path, body)
def now_utc() -> dt.datetime:
return dt.datetime.now(dt.UTC).replace(microsecond=0)
class KubeDelivery:
"""eso.force-sync and its eso-synced check, through the ExternalSecret API (no Secret read)."""
def __init__(self, kube: Kube, namespace: str, external_secret: str, clock: Callable[[], dt.datetime] = now_utc):
self.kube, self.clock = kube, clock
self.path = f"/apis/external-secrets.io/v1/namespaces/{namespace}/externalsecrets/{external_secret}"
def force_sync(self) -> dt.datetime:
at = self.clock()
self.kube.patch(self.path, {"metadata": {"annotations": {"force-sync": str(int(at.timestamp()))}}})
return at
def synced(self, since: dt.datetime) -> bool:
status = self.kube.get(self.path).get("status", {})
ready = any(c.get("type") == "Ready" and c.get("status") == "True" for c in status.get("conditions", []))
refreshed = status.get("refreshTime")
return ready and bool(refreshed) and dt.datetime.fromisoformat(refreshed.replace("Z", "+00:00")) >= since
class KubeRestart:
"""k8s.rollout-restart: patch the restartedAt annotation, then wait until the rollout is complete."""
def __init__(self, kube: Kube, namespace: str, deployment: str, timeout: float = 300, poll: float = 3,
clock: Callable[[], dt.datetime] = now_utc, sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic):
self.kube, self.timeout, self.poll, self.clock, self.sleep, self.monotonic = kube, timeout, poll, clock, sleep, monotonic
self.path = f"/apis/apps/v1/namespaces/{namespace}/deployments/{deployment}"
def restart(self) -> None:
stamp = self.clock().isoformat().replace("+00:00", "Z")
self.kube.patch(self.path, {"spec": {"template": {"metadata": {"annotations": {
"kubectl.kubernetes.io/restartedAt": stamp}}}}})
deadline = self.monotonic() + self.timeout
while self.monotonic() < deadline:
d = self.kube.get(self.path)
spec, status = d.get("spec", {}), d.get("status", {})
want = spec.get("replicas", 1)
if (status.get("observedGeneration", 0) >= d.get("metadata", {}).get("generation", 0)
and status.get("updatedReplicas") == want and status.get("availableReplicas") == want
and status.get("replicas") == want and not status.get("unavailableReplicas")):
return
self.sleep(self.poll)
raise RolloutFailed("rollout did not complete") # known-bad: the runner reverts
# --------------------------------------------------------------------------------------------------
# The pilot registry
# --------------------------------------------------------------------------------------------------
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 {}
def kv_target(step: dict) -> str:
target = step["target"]
if not target.startswith("openbao:") or target[len("openbao:"):] != bind_kv:
raise OperationError("target outside the configured bind")
return bind_kv
def generate(step, ctx):
path = kv_target(step)
data, version = bao.read(path)
if set(data) != {FIELD}:
raise OperationError("kv shape")
fresh = secrets.token_urlsafe(32)
ctx.data.update(previous=data[FIELD], fresh=fresh)
ctx.data["expect"] = (bao.write(path, {FIELD: fresh}, cas=version), "fresh")
def restore_kv(step, ctx):
path = kv_target(step)
_, version = bao.read(path)
ctx.data["expect"] = (bao.write(path, {FIELD: ctx.data["previous"]}, cas=version), "previous")
def change_own_password(step, ctx):
if step["target"] != "lldap:" + bind_user:
raise OperationError("target outside the configured bind")
want = bao.read(bind_kv)[0][FIELD]
if directory.login(bind_user, want):
return # already in effect
for known in (ctx.data.get("previous"), ctx.data.get("fresh")):
if known and known != want:
token = directory.login(bind_user, known)
if token:
directory.set_own_password(token, bind_user, want)
if not directory.login(bind_user, want):
raise OperationError("password change not effective")
return
raise OperationError("no known value signs in as the bind")
def force_sync(step, ctx):
ctx.data["delivered_at"] = delivery.force_sync()
def rollout_restart(step, ctx):
restart.restart()
def check_kv_advanced(check, ctx):
version, which = ctx.data["expect"]
data, current = bao.read(bind_kv)
return current == version and data.get(FIELD) == ctx.data[which]
def check_bind_signs_in(check, ctx):
return directory.login(bind_user, bao.read(bind_kv)[0][FIELD]) is not None
def check_eso_synced(check, ctx):
deadline = time.monotonic() + check["timeout_seconds"] * 0.8
while True:
if delivery.synced(ctx.data["delivered_at"]):
return True
if time.monotonic() > deadline:
return False
time.sleep(2)
def check_journey(check, ctx):
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"
operations = {
"openbao.kv.write-next-version": generate,
"openbao.kv.write-previous-value-as-next-version": restore_kv,
"lldap.user.change-own-password-from-kv": change_own_password,
"eso.force-sync": force_sync,
"k8s.rollout-restart": rollout_restart,
}
checks = {"kv-advanced": check_kv_advanced, "bind-signs-in": check_bind_signs_in,
"eso-synced": check_eso_synced, "synthetic-journey": check_journey}
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-")
try:
with os.fdopen(fd, "w") as out:
json.dump(config, out)
return journey.load_config(path)
finally:
os.unlink(path)