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

@ -19,7 +19,12 @@ from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from secrets_engine.approval_claim import validate_approval_claim
from secrets_engine.authorization import build_action_request, request_digest
from secrets_engine.decision_check import check_decision
from secrets_engine.authorization import (
build_action_request,
request_digest,
validate_decision_envelope,
)
from secrets_engine.errors import DecisionError
from secrets_engine.openbao import read_strict_token_file
from secrets_engine.pep_stance import demo_exception_enabled
@ -37,6 +42,26 @@ class ConsumeBinding:
decision_id: str = ""
@dataclass(frozen=True)
class AuthorizedAction:
"""A validated claim and decision for one exact proposed action.
Holding this is not authority to act: GH-DEC-2026-003 still requires a
successful CAS consume before the OpenBao call.
"""
binding: ConsumeBinding
decision_id: str
expires_at: str
def as_evidence(self) -> dict[str, object]:
return {
"authorization_decision_id": self.decision_id,
"authorization_expires_at": self.expires_at,
"request_digest": self.binding.request_digest,
}
@dataclass(frozen=True)
class ConsumedApproval:
"""Non-secret confirmation that consume succeeded for this request."""
@ -94,6 +119,44 @@ def _request_purpose(entry: Any) -> str:
return ""
def _expected_request(
cfg: Any,
entry: Any,
action: str,
*,
fields: tuple[str, ...] = (),
policy_targets: tuple[str, ...] = (),
auth_targets: tuple[str, ...] = (),
) -> dict[str, Any]:
"""Build the exact CheckRequest both steps must agree on.
Steps 1 and 2 must describe the same proposed action or the digests cannot
correspond, so neither builds its own.
"""
subject_id = str(getattr(cfg, "authorization_subject_id", "") or "")
subject_type = str(getattr(cfg, "authorization_subject_type", "") or "")
if not subject_id or not subject_type:
raise DecisionError(
"authorization join requires SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID "
"and _SUBJECT_TYPE; the PEP must not assert an unnamed subject"
)
purpose = _request_purpose(entry)
if not purpose:
raise DecisionError(
"authorization join requires a declared approval/consumer purpose"
)
return build_action_request(
entry,
action,
subject_id=subject_id,
subject_type=subject_type,
purpose=purpose,
fields=fields,
policy_targets=policy_targets,
auth_targets=auth_targets,
)
def fetch_approval_claim(
*,
base_url: str,
@ -147,18 +210,12 @@ def resolve_consume_binding(
auth_targets: tuple[str, ...] = (),
opener: Callable[..., Any] | None = None,
) -> ConsumeBinding | None:
"""Join the proposed action to a served approval-claim (step 1 of GH-DEC-2026-003).
"""Join the proposed action to a served approval-claim (step 1).
Returns None only when no serving path is configured at all, keeping
production fail-closed exactly as it was before the join existed. Anything
configured-but-wrong raises: a half-configured PEP must not look like an
unconfigured one.
Step 2 (the flex-auth DecisionEnvelope from POST /v1/check) is validated by
``authorization.validate_decision_envelope``. No PDP is reachable for this
consumer yet -- flex-auth runs per-consumer cluster-local pins and
``flex-auth-secrets-engine`` has not been created -- so that call is not
wired here and production stays closed at the stance gate regardless.
"""
base_url = str(getattr(cfg, "approval_url", "") or "")
token_file = getattr(cfg, "approval_token_file", None)
@ -166,29 +223,11 @@ def resolve_consume_binding(
if not base_url or not token_file or not authorization_id:
return None
subject_id = str(getattr(cfg, "authorization_subject_id", "") or "")
subject_type = str(getattr(cfg, "authorization_subject_type", "") or "")
if not subject_id or not subject_type:
raise DecisionError(
"authorization join requires SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID "
"and _SUBJECT_TYPE; the PEP must not assert an unnamed subject"
)
purpose = _request_purpose(entry)
if not purpose:
raise DecisionError(
"authorization join requires a declared approval/consumer purpose"
)
expected_request = build_action_request(
entry,
action,
subject_id=subject_id,
subject_type=subject_type,
purpose=purpose,
fields=fields,
policy_targets=policy_targets,
auth_targets=auth_targets,
expected_request = _expected_request(
cfg, entry, action,
fields=fields, policy_targets=policy_targets, auth_targets=auth_targets,
)
# Two different digests over the same proposed action, by contract; they are
# never compared to each other.
#
@ -215,9 +254,11 @@ def resolve_consume_binding(
approval_id=authorization_id,
expected_pdp_digest=pdp_digest,
)
binding = claim.get("binding") or {}
if isinstance(binding, dict) and binding.get("action") not in (None, action):
raise DecisionError("approval claim does not bind this action")
# No action comparison here. The claim's binding.action is approval-engine
# vocabulary ("secrets.kv.destroy") and ours is the catalog's ("destroy");
# comparing them would fail against every real claim, which is the same
# cross-vocabulary mistake the native digest made. The tie to this exact
# action is pdp_digest, checked above.
return ConsumeBinding(
approval_id=authorization_id,
request_digest=pdp_digest,
@ -225,6 +266,80 @@ def resolve_consume_binding(
)
def authorize_action(
cfg: Any,
entry: Any,
action: str,
decision: Any = None,
*,
fields: tuple[str, ...] = (),
policy_targets: tuple[str, ...] = (),
auth_targets: tuple[str, ...] = (),
opener: Callable[..., Any] | None = None,
pdp_opener: Callable[..., Any] | None = None,
) -> AuthorizedAction | None:
"""Run steps 1 and 2 for one proposed action, or return None if unserved.
Step 1 validates the approval-claim; step 2 obtains and validates the
flex-auth DecisionEnvelope. Returning None means no serving path is
configured at all, which leaves production fail-closed. A configured but
failing path raises: a partial deployment must not read as an absent one.
"""
binding = resolve_consume_binding(
cfg, entry, action, decision,
fields=fields,
policy_targets=policy_targets,
auth_targets=auth_targets,
opener=opener,
)
if binding is None:
return None
pdp_url = str(getattr(cfg, "pdp_url", "") or "")
pdp_token = getattr(cfg, "pdp_token_file", None)
if not pdp_url or not pdp_token:
raise DecisionError(
"production action requires an access-engine decision; "
"SECRETS_ENGINE_PDP_URL / _PDP_TOKEN_FILE are unset"
)
package = str(getattr(cfg, "authorization_policy_package", "") or "")
version = str(getattr(cfg, "authorization_policy_version", "") or "")
if not package or not version:
raise DecisionError(
"authorization join requires an explicitly configured policy "
"package/version pin; the reserved coordinate is a reservation, "
"not a publication, and must not be used as a default"
)
expected_request = _expected_request(
cfg, entry, action,
fields=fields, policy_targets=policy_targets, auth_targets=auth_targets,
)
envelope = check_decision(
base_url=pdp_url,
token_file=Path(pdp_token),
request=expected_request,
opener=pdp_opener or urlopen,
)
validated = validate_decision_envelope(
envelope,
expected_request,
accepted_policy_packages={package},
accepted_policy_versions={version},
)
if validated.action != action:
raise DecisionError("access-engine decision does not bind this action")
return AuthorizedAction(
binding=ConsumeBinding(
approval_id=binding.approval_id,
request_digest=binding.request_digest,
decision_id=validated.decision_id,
),
decision_id=validated.decision_id,
expires_at=validated.expires_at,
)
def consume_approval(
*,
base_url: str,