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
This commit is contained in:
parent
67b28f48a8
commit
c44306b1b2
11 changed files with 411 additions and 40 deletions
|
|
@ -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"]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue