feat: bind the destroy gate to approval_binding_digest and pdp_path
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

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
This commit is contained in:
tegwick 2026-09-06 20:39:59 +02:00
parent 67b28f48a8
commit c44306b1b2
11 changed files with 411 additions and 40 deletions

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import hashlib
import json
import re
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
@ -16,6 +17,8 @@ from typing import Any
from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import DecisionError
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
SCHEMA_VERSION = "0.1"
CONTRACT_VERSION = "flex-auth.decision-record.v1"
@ -171,7 +174,13 @@ def digest_material(request: object) -> dict[str, Any]:
against every correctly issued decision.
"""
canonical = canonical_check_request(request)
return {k: v for k, v in canonical.items() if k not in _UNHASHED_FIELDS}
material = {k: v for k, v in canonical.items() if k not in _UNHASHED_FIELDS}
# flex-auth tags Context `json:"context,omitempty"`, which drops the key for
# an empty map as well as a nil one. Keeping an empty object here would hash
# different material than the evaluator did.
if material.get("context") == {}:
del material["context"]
return material
def request_digest(request: object) -> str:
@ -182,6 +191,35 @@ def request_digest(request: object) -> str:
return "sha256:" + hashlib.sha256(encoded).hexdigest()
#: The context key an approval claim travels on, per flex-auth's
#: ``ApprovalContextKey``.
APPROVAL_CONTEXT_KEY = "approval"
def approval_binding_digest(request: object) -> str:
"""flex-auth's ``binding.approval_binding_digest`` for this request.
The same canonical digest with ``context.approval`` removed, so it is stable
across attaching the claim. A request that carries no claim has no separate
binding digest, and flex-auth returns the plain request digest there.
This exists because a ``pdp_digest`` recorded at approval-issue time can
never equal the ``request_digest`` of the request that later carries the
claim: the claim is part of the hashed context. See
``flex-auth/docs/canonical-request-digest.md`` "The approval-binding digest"
and FLEX-DEC-2026-007. It is deliberately NOT a replay identity.
"""
canonical = canonical_check_request(request)
context = canonical.get("context")
if not isinstance(context, dict) or APPROVAL_CONTEXT_KEY not in context:
return request_digest(request)
stripped = dict(canonical)
stripped["context"] = {
k: v for k, v in context.items() if k != APPROVAL_CONTEXT_KEY
}
return request_digest(stripped)
def _require_exact_target_sets(request: dict[str, Any]) -> None:
resource = _required_dict(request, "resource")
attributes = resource.get("attributes", {})
@ -199,12 +237,63 @@ def _require_exact_target_sets(request: dict[str, Any]) -> None:
)
def _check_approval_binding_digest(
binding: dict[str, Any],
expected: dict[str, Any],
expected_digest: str,
) -> None:
"""Tie an approval claim to this exact request (FLEX-DEC-2026-007).
``binding.approval_binding_digest`` is the canonical request digest computed
with ``context.approval`` removed, and it appears only when the request
carried a claim there. It is the ONLY sound comparand for a claim's
``pdp_digest``: a digest recorded at issue time can never equal the
``request_digest`` of the request that carries the claim, because the claim
is part of the hashed material. Comparing against ``request_digest`` fails
closed forever; comparing against nothing fails open.
It is deliberately NOT a replay identity -- two requests differing only in
which approval was presented share it while their decisions differ -- so it
is checked here in addition to ``request_digest``, never instead of it.
When our own request is claim-free the field is absent by contract, and the
identity already holds transitively: step 1 compared the claim's pdp_digest
to this same canonical digest of the claim-free request.
"""
present = binding.get("approval_binding_digest")
if present is not None:
if not isinstance(present, str) or not DIGEST_RE.fullmatch(present):
raise DecisionError("flex-auth approval binding digest is malformed")
if present != approval_binding_digest(expected):
raise DecisionError(
"flex-auth approval binding digest does not match this request "
"with the approval claim removed"
)
if expected_digest:
if present is None:
context = expected.get("context")
if not isinstance(context, dict) or APPROVAL_CONTEXT_KEY not in context:
# Claim-free request: no approval_binding_digest is emitted and
# step 1 already bound the claim to this canonical digest.
return
raise DecisionError(
"request carried an approval claim but the decision records no "
"approval_binding_digest; the claim cannot be tied to it"
)
if present != expected_digest:
raise DecisionError(
"approval claim pdp digest does not match the decision's "
"approval binding digest"
)
def validate_decision_envelope(
envelope: object,
expected_request: object,
*,
accepted_policy_packages: set[str],
accepted_policy_versions: set[str],
expected_approval_binding_digest: str = "",
now: datetime | None = None,
) -> ValidatedDecision:
"""Validate a flex-auth DecisionEnvelope against the proposed action.
@ -264,6 +353,7 @@ def validate_decision_envelope(
raise DecisionError("flex-auth decision binding does not match request")
if binding.get("request_digest") != request_digest(expected):
raise DecisionError("flex-auth request digest does not match request")
_check_approval_binding_digest(binding, expected, expected_approval_binding_digest)
if _subject_ref(envelope.get("subject")) != expected["subject"]:
raise DecisionError("flex-auth decision subject does not match request")
if _resource_ref(envelope.get("resource")) != expected["resource"]: