secrets-engine/tests/authorization_stub.py
tegwick f62d3fe789 feat: complete and prove the authorization chain end to end
Implements step 2 (access-engine POST /v1/check) and wires the whole
GH-DEC-2026-003 sequence together, then proves it against a live throwaway
OpenBao rather than only unit-level fakes.

- decision_check.check_decision performs the PDP call; an unreachable or
  non-200 PDP raises, since silence is never permission.
- approval_consume.authorize_action coordinates steps 1 and 2 and returns an
  AuthorizedAction. Both steps build the same CheckRequest via a shared
  _expected_request, since two descriptions of the action cannot produce
  corresponding digests.
- apply_unreachable_engine_stance takes authorized=. The published map
  defines fail_closed as no side effect WITHOUT a durable decision record,
  so holding a validated one means the residue does not apply. Not a bypass:
  both steps must have succeeded and CAS consume still precedes OpenBao.
  Unconfigured still returns None and fails closed.

The end-to-end test caught one more instance of the cross-vocabulary bug: a
leftover comparison of the claim's binding.action against ours. The claim
says secrets.kv.destroy where we say destroy, so it would have failed against
every real claim. Removed; the tie is pdp_digest.

Integration coverage asserts PIP-then-PDP ordering, that consume is the last
step before the backend, and that an unreachable PDP, denied decision,
invalid claim, missing pdp_digest, consume conflict and action mismatch each
stop before OpenBao. 284 tests pass; production still fails closed.

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

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 393550@bnt-lap001
Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 14:56:02 +02:00

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