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
This commit is contained in:
parent
e8144f315c
commit
f62d3fe789
9 changed files with 675 additions and 44 deletions
173
tests/authorization_stub.py
Normal file
173
tests/authorization_stub.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
"""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),
|
||||
},
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue