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
|
|
|
"""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
|
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
|
|
|
#: 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
|
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
|
|
|
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,
|
|
|
|
|
}
|
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
|
|
|
binding["pdp_path"] = self.claim_pdp_path
|
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
|
|
|
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),
|
|
|
|
|
},
|
|
|
|
|
}
|