"""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), }, }