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:
tegwick 2026-09-06 14:56:02 +02:00
parent e8144f315c
commit f62d3fe789
9 changed files with 675 additions and 44 deletions

View file

@ -36,12 +36,14 @@ class StanceApplication:
action: str
demo_exception: bool = False
decision_id: str = ""
authorized: bool = False
def as_evidence(self) -> dict[str, object]:
payload: dict[str, object] = {
"stance_stage": self.stage,
"stance_failure_mode": self.failure_mode,
"stance_demo_exception": self.demo_exception,
"stance_authorized": self.authorized,
}
if self.decision_id:
payload["stance_decision_id"] = self.decision_id
@ -111,12 +113,20 @@ def apply_unreachable_engine_stance(
action: str,
*,
stance_map: PepStanceMap | None = None,
authorized: bool = False,
) -> StanceApplication:
"""Apply the published unreachable-engine residue for a live action.
``fail_closed`` without the demo exception raises ``DecisionError`` carrying
named stance fields. ``fail_open`` is the documented residue: continue to
the existing lane-approval check, which is itself a gap until T02.
``fail_closed`` is the *unreachable-engine* residue, not a blanket ban: the
published map defines it as no protected side effect without a durable
access-engine decision record. ``authorized=True`` means the caller already
obtained and validated that record for this exact action, so the engine was
reachable and the residue does not apply. It is never a bypass -- the caller
must have completed steps 1 and 2, and GH-DEC-2026-003 still requires a
successful CAS consume before any OpenBao call.
Without such a record, ``fail_closed`` raises ``DecisionError`` carrying
named stance fields. ``fail_open`` is the documented residue for build/test.
"""
loaded = stance_map or load_pep_stance()
stage, mode = loaded.for_stage(getattr(entry, "stage", "unknown"))
@ -126,8 +136,9 @@ def apply_unreachable_engine_stance(
failure_mode=mode,
action=action or "unknown",
demo_exception=bool(demo and mode == "fail_closed"),
authorized=bool(authorized),
)
if mode == "fail_closed" and not demo:
if mode == "fail_closed" and not demo and not authorized:
raise DecisionError(
f"production action '{applied.action}' requires a durable "
"access-engine decision record; live production remains disabled",