fix: bind approval consumption to actual Flex Auth submissions
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 08:55:46 +02:00
parent 89bc31460f
commit ee4e901611
23 changed files with 612 additions and 1023 deletions

View file

@ -2,7 +2,8 @@
"""Disposable KeyCape image -> actual Secrets Engine client -> Approval Engine.
Synthetic keys/credentials only. No OpenBao, cluster mutation, or model call.
The PDP is a sequencing double; this is not production authorization evidence.
The actual Flex Auth evaluator runs locally with its published policy/registry.
This is component conformance evidence, not production authorization evidence.
"""
from __future__ import annotations
@ -66,6 +67,7 @@ def exercise(args):
from secrets_engine import cli
from secrets_engine.approval_auth import approval_token, credential_urlopen
from secrets_engine.approval_consume import ConsumeBinding, _expected_request, consume_approval
from secrets_engine.authorization import request_digest, validate_decision_envelope
from secrets_engine.catalog import validate_entry
from secrets_engine.config import Config
from secrets_engine.errors import BackendError, DecisionError
@ -77,17 +79,44 @@ def exercise(args):
"started_at": datetime.now(timezone.utc).isoformat(),
"keycape_image": KEYCAPE_IMAGE,
"approval_engine_commit": commit(args.approval_engine_source),
"flex_auth_commit": commit(args.flex_auth_source),
"keycape_contract_commit": commit(args.keycape_source),
"consumer_source_sha256": {
name: hashlib.sha256((ROOT / "src/secrets_engine" / name).read_bytes()).hexdigest()
for name in ("approval_auth.py", "approval_consume.py", "config.py", "service_auth.py")
for name in ("approval_auth.py", "approval_consume.py", "approval_claim.py", "authorization.py", "config.py", "service_auth.py", "cli.py")
},
"limitations": ["PDP sequencing double", "local Approval Engine source, not deployed image",
"limitations": ["standalone Flex Auth source, not deployed pin", "local Approval Engine source, not deployed image",
"no live custody or client-side read grant", "no OpenBao effect or model execution"],
"checks": {},
}
with tempfile.TemporaryDirectory(prefix="approval-identity-private-") as temporary:
root = Path(temporary)
source = args.flex_auth_source.resolve()
run(["go", "build", "-C", str(source), "-o", str(root / "flex-auth"), "./cmd/flex-auth"])
producer_files = source / "examples/secrets-engine"
receipt["flex_auth_binary_sha256"] = hashlib.sha256((root / "flex-auth").read_bytes()).hexdigest()
receipt["producer_input_sha256"] = {
name: hashlib.sha256((producer_files / name).read_bytes()).hexdigest()
for name in ("policy_package.md", "registry_snapshot.json")
}
class ProducerPDP(AuthorizationStub):
"""Loopback transport only; every decision comes from the Go evaluator."""
last_decision = None
def decision(self, request):
path = root / "check-request.json"
path.write_text(json.dumps(request))
path.chmod(0o600)
self.last_decision = json.loads(run([str(root / "flex-auth"), "check",
"-policy", str(producer_files / "policy_package.md"),
"-registry", str(producer_files / "registry_snapshot.json"), "-request", str(path)]))
return self.last_decision
def bind_request(self, request):
# At issue, record the evaluator's claim-free digest, even if
# the destructive action denies until approval is supplied.
return self.decision(request)["binding"]["request_digest"]
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())
@ -189,7 +218,7 @@ def exercise(args):
raw["stage"] = "prod"
raw["approval"] = {"model": "bootstrap-only", "authorization_id": "synthetic-approval", "purpose": "disposable identity proof"}
entry = validate_entry(raw)
pdp = AuthorizationStub(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start()
pdp = ProducerPDP(approval_id="unused", package="secrets-engine.catalog-lane.lifecycle", version="v2").start()
cfg = Config(catalog_dir=root, policy_dir=root, evidence_dir=root / "evidence", hub_url="", bao_addr="", topic_id="",
approval_url=api, approval_client_secret_file=root / "approval.secret",
keycape_issuer=issuer, keycape_token_url=issuer + "/token",
@ -215,9 +244,15 @@ def exercise(args):
receipt["checks"]["wrong_action_refused_before_consume"] = True
cli._require_lane_approval(cfg, entry, "apply")
assert engine.claim(obj["id"])["consumed"]
assert pdp.calls == ["check"]
assert pdp.calls == ["check", "check"]
accepted = pdp.last_decision
assert accepted["binding"]["approval_binding_digest"] == digest
assert accepted["binding"]["request_digest"] != digest
assert accepted["binding"]["submitted_request_digest"] != accepted["binding"]["request_digest"]
assert accepted["binding"]["context"]["approval"]["approval_id"] == obj["id"]
receipt["checks"]["producer_origin_join_with_carried_claim"] = True
receipt["checks"]["actual_consumer_claim_check_consume"] = True
binding = ConsumeBinding(obj["id"], digest)
binding = ConsumeBinding(obj["id"], accepted["binding"]["request_digest"], accepted["id"])
retry = consume_approval(base_url=api, binding=binding, token_provider=lambda: approval_token(cfg, scope="approval:consume"))
assert retry.idempotent
receipt["checks"]["same_digest_retry_idempotent"] = True
@ -254,6 +289,44 @@ def exercise(args):
receipt["checks"]["wrong_secret_refused"] = True
assert not (root / "approval.token").exists()
receipt["checks"]["no_access_token_file_created"] = True
# Independent input -> evaluator -> consumer: registry may
# override a caller's same-key facts without breaking replay.
request = json.loads((producer_files / "check_request_allow_rotate.json").read_text())
request["subject"]["attributes"] = {"roles": ["UntrustedCallerRole"]}
decision = pdp.decision(request)
assert decision["binding"]["subject"]["attributes"]["roles"] == ["Operator"]
assert decision["binding"]["submitted_request_digest"] == request_digest(request)
validate_decision_envelope(decision, request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"})
receipt["checks"]["registry_override_accepts_exact_submission"] = True
replay = copy.deepcopy(request)
replay["subject"]["attributes"]["roles"] = ["Operator"]
try:
validate_decision_envelope(decision, replay,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"}, accepted_policy_versions={"v2"})
except DecisionError:
pass
else:
raise AssertionError("changed submission replayed")
receipt["checks"]["same_enriched_result_different_submission_refused"] = True
# Real dual-control policy: empty claim denies; fresh issued
# claim passes. No destructive backend is connected.
raw["approval"]["authorization_id"] = "synthetic-destroy"
destroy_entry = validate_entry(raw)
destroy_request = _expected_request(cfg, destroy_entry, "destroy")
denied = pdp.decision(destroy_request)
assert denied["effect"] == "deny" and denied["reason"] == "dual_control_required"
operator_request("/v1/approvals", {
"id": "synthetic-destroy", "binding": {"action": "secrets.kv.destroy", "target": {"id": destroy_entry.id, "stage": "prod"},
"actor": "service:approval-engine-operator", "principal": "synthetic-operator", "purpose": "disposable identity proof"},
"validity": {"not_before": (now - timedelta(minutes=1)).isoformat(), "expires_at": (now + timedelta(minutes=10)).isoformat()},
"pdp_digest": denied["binding"]["request_digest"], "pdp_path": True}, "approval:create")
operator_request("/v1/approvals/synthetic-destroy/entries", {}, "approval:approve")
(root / "approval.secret").write_text(values["secrets-engine-approval"])
cli._require_lane_approval(cfg, destroy_entry, "destroy")
assert engine.claim("synthetic-destroy")["consumed"]
receipt["checks"]["real_dual_control_denial_then_claim_check_consume"] = True
finally:
if api_server:
api_server.shutdown()
@ -275,6 +348,7 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--keycape-source", required=True, type=Path)
parser.add_argument("--approval-engine-source", required=True, type=Path)
parser.add_argument("--flex-auth-source", required=True, type=Path)
parser.add_argument("--receipt", required=True, type=Path)
args = parser.parse_args()
if args.receipt.exists():