The exercise runs the real request phase, KeyCape client credentials, preflight and consume against KeyCape's pinned image and approval-engine's source with synthetic keys. It found that approval-engine stores an entry's assurance as the verified token's JSON object (KeyCape assuranceClaim), so the executor's comparison with a bare "aal2" refused every real founder approval. entry_is_aal2 now requires level aal2/aal3 with mfa true and fails closed otherwise. Fakes use the real shape. The request-phase tests freeze the clock inside the example admission's window (they had expired on 2026-11-03T11:00Z). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 523928@bnt-lap001 Assistant-Session: 6c737afb-9c14-4152-9f02-477214b9c350
396 lines
21 KiB
Python
396 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Executor approval exercise against the real services (NK-WP-0056-T19).
|
|
|
|
Disposable KeyCape on the pinned image -> the executor's own client -> approval-engine's real source.
|
|
Follows secrets-engine `tools/exercise_approval_identity.py`. Synthetic keys and credentials only. No OpenBao, no
|
|
cluster, no live service.
|
|
|
|
The executor-side code under test is the real code: `cli.run_request` (request phase), `clients.KeyCapeClientCredentials`,
|
|
`clients.ApprovalEngine`, and `executor.Run.preflight` and `consume`. Only the OpenBao lane that delivers the client
|
|
secret is replaced: the generated value is handed straight to the client. The T18 tests cover that lane.
|
|
|
|
KeyCape runs `client-from-case` output for case `nk-bind-executor-approval` and the `approval-engine-operator` client
|
|
from key-cape's example registry. The operator client stands in for the revoker and for a service that tries to bind a
|
|
human-control approval. approval-engine runs in-process (WSGI) with its JWT authenticator against KeyCape's JWKS.
|
|
|
|
Checks: create, approve, consume, idempotent retry, a different digest, revoke-before-consume, wrong scopes, and the
|
|
executor's own refusals. The founder's aal2 entry is seeded through approval-engine's store with the exact assurance
|
|
object KeyCape mints (token.go assuranceClaim) and that approval-engine's API persists. That is a limitation: there is
|
|
no human PKCE login here.
|
|
|
|
Run: python3 bounded-executor/exercise_approval_identity.py --receipt FILE
|
|
Writes a value-free receipt (no secret, token or generated value; asserted). Exit 0 when every check passes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import datetime as dt
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import secrets
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from unittest.mock import patch
|
|
from wsgiref.simple_server import WSGIRequestHandler, make_server
|
|
|
|
import yaml
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
KEYCAPE_IMAGE = ("forgejo.coulomb.social/coulomb/key-cape@sha256:"
|
|
"9e082dca78f65a4af3e92d3de3705b99e49ca9b298d9562ad0d37511c4306f8d") # sso-mfa/k8s/keycape/deployment.yaml
|
|
HERE = pathlib.Path(__file__).resolve().parent
|
|
REPO = HERE.parent
|
|
CASE = REPO / "security-case-types/oidc-service-client/examples/nk-bind-executor-approval.yaml"
|
|
EXECUTOR = "service:nk-bind-executor"
|
|
APPROVER_SUB = "synthetic-founder-sub"
|
|
OPERATOR = "approval-engine-operator"
|
|
UNDER_TEST = ("cli.py", "clients.py", "executor.py")
|
|
|
|
|
|
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
|
|
|
|
|
|
cli = _load("bounded_executor_cli", HERE / "cli.py")
|
|
X, C, OPS = cli.X, cli.C, cli.OPS
|
|
keycape_client = _load("keycape_client", REPO / "sso-mfa/k8s/keycape/keycape-client.py")
|
|
|
|
|
|
def run(args, timeout=60):
|
|
result = subprocess.run(args, capture_output=True, timeout=timeout)
|
|
if result.returncode:
|
|
raise RuntimeError("contained command failed: " + args[0])
|
|
return result.stdout.decode().strip()
|
|
|
|
|
|
def commit(path: pathlib.Path) -> str:
|
|
return run(["git", "-C", str(path), "rev-parse", "HEAD"])
|
|
|
|
|
|
def private_write(path: pathlib.Path, value: str) -> None:
|
|
with path.open("x") as out:
|
|
os.chmod(path, 0o600)
|
|
out.write(value)
|
|
|
|
|
|
def free_port() -> int:
|
|
with socket.socket() as sock:
|
|
sock.bind(("127.0.0.1", 0))
|
|
return sock.getsockname()[1]
|
|
|
|
|
|
def keycape_assurance(level: str) -> str:
|
|
"""The assurance object KeyCape mints for a browser login (token.go assuranceClaim), stored by approval-engine's
|
|
API as json.dumps(identity.assurance, sort_keys=True) (api.py, POST .../entries)."""
|
|
mfa = level != "aal1"
|
|
return json.dumps({"level": level, "methods": ["pwd", "otp"] if mfa else ["pwd"], "mfa": mfa,
|
|
"source": "key-cape", "at": int(time.time())}, sort_keys=True)
|
|
|
|
|
|
def admission(admission_id: str) -> dict:
|
|
"""The pilot example pinned to the current files, with a window around now (synthetic_pilot.admission)."""
|
|
doc = X.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")}
|
|
files = [{"path": f["path"], "sha256": hashlib.sha256((REPO / f["path"]).read_bytes()).hexdigest()}
|
|
for f in spec["plan"]["source"]["files"]]
|
|
spec["plan"]["source"]["files"] = files
|
|
pins = {f["path"]: f["sha256"] for f in files}
|
|
for check in spec["checks"]:
|
|
check["sha256"] = pins[check["ref"]]
|
|
spec["plan_digest"] = X.bea.plan_digest(doc)
|
|
if not X.bea.evaluate(doc, json.loads(X.bea.SCHEMA.read_text()))["valid"]:
|
|
raise RuntimeError("synthetic admission invalid")
|
|
return doc
|
|
|
|
|
|
def expect(fn, *errors):
|
|
"""Run fn and return the exception it raised; fail if it raised nothing or something else."""
|
|
try:
|
|
fn()
|
|
except errors as error:
|
|
return error
|
|
raise AssertionError("expected a refusal")
|
|
|
|
|
|
def exercise(args) -> dict:
|
|
sys.path.insert(0, str(args.approval_engine_source))
|
|
from approval_engine.api import App
|
|
from approval_engine.auth import JWTAuthenticator
|
|
from approval_engine.store import Engine
|
|
|
|
client = keycape_client.client_from_case(yaml.safe_load(CASE.read_text()))
|
|
receipt = {
|
|
"schema": "netkingdom.bounded-executor.approval-exercise/v1",
|
|
"task": "NK-WP-0056-T19",
|
|
"target": "disposable local processes; synthetic keys and credentials",
|
|
"started_at": dt.datetime.now(dt.UTC).isoformat(timespec="seconds"),
|
|
"keycape_image": KEYCAPE_IMAGE,
|
|
"keycape_contract_commit": commit(args.keycape_source),
|
|
"approval_engine_commit": commit(args.approval_engine_source),
|
|
"net_kingdom_commit": commit(REPO),
|
|
"executor_source_sha256": {n: hashlib.sha256((HERE / n).read_bytes()).hexdigest() for n in UNDER_TEST},
|
|
"case_sha256": hashlib.sha256(CASE.read_bytes()).hexdigest(),
|
|
"keycape_client": {k: client[k] for k in ("clientId", "audience", "allowedScopes", "grantTypes",
|
|
"serviceSubject", "tenant", "tokenLifetime")},
|
|
"limitations": [
|
|
"approval-engine runs from source in-process, not the deployed image",
|
|
"the founder's aal2 entry is seeded through approval-engine's store; no human PKCE login",
|
|
"the client secret is handed to the client directly; the OpenBao lane is covered by the T18 tests",
|
|
"no informed-decision presentation (T21 tests cover the memo against its real validator)",
|
|
"plain HTTP on loopback; production uses KeyCape's public HTTPS issuer",
|
|
],
|
|
"checks": {},
|
|
}
|
|
checks = receipt["checks"]
|
|
values: dict[str, str] = {}
|
|
|
|
with tempfile.TemporaryDirectory(prefix="nk56-t19-") as temporary:
|
|
root = pathlib.Path(temporary)
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
private_write(root / "key.pem", key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption()).decode())
|
|
port = free_port()
|
|
issuer = f"http://127.0.0.1:{port}"
|
|
config = yaml.safe_load((args.keycape_source / "config/dev-config.yaml").read_text())
|
|
examples = yaml.safe_load((args.keycape_source / "config/service-clients.example.yaml").read_text())["clients"]
|
|
operator = [c for c in examples if c["clientId"] == OPERATOR]
|
|
assert len(operator) == 1
|
|
config.update(issuer=issuer, clients=[client, operator[0]])
|
|
config["authelia"]["issuer"] = "https://synthetic-upstream.invalid"
|
|
private_write(root / "config.yaml", yaml.safe_dump(config, sort_keys=False))
|
|
values = {c["clientId"]: secrets.token_urlsafe(48) for c in (client, operator[0])}
|
|
private_write(root / "env", "KEYCAPE_CONFIG=/etc/keycape/config.yaml\n" + "".join(
|
|
c["secretRef"].removeprefix("env:") + "=" + values[c["clientId"]] + "\n" for c in (client, operator[0])))
|
|
os.chmod(root, 0o755) # the container user reads the bind mount
|
|
container = "nk56-t19-keycape-" + secrets.token_hex(5)
|
|
created = engine = api_server = None
|
|
try:
|
|
run(["docker", "run", "-d", "--rm", "--pull=never", "--name", container, "--user", str(os.getuid()),
|
|
"--read-only", "--cap-drop=ALL", "--security-opt=no-new-privileges",
|
|
"--publish", f"127.0.0.1:{port}:8080", "--env-file", str(root / "env"),
|
|
"--mount", f"type=bind,source={temporary},target=/etc/keycape,readonly", KEYCAPE_IMAGE])
|
|
created = True
|
|
for _ in range(80):
|
|
try:
|
|
with urllib.request.urlopen(issuer + "/jwks", timeout=1) as response:
|
|
if response.status == 200:
|
|
break
|
|
except Exception:
|
|
time.sleep(0.25)
|
|
else:
|
|
raise RuntimeError("disposable KeyCape did not start")
|
|
|
|
engine = Engine(root / "approval.sqlite")
|
|
app = App(engine, JWTAuthenticator(issuer=issuer, audience="approval-engine", jwks_url=issuer + "/jwks"))
|
|
|
|
class Quiet(WSGIRequestHandler):
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
api_server = make_server("127.0.0.1", 0, app, handler_class=Quiet)
|
|
threading.Thread(target=api_server.serve_forever, daemon=True).start()
|
|
api = f"http://127.0.0.1:{api_server.server_port}"
|
|
|
|
# ---- the executor's own client, exactly as cli.approval_carrier builds it after the OpenBao read
|
|
secret = values[client["clientId"]]
|
|
tokens = C.KeyCapeClientCredentials(issuer + "/token", client["clientId"], lambda: secret)
|
|
carrier = C.ApprovalEngine(api, token_provider=tokens.token)
|
|
operator_tokens = C.KeyCapeClientCredentials(issuer + "/token", OPERATOR, lambda: values[OPERATOR])
|
|
operator_carrier = C.ApprovalEngine(api, token_provider=operator_tokens.token)
|
|
|
|
cfg = {"executor_identity": EXECUTOR, "approver_subjects": {"admin": APPROVER_SUB},
|
|
"openbao": {"addr": "http://127.0.0.1:1"}, "journey": {}, "approval_engine": {"url": api}}
|
|
run_cfg = X.Config(executor_identity=EXECUTOR, runtime_principal="system:serviceaccount:sso:nk-bind-executor",
|
|
tenant="tenant:platform", environment="TEST", approver_subjects={"admin": APPROVER_SUB})
|
|
|
|
def deps_for(doc):
|
|
plan = doc["spec"]["plan"]
|
|
return X.Deps(carrier=carrier,
|
|
operations={s["operation"]: None for s in plan["steps"] + plan.get("revert_steps", [])},
|
|
checks={c["id"]: None for c in doc["spec"]["checks"]}, trust=lambda _s: True,
|
|
audit=None, alert=lambda *_a: None)
|
|
|
|
def request(doc) -> str:
|
|
"""The real request phase; only the OpenBao lane is replaced by the client built above."""
|
|
path = root / (doc["spec"]["id"] + ".yaml")
|
|
path.write_text(yaml.safe_dump(doc, sort_keys=False))
|
|
with patch.object(cli, "approval_carrier", lambda _cfg, _logins: carrier), \
|
|
patch.object(OPS, "wait_reachable", lambda *_a, **_k: None):
|
|
result, code = cli.run_request(str(path), cfg)
|
|
assert code == 0 and result["outcome"] == "requested", result.get("reason")
|
|
return result["approval_id"]
|
|
|
|
def found(approval_id):
|
|
return engine.get(approval_id).as_dict()
|
|
|
|
# ---- 1. create, approve, consume ------------------------------------------------------------------
|
|
doc = admission("BEA-2026-9101")
|
|
digest = X.bea.admission_digest(doc)
|
|
aid = request(doc)
|
|
obj = found(aid)
|
|
assert obj["status"] == "requested" and obj["required_count"] == 1
|
|
assert obj["binding"]["actor"] == EXECUTOR and obj["binding"]["human_control"] is True
|
|
assert obj["binding"]["target"] == {"admission_id": "BEA-2026-9101", "admission_digest": digest}
|
|
assert obj["binding"]["principal"] == APPROVER_SUB and obj["binding"]["pdp_path"] is False
|
|
checks["request_phase_creates_as_executor_via_verified_keycape_jwt"] = True
|
|
|
|
binding, validity, required = cli.request_binding(doc, cfg)
|
|
spoof = {**binding, "actor": "service:someone-else"}
|
|
assert str(expect(lambda: carrier.create(spoof, validity, required), C.ClientError)) == "POST 403"
|
|
checks["create_with_another_actor_refused"] = True
|
|
|
|
assert str(expect(lambda: X.Run(doc, aid, run_cfg, deps_for(doc)).preflight(), X.Refused)) == "approval_status"
|
|
checks["preflight_refuses_unapproved"] = True
|
|
|
|
assert str(expect(lambda: operator_carrier.http.call("POST", f"/v1/approvals/{aid}/entries", {},
|
|
scope="approval:approve"), C.ClientError)) == "POST 403"
|
|
assert found(aid)["entries"] == []
|
|
checks["service_approve_of_human_control_refused"] = True
|
|
|
|
engine.add_entry(aid, APPROVER_SUB, assurance=keycape_assurance("aal2"), principal_type="human",
|
|
evidence_ref="synthetic-store-fixture", tenant_source="directory")
|
|
assert found(aid)["status"] == "approved"
|
|
checks["human_aal2_entry_approves"] = True
|
|
|
|
first = X.Run(doc, aid, run_cfg, deps_for(doc))
|
|
first.preflight()
|
|
assert first.approver_subject == APPROVER_SUB
|
|
checks["executor_preflight_accepts_keycape_aal2_entry"] = True
|
|
|
|
first.consume()
|
|
obj = found(aid)
|
|
assert obj["status"] == "consumed"
|
|
assert obj["consumption"]["request_digest"] == digest and obj["consumption"]["decision_id"] == first.run_id
|
|
checks["executor_consumes_once_and_confirms_by_read_back"] = True
|
|
|
|
again = carrier.consume(aid, digest, first.run_id)
|
|
assert again["idempotent"] is True and again["status"] == "consumed"
|
|
checks["same_digest_retry_idempotent"] = True
|
|
|
|
assert str(expect(lambda: X.Run(doc, aid, run_cfg, deps_for(doc)).preflight(), X.Refused)) == "approval_status"
|
|
checks["second_run_on_consumed_approval_refused_before_consume"] = True
|
|
|
|
assert str(expect(lambda: carrier.consume(aid, "sha256:" + "f" * 64, "other-run"), C.ClientError)) == "POST 409"
|
|
assert found(aid)["consumption"]["request_digest"] == digest
|
|
checks["different_digest_refused"] = True
|
|
|
|
changed = admission("BEA-2026-9101")
|
|
changed["spec"]["budget"]["attempts"] = changed["spec"]["budget"]["attempts"] + 1
|
|
assert X.bea.admission_digest(changed) != digest
|
|
assert str(expect(lambda: X.Run(changed, aid, run_cfg, deps_for(changed)).preflight(), X.Refused)) \
|
|
== "approval_binding"
|
|
checks["changed_admission_refused_by_binding_digest"] = True
|
|
|
|
# ---- 2. revoke before consume ---------------------------------------------------------------------
|
|
doc = admission("BEA-2026-9102")
|
|
aid = request(doc)
|
|
engine.add_entry(aid, APPROVER_SUB, assurance=keycape_assurance("aal2"), principal_type="human",
|
|
evidence_ref="synthetic-store-fixture", tenant_source="directory")
|
|
X.Run(doc, aid, run_cfg, deps_for(doc)).preflight()
|
|
assert str(expect(lambda: carrier.http.call("POST", f"/v1/approvals/{aid}/revoke", {},
|
|
scope="approval:read"), C.ClientError)) == "POST 403"
|
|
status, revoked = operator_carrier.http.call("POST", f"/v1/approvals/{aid}/revoke", {}, scope="approval:revoke")
|
|
assert status == 200 and revoked["status"] == "revoked"
|
|
assert str(expect(lambda: X.Run(doc, aid, run_cfg, deps_for(doc)).preflight(), X.Refused)) == "approval_status"
|
|
assert str(expect(lambda: carrier.consume(aid, X.bea.admission_digest(doc), "run"), C.ClientError)) == "POST 409"
|
|
assert found(aid)["status"] == "revoked" and "consumption" not in found(aid)
|
|
checks["revoke_before_consume_refused_by_executor_and_engine"] = True
|
|
|
|
# ---- 3. an aal1 human entry: approval-engine accepts it (no floor declared); the executor refuses
|
|
doc = admission("BEA-2026-9103")
|
|
aid = request(doc)
|
|
engine.add_entry(aid, APPROVER_SUB, assurance=keycape_assurance("aal1"), principal_type="human",
|
|
evidence_ref="synthetic-store-fixture", tenant_source="directory")
|
|
assert found(aid)["status"] == "approved"
|
|
assert str(expect(lambda: X.Run(doc, aid, run_cfg, deps_for(doc)).preflight(), X.Refused)) == "approval_entry"
|
|
assert found(aid)["status"] == "approved"
|
|
checks["aal1_human_entry_refused_by_executor"] = True
|
|
|
|
# ---- 4. wrong scope and wrong secret ---------------------------------------------------------------
|
|
form = urllib.parse.urlencode({"grant_type": "client_credentials", "scope": "approval:approve"}).encode()
|
|
basic = base64.b64encode(f"{client['clientId']}:{secret}".encode()).decode()
|
|
req = urllib.request.Request(issuer + "/token", data=form, headers={"Authorization": "Basic " + basic})
|
|
error = expect(lambda: urllib.request.urlopen(req, timeout=5), urllib.error.HTTPError)
|
|
with error:
|
|
assert error.code in (400, 401) and json.load(error)["error"] == "invalid_profile_usage"
|
|
assert str(expect(lambda: tokens.token("approval:approve"), C.ClientError)).startswith("token 4")
|
|
checks["executor_client_refused_approve_scope_by_issuer"] = True
|
|
|
|
read_only = C.ApprovalEngine(api, token_provider=lambda _scope: tokens.token("approval:read"))
|
|
binding, validity, required = cli.request_binding(admission("BEA-2026-9104"), cfg)
|
|
assert str(expect(lambda: read_only.create(binding, validity, required), C.ClientError)) == "POST 403"
|
|
assert str(expect(lambda: read_only.consume(aid, "sha256:" + "a" * 64, "run"), C.ClientError)) == "POST 403"
|
|
checks["token_for_another_scope_refused_by_engine"] = True
|
|
|
|
wrong = C.KeyCapeClientCredentials(issuer + "/token", client["clientId"], lambda: "synthetic-wrong-secret")
|
|
assert str(expect(lambda: wrong.token("approval:read"), C.ClientError)).startswith("token 4")
|
|
checks["wrong_client_secret_refused"] = True
|
|
finally:
|
|
if api_server:
|
|
api_server.shutdown()
|
|
api_server.server_close()
|
|
if engine:
|
|
engine.close()
|
|
if created:
|
|
run(["docker", "stop", "--time=5", container])
|
|
|
|
receipt.update(status="passed", cleanup_complete=True,
|
|
finished_at=dt.datetime.now(dt.UTC).isoformat(timespec="seconds"))
|
|
text = json.dumps(receipt, indent=2, sort_keys=False)
|
|
if any(v in text for v in values.values()):
|
|
raise RuntimeError("receipt would carry a generated value")
|
|
return receipt
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
|
|
home = pathlib.Path.home()
|
|
parser.add_argument("--keycape-source", type=pathlib.Path, default=home / "key-cape")
|
|
parser.add_argument("--approval-engine-source", type=pathlib.Path, default=home / "approval-engine")
|
|
parser.add_argument("--receipt", required=True, type=pathlib.Path)
|
|
parser.add_argument("--trace", action="store_true", help="print the failing assertion's location (no values)")
|
|
args = parser.parse_args(argv)
|
|
if args.receipt.exists():
|
|
raise SystemExit("receipt path must be new")
|
|
try:
|
|
receipt = exercise(args)
|
|
except Exception as error:
|
|
# Never dump a request, client environment or credential-bearing frame.
|
|
where = ""
|
|
if args.trace:
|
|
tb, line = error.__traceback__, None
|
|
while tb:
|
|
if tb.tb_frame.f_code.co_filename == __file__:
|
|
line = tb.tb_lineno
|
|
tb = tb.tb_next
|
|
reason = str(error) if isinstance(error, (X.Refused, C.ClientError)) else ""
|
|
where = f" at line {line} {reason}".rstrip()
|
|
print(f"Executor approval exercise failed: {type(error).__name__}{where}", file=sys.stderr)
|
|
return 1
|
|
private_write(args.receipt, json.dumps(receipt, indent=2) + "\n")
|
|
print(f"Executor approval exercise passed: {len(receipt['checks'])} checks; temporary resources removed.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|