secrets-engine/tests/authorization_stub.py
tegwick c44306b1b2
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
feat: bind the destroy gate to approval_binding_digest and pdp_path
The vocabulary mapping this path was waiting on is not coming: gate-house
rejected it in GH-DEC-2026-008, because a translation can be confidently
wrong and fails open by accepting a claim approved for a different action.
The stronger option arrived instead, and both halves are enforced here.

flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) to
fix the circularity this repo reported: a pdp_digest recorded at issue time
can never equal the request_digest of the request that carries the claim in
its hashed context, so with GH-DEC-2026-008 requiring that equality, destroy
would have failed closed forever on a check no correct record could pass.

- authorization.approval_binding_digest implements the published exclusion
  rule, including Go's context,omitempty behaviour when stripping empties
  the context; digest_material drops an empty context for the same reason.
- validate_decision_envelope recomputes the field rather than trusting it,
  refuses a claim-bearing request whose decision records none, and compares
  the claim's digest from step 1 against it -- never against request_digest,
  which still covers the claim so it stays a sound replay identity.
- validate_approval_claim requires binding.pdp_path true before using
  pdp_digest at all. Path intent is never inferred from a digest that
  happens to be present; pre-schema-v3 approvals carry pdp_path false
  regardless of any digest they hold.

Replay fixtures re-vendored from dd3ce4c. The destroy pins moved a second
and final time; approval_binding_digest did not, which is the point. The
fixture now demonstrates the property instead of asserting it: we rederive
fa07becf... from its own request through our canonical implementation,
proving we hash the same material flex-auth does rather than pinning a
constant we cannot reproduce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4tNMAYcSQmZWUE4wqP4ij

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715726@bnt-lap001
Assistant-Session: 80a42b32-cba6-4b23-8be0-68819b1a6092
2026-09-06 20:39:59 +02:00

177 lines
6.9 KiB
Python

"""In-process stand-ins for approval-engine and access-engine.
These exist so the whole GH-DEC-2026-003 chain can be exercised end to end
before either engine is deployed: claim -> check -> consume -> OpenBao. They are
test doubles for *transport and sequencing*, not for the contracts -- the wire
shapes are pinned independently against flex-auth's real replay fixtures in
tests/test_decision_replay.py, which is what stops these stubs from quietly
defining a contract of their own.
"""
from __future__ import annotations
import json
import threading
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
from secrets_engine.approval_claim import binding_from_check_request
from secrets_engine.authorization import request_digest
def _now():
return datetime.now(timezone.utc)
def _stamp(moment):
return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
class AuthorizationStub:
"""Serves the claim, check and consume endpoints on a loopback port."""
def __init__(self, *, approval_id, package, version, policy_digest="sha256:" + "1" * 64):
self.approval_id = approval_id
self.package = package
self.version = version
self.policy_digest = policy_digest
self.calls: list[str] = []
self.consumed = False
#: set to force a specific failure for negative tests
self.claim_valid_now = True
self.claim_reason_code = "ok"
self.include_pdp_digest = True
#: approval-engine schema v3 declares whether the approval was
#: requested against a bound CheckRequest; false is the pre-v3 shape
self.claim_pdp_path = True
self.effect = "allow"
self.consume_status = 200
self._pdp_digest = ""
self._server = None
self._thread = None
# -- lifecycle ----------------------------------------------------------
def start(self):
stub = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_args):
pass
def _send(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == f"/v1/approvals/{stub.approval_id}/claim":
stub.calls.append("claim")
return self._send(200, stub.claim())
return self._send(404, {"error": "not found"})
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
if self.path == "/v1/check":
stub.calls.append("check")
return self._send(200, stub.decision(json.loads(raw)))
if self.path == f"/v1/approvals/{stub.approval_id}/consume":
stub.calls.append("consume")
if stub.consume_status != 200:
return self._send(stub.consume_status, {"error": "conflict"})
stub.consumed = True
return self._send(200, {
"status": "consumed",
"request_digest": json.loads(raw).get("request_digest"),
"consumed_at": _stamp(_now()),
})
return self._send(404, {"error": "not found"})
self._server = HTTPServer(("127.0.0.1", 0), Handler)
self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
return self
def stop(self):
if self._server is not None:
self._server.shutdown()
self._server.server_close()
@property
def url(self):
return f"http://127.0.0.1:{self._server.server_address[1]}"
# -- payloads -----------------------------------------------------------
def bind_request(self, request):
"""Record the request the engine will propose, so the claim can name it."""
self._pdp_digest = request_digest(request)
self._binding = binding_from_check_request(request)
return self._pdp_digest
def claim(self):
now = _now()
binding = {
# approval-engine's own vocabulary, deliberately unlike ours
"action": f"secrets.kv.{self._binding['action']}",
"actor": self._binding["actor"],
"principal": self._binding["principal"],
"purpose": self._binding["purpose"],
"target": {"id": "lane-under-test", "stage": "prod"},
"digest": "sha256:" + "3" * 64,
}
binding["pdp_path"] = self.claim_pdp_path
if self.include_pdp_digest:
binding["pdp_digest"] = self._pdp_digest
return {
"schema_version": "0.1",
"kind": "approval-claim",
"issuer": "approval-engine",
"approval_id": self.approval_id,
"state": "valid" if self.claim_valid_now else "revoked",
"valid_now": self.claim_valid_now,
"consumed": False,
"binding": binding,
"freshness": {
"observed_at": _stamp(now),
"ttl_seconds": 30,
"not_after": _stamp(now + timedelta(seconds=30)),
},
"validity": {
"not_before": _stamp(now - timedelta(hours=1)),
"expires_at": _stamp(now + timedelta(hours=1)),
},
"reason_code": self.claim_reason_code,
}
def decision(self, request):
now = _now()
binding = {k: request[k] for k in ("tenant", "subject", "action", "resource")
if request.get(k) is not None}
if request.get("context") is not None:
binding["context"] = request["context"]
binding["request_digest"] = request_digest(request)
return {
"id": "decision:stub-" + request["action"],
"contract_version": "flex-auth.decision-record.v1",
"request_id": request.get("id"),
"effect": self.effect,
"subject": request["subject"],
"resource": request["resource"],
"binding": binding,
"lifetime": {
"kind": "ttl",
"ttl": "15m",
"not_before": _stamp(now - timedelta(minutes=1)),
"expires_at": _stamp(now + timedelta(minutes=15)),
},
"provenance": {
"evaluator": "stub/local",
"mode": "standalone",
"policy_package": self.package,
"policy_version": self.version,
"policy_package_digest": self.policy_digest,
"decision_time": _stamp(now),
},
}