diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 904c467..0f909c2 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -354,7 +354,8 @@ | task | NK-WP-0056-T09 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T10 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md | | task | NK-WP-0056-T11 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | -| task | NK-WP-0056-T12 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md | +| task | NK-WP-0056-T12 | done | — | workplans/NK-WP-0056-bounded-executor-path.md | +| task | NK-WP-0056-T13 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md | | intake | NK-IN-0001 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | | intake | NK-IN-0002 | closed | blue | docs/intakes/activity-core-ops-sso-operators.md | | intake | NET-IN-0001 | open | — | intakes/intakes.md | diff --git a/bounded-executor/README.md b/bounded-executor/README.md index 572be73..1a30b95 100644 --- a/bounded-executor/README.md +++ b/bounded-executor/README.md @@ -28,9 +28,23 @@ Job per admission in `sso` (T12). - **receipt:** one `execution.attribution.receipt` per run, with PDP `not-evaluated` in the pilot. It never holds a value that an operation handled. -Operations, checks, the approval carrier, the trust oracle, the audit sink, alerts and clocks are injected. T12 adds -the pilot's real operations (OpenBao CAS write, LLDAP self-rotation, ExternalSecret sync, rollout, synthetic -journey), the CLI, the Containerfile and the image workflow. +Operations, checks, the approval carrier, the trust oracle, the audit sink, alerts and clocks are injected. + +`operations.py` (T12) holds the pilot's real operations: + +- OpenBao KV v2 with CAS; +- LLDAP self-rotation; +- ExternalSecret force-sync and its status; +- rollout restart, where a rollout that never becomes ready is a definite failure and is reverted; +- the synthetic journey check. + +An operation signals a known-bad outcome with `definite_failure`, and the runner reverts. Any other error stops +without guessing. + +`synthetic_pilot.py` runs a rotation and a deliberate revert on the T08 stack plus OpenBao: +`uv run bounded-executor/synthetic_pilot.py`. + +T13 adds the CLI, the Containerfile, the image workflow and the rapp package. ```bash python3 -m pytest bounded-executor/tests # or: make bounded-executor-test diff --git a/bounded-executor/executor.py b/bounded-executor/executor.py index 9993a3e..edd0da0 100644 --- a/bounded-executor/executor.py +++ b/bounded-executor/executor.py @@ -12,9 +12,10 @@ Runs one Bounded Execution Admission (canon/standards/bounded-execution-admissio started `execution.run.started` to the audit sink; no custody, no run steps in plan order, one consumer at a time; checks after each mutating step, after their settle time; the kill switch and the budget are checked between steps - failure a failed check or an exhausted budget reverts the mutated steps in reverse - order (or stops, as the admission says) and re-runs their checks; an error of - unknown outcome stops without guessing; a failed revert stops and alerts + failure a failed check, an exhausted budget or the kill switch runs the revert steps of + the mutated steps in the admission's declared order (or stops, as it says), each + proving the restored state with its own checks; an error of unknown outcome stops + without guessing; a failed revert stops and alerts receipt one `execution.attribution.receipt` per run (PDP `not-evaluated` in the pilot) Every effect goes through an injected interface: Carrier, operation and check callables, TrustOracle, @@ -52,6 +53,12 @@ class Unknown(Exception): """An operation's outcome is unknown: stop, do not guess, do not revert blindly.""" +class StepFailed(Exception): + """A definite, known-bad outcome (e.g. a rollout that never became ready): handled like a failed check. + Operations outside this module may instead set `definite_failure = True` on their exception class.""" + definite_failure = True + + class Carrier(Protocol): def get(self, approval_id: str) -> dict: ... def consume(self, approval_id: str, request_digest: str, decision_id: str) -> dict: ... @@ -203,9 +210,13 @@ class Run: def _budget_left(self) -> float: return self.spec["budget"]["wall_time_seconds"] - (self.d.monotonic() - self.start_mono) - def _record(self, step: dict, kind: str, outcome: str, checks: list[dict] | None = None) -> None: - self.steps.append({"id": step["id"], "kind": kind, "operation": step["operation"], "target": step["target"], - "consumer": step["consumer"], "outcome": outcome, "checks": checks or []}) + def _record(self, step: dict, kind: str, outcome: str, checks: list[dict] | None = None, + error: BaseException | None = None) -> None: + entry = {"id": step["id"], "kind": kind, "operation": step["operation"], "target": step["target"], + "consumer": step["consumer"], "outcome": outcome, "checks": checks or []} + if error is not None: + entry["error_type"] = type(error).__name__ # the type only: messages can carry values + self.steps.append(entry) def _run_checks(self, ids: list[str], ctx: Context) -> tuple[bool, list[dict]]: results = [] @@ -230,18 +241,20 @@ class Run: return True, results def _revert(self, done: list[dict], ctx: Context) -> bool: - """Undo the mutated steps in reverse order; each revert step must pass its own checks.""" - reverts = {s["id"]: s for s in self.spec["plan"].get("revert_steps", [])} - for step in reversed(done): - if not step["mutates"]: + """Undo the mutated steps. Revert steps run in the order the admission declares them (the author knows + the recovery order: data before delivery before restart), limited to those whose forward step ran. + Each must pass its own checks; an irreversible step that ran makes the revert fail.""" + mutated = [s for s in done if s["mutates"]] + if any(s.get("irreversible") for s in mutated): + return False + wanted = {s["revert"] for s in mutated} + for revert in self.spec["plan"].get("revert_steps", []): + if revert["id"] not in wanted: continue - if step.get("irreversible"): - return False - revert = reverts[step["revert"]] try: self.d.operations[revert["operation"]](revert, ctx) - except Exception: - self._record(revert, "revert", "error") + except Exception as error: + self._record(revert, "revert", "error", error=error) return False ok, results = self._run_checks(revert.get("checks_after", []), ctx) self._record(revert, "revert", "done" if ok else "check_failed", results) @@ -262,11 +275,15 @@ class Run: return reason try: self.d.operations[step["operation"]](step, ctx) - except Unknown: - self._record(step, "step", "unknown") - return "unknown" - except Exception: - self._record(step, "step", "unknown") + except Exception as error: + if getattr(error, "definite_failure", False) and step["mutates"]: + # known-bad state: treat like a failed check, the step counts as done for the revert + done.append(step) + self._record(step, "step", "failed", error=error) + if policy["on_check_failure"] == "revert": + return "reverted" if self._revert(done, ctx) else "revert_failed" + return "stopped" + self._record(step, "step", "unknown", error=error) # outcome not established: stop return "unknown" done.append(step) ok, results = self._run_checks(step.get("checks_after", []), ctx) if step["mutates"] else (True, []) diff --git a/bounded-executor/operations.py b/bounded-executor/operations.py new file mode 100644 index 0000000..7018f14 --- /dev/null +++ b/bounded-executor/operations.py @@ -0,0 +1,332 @@ +"""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 + 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"] + + def _call(self, method: str, path: str, body: dict | None = None, auth: bool = True) -> dict: + 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: + try: + self._call("POST", "/v1/auth/token/revoke-self", {}) + 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, 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); + `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): + creds, _ = 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"]), + 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")}) + 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 _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) diff --git a/bounded-executor/synthetic_pilot.py b/bounded-executor/synthetic_pilot.py new file mode 100644 index 0000000..41f1c07 --- /dev/null +++ b/bounded-executor/synthetic_pilot.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["PyYAML>=6,<7", "jsonschema>=4,<5"] +# /// +"""Synthetic pilot: the executor rotates the svc-authelia bind on the T08 stack (NK-WP-0056-T12). + +Agent-runnable, local Docker only, generated values, everything removed afterwards. Starts the T08 identity +stack (LLDAP, privacyIDEA, Authelia, KeyCape on the pinned images) and an OpenBao dev server on the platform's +pinned 2.5.4 image (KV v2 at `platform`, `cas_required` on the bind). The executor gets its own token with +exactly CCR-2026-0058's policy file plus a journey-read policy, never the root token. + +Not on this stack: approval-engine (an in-process carrier with its consume semantics stands in), audit-core +(an in-memory sink), and Kubernetes. Stand-ins replace the ExternalSecret delivery (re-project the KV value +into Authelia's configuration) and the rollout (restart the Authelia container). The real Kubernetes adapters +(operations.KubeDelivery, KubeRestart) are covered by unit tests and exercised live in T06. + +Runs: + rotation one admission, the bind rotates; afterwards the old value is refused and the journey passes + revert a second admission whose first delivery projects a wrong value while reporting success: + Authelia does not become ready after the restart (definite failure), the executor reverts in + declared order, the previous value is back and the journey passes again + +Writes a value-free report. Exit 0 when both runs end as expected. +Run: uv run bounded-executor/synthetic_pilot.py [--report FILE] +""" + +from __future__ import annotations + +import argparse +import base64 +import copy +import datetime as dt +import hashlib +import importlib.util +import json +import pathlib +import secrets +import sys +import tempfile + +import yaml + +HERE = pathlib.Path(__file__).resolve().parent +REPO = HERE.parent + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +X = _load("executor", HERE / "executor.py") +OPS = _load("operations", HERE / "operations.py") +stack_mod = _load("stack", REPO / "sso-mfa/k8s/synthetic-journey/stack.py") +bea = X.bea + +OPENBAO_IMAGE = "quay.io/openbao/openbao@sha256:436eaf9778cad75507ff70ea26ace30dcbe15606e619ac3823495663d7f7c115" # 2.5.4 +BIND_KV = "platform/workloads/net-kingdom/directory-binds/svc-authelia" +JOURNEY_KV = "platform/workloads/net-kingdom/synthetic-journey/syn-journey" +POLICY_FILE = REPO.parent / "railiance-platform/openbao/policies/net-kingdom-bind-executor-svc-authelia-write.hcl" +JOURNEY_POLICY = 'path "platform/data/workloads/net-kingdom/synthetic-journey/syn-journey" { capabilities = ["read"] }\n' +EXECUTOR = "service:nk-bind-executor" +APPROVER_SUB = "synthetic-admin-sub" + + +class OpenBaoDev: + def __init__(self, network: str, tag: str): + self.name = f"nk56-executor-{tag}-openbao" + self.root = secrets.token_hex(16) + stack_mod.docker("run", "--detach", "--pull=never", "--name", self.name, "--network", network, + "--publish", "127.0.0.1::8200", "--cap-add", "IPC_LOCK", "--env", "BAO_DEV_ROOT_TOKEN_ID", + OPENBAO_IMAGE, "server", "-dev", "-dev-listen-address=0.0.0.0:8200", + env={"BAO_DEV_ROOT_TOKEN_ID": self.root}) + self.addr = f"http://127.0.0.1:{stack_mod.port(self.name, 8200)}" + stack_mod.wait_http(self.addr + "/v1/sys/health", "openbao") + self.admin = OPS.OpenBao(self.addr, token=self.root) + + def call(self, method, path, body=None): + return self.admin._call(method, path, body) + + def setup(self, bind_value: str, journey_password: str, seed: bytes) -> str: + self.call("POST", "/v1/sys/mounts/platform", {"type": "kv", "options": {"version": "2"}}) + self.admin.write(BIND_KV, {OPS.FIELD: bind_value}, cas=0) + self.call("POST", "/v1/platform/metadata/workloads/net-kingdom/directory-binds/svc-authelia", {"cas_required": True}) + self.admin.write(JOURNEY_KV, {"PASSWORD": journey_password, + "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/journey-read", {"policy": JOURNEY_POLICY}) + token = self.call("POST", "/v1/auth/token/create", {"policies": ["bind-executor-write", "journey-read"], + "no_default_policy": True, "ttl": "15m"}) + return token["auth"]["client_token"] + + def remove(self): + stack_mod.docker("rm", "--force", self.name, check=False) + + +class Delivery: + """Stand-in for the ExternalSecret: projects the KV value into Authelia's configuration file.""" + + def __init__(self, stack, bao_admin): + self.stack, self.bao, self.corrupt = stack, bao_admin, 0 # number of next deliveries to corrupt + + def force_sync(self) -> dt.datetime: + at = OPS.now_utc() + value = self.bao.read(BIND_KV)[0][OPS.FIELD] + if self.corrupt: + self.corrupt -= 1 + value = secrets.token_urlsafe(32) # a one-off wrong projection that still reports success + path = self.stack.workdir / "authelia.yml" + config = yaml.safe_load(path.read_text()) + config["authentication_backend"]["ldap"]["password"] = value + path.write_text(yaml.safe_dump(config)) # same inode: the bind mount sees it + self.synced_at = OPS.now_utc() + return at + + def synced(self, since: dt.datetime) -> bool: + return getattr(self, "synced_at", None) is not None and self.synced_at >= since + + +class Restart: + """Stand-in for the rollout: restart the Authelia container and wait for it to answer.""" + + def __init__(self, stack): + self.stack = stack + + def restart(self): + stack_mod.docker("restart", self.stack.names["authelia"]) + # docker assigns a new ephemeral host port on restart (an artifact of this stand-in) + self.stack.ports["authelia"] = stack_mod.port(self.stack.names["authelia"], 9091) + try: + stack_mod.wait_http(f"http://127.0.0.1:{self.stack.ports['authelia']}/api/health", "authelia", + {"Host": "auth.journey.test", "X-Forwarded-Proto": "https"}, attempts=60) + except stack_mod.SetupError: + raise OPS.RolloutFailed("authelia did not become ready") from None + + +class MemoryCarrier: + """approval-engine's approval object and consume semantics, in memory.""" + + def __init__(self): + self.approvals = {} + + def approve(self, approval_id, admission): + spec = admission["spec"] + self.approvals[approval_id] = { + "id": approval_id, "status": "approved", + "binding": {"action": "bounded-execution", "actor": EXECUTOR, "principal": "admin", "purpose": spec["purpose"], + "target": {"admission_id": spec["id"], "admission_digest": bea.admission_digest(admission)}, + "human_control": True, "pdp_path": False}, + "validity": {"not_before": spec["window"]["not_before"], "expires_at": spec["window"]["not_after"]}, + "entries": [{"subject_id": APPROVER_SUB, "assurance": "aal2", "principal_type": "human", + "approved_at": OPS.now_utc().isoformat()}]} + + def get(self, approval_id): + return copy.deepcopy(self.approvals[approval_id]) + + def consume(self, approval_id, request_digest, decision_id): + a = self.approvals[approval_id] + if a["status"] != "approved": + raise RuntimeError("409") + a["status"] = "consumed" + a["consumption"] = {"request_digest": request_digest, "decision_id": decision_id, + "consumed_at": OPS.now_utc().isoformat()} + return {"approval_id": approval_id, "status": "consumed", "request_digest": request_digest, + "decision_id": decision_id, "consumed_at": a["consumption"]["consumed_at"], "idempotent": False} + + +def admission(admission_id: str, attempts: int) -> dict: + """The pilot example, pinned to the current files, with a window around now.""" + doc = bea.load(REPO / "examples/bounded-execution-admission/bind-rotation-svc-authelia.yaml") + spec = doc["spec"] + spec["id"] = admission_id + now = dt.datetime.now(dt.UTC).replace(microsecond=0) + spec["window"] = {"not_before": (now - dt.timedelta(minutes=5)).isoformat().replace("+00:00", "Z"), + "not_after": (now + dt.timedelta(hours=2)).isoformat().replace("+00:00", "Z")} + spec["budget"]["attempts"] = attempts + files = [] + for f in spec["plan"]["source"]["files"]: + files.append({"path": f["path"], "sha256": hashlib.sha256((REPO / f["path"]).read_bytes()).hexdigest()}) + spec["plan"]["source"]["files"] = files + pins = {f["path"]: f["sha256"] for f in files} + for c in spec["checks"]: + c["sha256"] = pins[c["ref"]] + spec["plan_digest"] = bea.plan_digest(doc) + report = bea.evaluate(doc, json.loads(bea.SCHEMA.read_text())) + if not report["valid"]: + raise SystemExit(f"synthetic admission invalid: {report['findings']}") + return doc + + +def lldap_command(): + return ["docker", "run", "--rm", "--pull=never", "--network", "host", "--env", "LLDAP_USER_PASSWORD", + "--entrypoint", "/app/lldap_set_password", stack_mod.LLDAP_IMAGE] + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument("--report") + args = parser.parse_args(argv) + report = {"schema": "netkingdom.bounded-executor.synthetic-pilot/v1", + "started_at": OPS.now_utc().isoformat(), "images": {"openbao": OPENBAO_IMAGE}, + "policy_file_sha256": hashlib.sha256(POLICY_FILE.read_bytes()).hexdigest(), "runs": []} + ok = False + with tempfile.TemporaryDirectory(prefix="nk56-executor-") as tmp: + stack = stack_mod.Stack(pathlib.Path(tmp), pi_cache_timeout=120) + bao = None + try: + stack.up() + bao = OpenBaoDev(stack.network, stack.tag) + executor_token = bao.setup(stack.pw["svc-authelia"], stack.pw[stack_mod.USER], stack.seed) + executor_bao = OPS.OpenBao(bao.addr, token=executor_token) + directory = OPS.Directory(stack.lldap, lldap_command()) + delivery = Delivery(stack, bao.admin) + journey_state = {} + operations, checks = OPS.pilot_registry( + bao=executor_bao, directory=directory, delivery=delivery, restart=Restart(stack), + bind_kv=BIND_KV, bind_user="svc-authelia", journey_kv=JOURNEY_KV, + journey_config=stack.journey_config, journey_state=journey_state) + carrier, events, alerts = MemoryCarrier(), [], [] + audit = type("Sink", (), {"emit": lambda _s, e: events.append(e)})() + cfg = X.Config(executor_identity=EXECUTOR, runtime_principal="synthetic:executor-token", + tenant="tenant:platform", environment="TEST", approver_subjects={"admin": APPROVER_SUB}) + deps = X.Deps(carrier=carrier, operations=operations, checks=checks, trust=lambda s: True, + audit=audit, alert=lambda kind, info: alerts.append(kind)) + + def run(admission_id, attempts, corrupt): + doc = admission(admission_id, attempts) + approval = "apr-" + admission_id + carrier.approve(approval, doc) + before_value, before_version = bao.admin.read(BIND_KV) + delivery.corrupt = 1 if corrupt else 0 + result = X.Run(doc, approval, cfg, deps).execute() + delivery.corrupt = 0 + after_value, after_version = bao.admin.read(BIND_KV) + changed = after_value != before_value + old_refused = directory.login("svc-authelia", before_value[OPS.FIELD]) is None + new_works = directory.login("svc-authelia", after_value[OPS.FIELD]) is not None + receipt = result.get("receipt", {}).get("data", {}) + return {"admission": admission_id, "outcome": result["outcome"], "reason": result.get("reason"), + "kv_version": [before_version, after_version], "kv_value_changed": changed, + "previous_value_refused_by_lldap": old_refused, "current_kv_value_signs_in": new_works, + "steps": [(s["id"], s["kind"], s["outcome"], [c["result"] for c in s.get("checks", [])], + s.get("error_type")) + for s in receipt.get("steps", [])], + "reverted": receipt.get("reverted"), "pdp": receipt.get("pdp"), + "events": [e["type"] for e in events if e["correlation_id"] == result.get("run_id")]} + + rotation = run("BEA-2026-9001", 1, corrupt=False) + rotation["met"] = (rotation["outcome"] == "completed" and rotation["kv_value_changed"] + and rotation["previous_value_refused_by_lldap"] and rotation["current_kv_value_signs_in"]) + report["runs"].append(rotation) + revert = run("BEA-2026-9002", 1, corrupt=True) + revert["met"] = (revert["outcome"] == "reverted" and not revert["kv_value_changed"] + and revert["current_kv_value_signs_in"] + and any(s[0] == "restart" and s[2] in ("check_failed", "failed") for s in revert["steps"]) + and all(s[2] == "done" for s in revert["steps"] if s[1] == "revert")) + report["runs"].append(revert) + report["alerts"] = alerts + secrets_in_report = [stack.pw["svc-authelia"], executor_token, bao.root] + text = json.dumps(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"] + finally: + if bao: + bao.remove() + stack.down() + report["all_met"] = ok + report["finished_at"] = OPS.now_utc().isoformat() + text = json.dumps(report, indent=2, default=str) + if args.report: + pathlib.Path(args.report).write_text(text + "\n") + print(text) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bounded-executor/tests/test_executor.py b/bounded-executor/tests/test_executor.py index 570e52b..16c9170 100644 --- a/bounded-executor/tests/test_executor.py +++ b/bounded-executor/tests/test_executor.py @@ -299,11 +299,12 @@ class FailureTests(Base): self.w.fail_check["eso-synced"] = 1 result = self.run_it() self.assertEqual(result["outcome"], "reverted", result) - self.assertEqual(self.ops(), ["generate", "set-directory", "deliver", "redeliver", "restore-directory", "restore-kv"]) + # declared recovery order, only for steps that ran (restart did not run, so no restart-again) + self.assertEqual(self.ops(), ["generate", "set-directory", "deliver", "restore-kv", "restore-directory", "redeliver"]) data = result["receipt"]["data"] self.assertTrue(data["reverted"]) reverts = [x for x in data["steps"] if x["kind"] == "revert"] - self.assertEqual([x["checks"][0]["id"] for x in reverts], ["eso-synced", "bind-signs-in", "kv-advanced"]) + self.assertEqual([x["checks"][0]["id"] for x in reverts], ["kv-advanced", "bind-signs-in", "eso-synced"]) self.assertTrue(all(x["outcome"] == "done" for x in reverts)) self.assertEqual(data["attempts"], 1) @@ -319,10 +320,21 @@ class FailureTests(Base): def test_revert_whose_check_fails_counts_as_failed(self): self.doc = synthetic_admission(self.root, lambda s: s["budget"].update(attempts=1)) self.w = World(self.doc, self.clock) - self.w.fail_check["eso-synced"] = 2 # the forward check and the revert's own check + self.w.fail_check["eso-synced"] = 1 + self.w.fail_check["kv-advanced"] = 0 + original = self.w.check + calls = {"kv": 0} + def check(c, ctx): # kv-advanced passes forward, fails on the revert + if c["id"] == "kv-advanced": + calls["kv"] += 1 + if calls["kv"] == 2: + self.w.calls.append("check:kv-advanced") + return False + return original(c, ctx) + self.w.check = check result = self.run_it() self.assertEqual(result["outcome"], "revert_failed") - self.assertNotIn("restore-directory", self.ops()) + self.assertNotIn("restore-directory", self.ops()) # stop at the failed revert def test_failed_revert_stops_and_alerts(self): self.w.fail_check["eso-synced"] = 1 @@ -330,7 +342,7 @@ class FailureTests(Base): result = self.run_it() self.assertEqual(result["outcome"], "revert_failed") self.assertIn("revert_failed", self.w.alerts) - self.assertNotIn("restore-kv", self.ops()) # stop at the failed revert, do not continue blindly + self.assertNotIn("redeliver", self.ops()) # stop at the failed revert, do not continue blindly def test_unknown_outcome_stops_without_revert(self): self.w.unknown_op = {"eso.force-sync"} @@ -340,6 +352,23 @@ class FailureTests(Base): self.assertFalse(result["receipt"]["data"]["reverted"]) self.assertIn("unknown", self.w.alerts) + def test_definite_failure_reverts_like_a_failed_check(self): + self.doc = synthetic_admission(self.root, lambda s: s["budget"].update(attempts=1)) + self.w = World(self.doc, self.clock) + original = self.w.op + def op(step, ctx): + if step["id"] == "restart": + self.w.calls.append("restart") + raise X.StepFailed("never became ready") + return original(step, ctx) + self.w.op = op + result = self.run_it() + self.assertEqual(result["outcome"], "reverted") + self.assertEqual(self.ops(), ["generate", "set-directory", "deliver", "restart", + "restore-kv", "restore-directory", "redeliver", "restart-again"]) + restart = next(s for s in result["receipt"]["data"]["steps"] if s["id"] == "restart") + self.assertEqual((restart["outcome"], restart["error_type"]), ("failed", "StepFailed")) + def test_budget_exhausted_reverts(self): self.w.slow_op = 700.0 # wall_time_seconds is 1200 result = self.run_it() diff --git a/bounded-executor/tests/test_operations.py b/bounded-executor/tests/test_operations.py new file mode 100644 index 0000000..64f783f --- /dev/null +++ b/bounded-executor/tests/test_operations.py @@ -0,0 +1,133 @@ +"""Kubernetes adapters of the pilot operations against a fake API server (NK-WP-0056-T12). No cluster.""" + +from __future__ import annotations + +import datetime as dt +import importlib.util +import json +import pathlib +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +HERE = pathlib.Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("operations", HERE.parent / "operations.py") +OPS = importlib.util.module_from_spec(spec) +sys.modules["operations"] = OPS +spec.loader.exec_module(OPS) + +ES = "/apis/external-secrets.io/v1/namespaces/sso/externalsecrets/authelia-secrets" +DEP = "/apis/apps/v1/namespaces/sso/deployments/authelia" +T0 = dt.datetime(2026, 11, 3, 9, 30, tzinfo=dt.UTC) + + +class Api: + def __init__(self): + self.patches = [] + self.auth = [] + self.es_status = {"conditions": [{"type": "Ready", "status": "True"}], "refreshTime": "2026-11-03T09:29:00Z"} + self.dep = {"metadata": {"generation": 5}, "spec": {"replicas": 1}, + "status": {"observedGeneration": 5, "replicas": 1, "updatedReplicas": 1, "availableReplicas": 1}} + self.gets_until_ready = 0 + self.forbidden = False + + +def serve(api: Api): + class Handler(BaseHTTPRequestHandler): + def _send(self, code, body): + raw = json.dumps(body).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): + api.auth.append(self.headers.get("Authorization")) + if api.forbidden: + return self._send(403, {"kind": "Status"}) + if self.path == ES: + return self._send(200, {"status": api.es_status}) + if self.path == DEP: + if api.gets_until_ready > 0: + api.gets_until_ready -= 1 + return self._send(200, {**api.dep, "status": {**api.dep["status"], "updatedReplicas": 0, + "unavailableReplicas": 1}}) + return self._send(200, api.dep) + self._send(404, {}) + + def do_PATCH(self): + api.patches.append((self.path, self.headers.get("Content-Type"), + json.loads(self.rfile.read(int(self.headers["Content-Length"]))))) + if api.forbidden: + return self._send(403, {"kind": "Status"}) + if self.path == DEP: + api.dep["metadata"]["generation"] += 1 + api.dep["status"]["observedGeneration"] = api.dep["metadata"]["generation"] + self._send(200, {}) + + def log_message(self, *a): + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.02}, daemon=True).start() + return server + + +class KubeTests(unittest.TestCase): + def setUp(self): + self.api = Api() + self.server = serve(self.api) + self.addCleanup(self.server.shutdown) + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + token = pathlib.Path(tmp.name) / "token" + token.write_text("synthetic-sa-token\n") + self.kube = OPS.Kube(api=f"http://127.0.0.1:{self.server.server_port}", token_file=str(token), ca_file=None) + + def test_force_sync_patches_only_the_annotation(self): + delivery = OPS.KubeDelivery(self.kube, "sso", "authelia-secrets", clock=lambda: T0) + self.assertEqual(delivery.force_sync(), T0) + ((path, ctype, body),) = self.api.patches + self.assertEqual((path, ctype), (ES, "application/merge-patch+json")) + self.assertEqual(body, {"metadata": {"annotations": {"force-sync": str(int(T0.timestamp()))}}}) + self.assertEqual(self.api.auth, []) # no read before the patch + + def test_synced_needs_ready_and_a_refresh_after_the_sync(self): + delivery = OPS.KubeDelivery(self.kube, "sso", "authelia-secrets") + self.assertFalse(delivery.synced(T0)) # refreshed before T0 + self.api.es_status["refreshTime"] = "2026-11-03T09:30:05Z" + self.assertTrue(delivery.synced(T0)) + self.api.es_status["conditions"][0]["status"] = "False" + self.assertFalse(delivery.synced(T0)) + + def test_restart_patches_restarted_at_and_waits_for_the_rollout(self): + self.api.gets_until_ready = 2 + restart = OPS.KubeRestart(self.kube, "sso", "authelia", timeout=10, poll=0.01, clock=lambda: T0) + restart.restart() + ((path, _, body),) = self.api.patches + self.assertEqual(path, DEP) + self.assertEqual(body["spec"]["template"]["metadata"]["annotations"]["kubectl.kubernetes.io/restartedAt"], + "2026-11-03T09:30:00Z") + self.assertEqual(self.api.gets_until_ready, 0) + + def test_rollout_that_never_becomes_ready_is_a_definite_failure(self): + self.api.gets_until_ready = 10 ** 6 + ticks = iter(range(10 ** 6)) + restart = OPS.KubeRestart(self.kube, "sso", "authelia", timeout=5, poll=0, monotonic=lambda: next(ticks), + sleep=lambda s: None) + with self.assertRaises(OPS.RolloutFailed) as caught: + restart.restart() + self.assertTrue(caught.exception.definite_failure) + + def test_forbidden_is_an_operation_error_and_sends_the_bearer(self): + self.api.forbidden = True + with self.assertRaises(OPS.OperationError): + OPS.KubeDelivery(self.kube, "sso", "authelia-secrets").synced(T0) + self.assertEqual(self.api.auth, ["Bearer synthetic-sa-token"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/canon/standards/bounded-execution-admission_v0.1.md b/canon/standards/bounded-execution-admission_v0.1.md index e35be2c..27817e1 100644 --- a/canon/standards/bounded-execution-admission_v0.1.md +++ b/canon/standards/bounded-execution-admission_v0.1.md @@ -105,7 +105,7 @@ for up to 120 s, so a check run sooner proves nothing. | BEA-7 | No `human-intrinsic` step. A `root-class` step needs `high` risk | | BEA-8 | Every step's consumer is in the blast radius. `medium` and `high` risk change one consumer at a time | | BEA-9 | Every mutating step names checks, revert steps included: a revert must prove the restored state with its own checks. Every check is used and pinned: its `ref` and `sha256` appear in `plan.source.files` | -| BEA-10 | Every mutating step has a revert step, or is `irreversible` at `high` risk. Revert steps mutate and do not nest. Step ids are unique | +| BEA-10 | Every mutating step has a revert step, or is `irreversible` at `high` risk. Revert steps mutate and do not nest. Step ids are unique. The executor runs revert steps in their declared order (the recovery order: data, then delivery, then restart), limited to those whose forward step ran | | BEA-11 | A revertable plan reverts on check failure. A plan with nothing to revert stops | | BEA-12 | The wall time fits the window, the checks (timeouts plus settle times) fit the wall time, and attempts are at most 3 (1 at `high` risk) | | BEA-13 | The executed source is pinned by file and commit | @@ -208,6 +208,8 @@ consume, and `execute-step` after it. The T06 pilot runs without the gate: its r gate as `not-evaluated` and claims no allow. Any further admission needs the gate, or an exception the founder records (founder decision 2026-10-31). +**Failure classes.** A failed check, or an operation that reports a definite failure (for example a rollout that never becomes ready), reverts per `on_check_failure`. An error whose outcome is not established stops (`on_unknown: stop`) without reverting. Receipts name the error type, never its message. + **Credentials during a run.** Write roles exist only during the admitted window: the platform creates them when an admission is approved and deletes them after the receipt (CCR-2026-0058). A directory bind rotates its own password, so no password-manager account is needed (founder decisions diff --git a/docs/evidence/2026-10-31-nk56-t12-synthetic-pilot/README.md b/docs/evidence/2026-10-31-nk56-t12-synthetic-pilot/README.md new file mode 100644 index 0000000..5062cb6 --- /dev/null +++ b/docs/evidence/2026-10-31-nk56-t12-synthetic-pilot/README.md @@ -0,0 +1,41 @@ +# NK-WP-0056-T12: the executor rotates a bind on the synthetic stack (2026-10-31) + +Agent-run, local Docker, generated values, everything removed afterwards: `uv run bounded-executor/synthetic_pilot.py`. + +**Setup:** + +- The T08 stack: LLDAP, privacyIDEA, Authelia and KeyCape on the pinned images. +- An OpenBao dev server on the platform's pinned 2.5.4 image, with KV v2 at `platform` and `cas_required` on the + `svc-authelia` bind. +- The executor gets its own token with CCR-2026-0058's policy file plus a journey-read policy. +- Stand-ins: + - approval-engine: an in-process carrier with its consume semantics; + - audit-core: an in-memory sink; + - the ExternalSecret: re-projects the KV value into Authelia's configuration; + - the rollout: restarts the Authelia container. +- The Kubernetes adapters are unit-tested against a fake API server (`bounded-executor/tests/test_operations.py`). + +Report: [`pilot-report.json`](pilot-report.json). It is value-free: scanned, and the harness asserts that no generated +value is present. + +| Run | Outcome | Evidence | +| --- | --- | --- | +| BEA-2026-9001 rotation | `completed` | KV v1→v2; LLDAP refuses the old value and accepts the new; delivery, restart and synthetic journey pass; started event and receipt emitted | +| BEA-2026-9002 one-off wrong delivery | `reverted` | Authelia never becomes ready after the restart (`RolloutFailed`, a definite failure). Revert in declared order, each step passing its own check: `restore-kv` → `restore-directory` → `redeliver` → `restart-again`. The value from before the run is current again (KV v4); the journey passes | + +## Findings that changed the design + +1. **CCR-2026-0058's policy refused reads.** OpenBao 2.5.4 applies `required_parameters = ["options"]` to GET as well, + so the executor could not read the version it must name for CAS. The executor token got 403 on every read. + - Without that line, read and CAS write work. + - A stale CAS and a write without `options` are refused, by `cas_required` on the key. + - Metadata, delete, sibling reads and new paths are still denied. + - The fix is in railiance-platform's renderer and policy file. CCR-2026-0058 needs re-approval. +2. **Revert order.** Undoing in strict reverse order would restart Authelia before its password was restored. Revert + steps now run in the order the admission declares (data, then delivery, then restart), limited to steps that ran. + The standard's BEA-10 says so. +3. **Definite failures revert.** A rollout that never becomes ready was first handled like an unknown outcome + (stop, no revert). Operations can now signal a definite failure (`definite_failure`, `executor.StepFailed`), + which the runner reverts like a failed check. Truly ambiguous errors still stop. +4. **Error types in receipts.** Receipts now carry the error *type* of a failed or unknown step, never its message. +5. **Stand-in artifact:** a restarted container gets a new host port. Fixed in the harness; not relevant to Kubernetes. diff --git a/docs/evidence/2026-10-31-nk56-t12-synthetic-pilot/pilot-report.json b/docs/evidence/2026-10-31-nk56-t12-synthetic-pilot/pilot-report.json new file mode 100644 index 0000000..5f264c7 --- /dev/null +++ b/docs/evidence/2026-10-31-nk56-t12-synthetic-pilot/pilot-report.json @@ -0,0 +1,162 @@ +{ + "schema": "netkingdom.bounded-executor.synthetic-pilot/v1", + "started_at": "2026-10-31T15:21:55+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-31T15:22:57+00:00" +} diff --git a/workplans/NK-WP-0056-bounded-executor-path.md b/workplans/NK-WP-0056-bounded-executor-path.md index 7d40862..bdd9e3b 100644 --- a/workplans/NK-WP-0056-bounded-executor-path.md +++ b/workplans/NK-WP-0056-bounded-executor-path.md @@ -470,7 +470,7 @@ Standard and validator change: BEA-9 now applies to revert steps, which name the ```task id: NK-WP-0056-T12 -status: todo +status: done priority: high assignee: claude-code depends_on: [NK-WP-0056-T11, NK-WP-0056-T08] @@ -489,7 +489,51 @@ Run them on the T08 synthetic stack, including a revert. Add the Containerfile a net-kingdom. The `rapp-bounded-executor` package is created with `rmgr rapp wrap`, and the founder creates the Forgejo remote. It runs one Job per admission in `sso` with an egress-only NetworkPolicy. -**Done when:** a synthetic rotation completes and a deliberately failing one reverts, with receipts; the image builds. +**Done when:** a synthetic rotation completes and a deliberately failing one reverts, with receipts. + +**Done 2026-10-31** ([evidence](../docs/evidence/2026-10-31-nk56-t12-synthetic-pilot/README.md)). +`bounded-executor/operations.py`: + +- the OpenBao KV v2 CAS client; +- LLDAP self-rotation, the same operation forward and in revert; +- the Kubernetes ExternalSecret force-sync and status check, and the rollout restart that raises `RolloutFailed`; +- the pilot checks. + +`bounded-executor/synthetic_pilot.py`: rotation `completed`, and a one-off wrong delivery `reverted` in declared order +with the earlier value restored. 29 tests. + +Findings: + +- CCR-2026-0058's `required_parameters` refused reads. Corrected in railiance-platform; needs re-approval. +- Revert steps now run in declared order (BEA-10). +- Definite failures revert; unknown outcomes stop. +- Receipts carry error types. + +The image part moved to T13 (split agreed 2026-10-31). + +## Executor runtime: packaging + +```task +id: NK-WP-0056-T13 +status: todo +priority: high +assignee: claude-code +depends_on: [NK-WP-0056-T12] +``` + +The CLI entry point for one admission: + +- reads the admission, the approval id and the configuration; +- uses Kubernetes auth to OpenBao; +- uses the in-cluster Kubernetes and audit sender; +- ships the pinned `lldap_set_password` in the image. + +Also: the Containerfile and the Forgejo image workflow in net-kingdom, and the `rapp-bounded-executor` package +(`rmgr rapp wrap`, founder creates the Forgejo remote). The package holds one Job per admission in `sso` with the +T03 ServiceAccount, Role and an egress-only NetworkPolicy. + +**Done when:** the image builds in the workflow, the rapp renders and passes `server-dry-run` against a synthetic +manifest check, and nothing is applied. ## Pilot: unattended bind rotation under one admission @@ -500,7 +544,7 @@ priority: high assignee: claude-code needs_human: true intervention_note: "Founder signs the admission for the single live pilot rotation." -depends_on: [NK-WP-0056-T01, NK-WP-0056-T03, NK-WP-0056-T04, NK-WP-0056-T05, NK-WP-0056-T10, NK-WP-0056-T12] +depends_on: [NK-WP-0056-T01, NK-WP-0056-T03, NK-WP-0056-T04, NK-WP-0056-T05, NK-WP-0056-T10, NK-WP-0056-T13] state_hub_task_id: "6c3939f8-23a9-5f70-a3b1-94f4b291706b" ```