174 lines
6.7 KiB
Python
174 lines
6.7 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
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
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),
|
||
|
|
},
|
||
|
|
}
|