fix: bind approval consumption to actual Flex Auth submissions
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
89bc31460f
commit
ee4e901611
23 changed files with 612 additions and 1023 deletions
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue