fix: bind approval consumption to actual Flex Auth submissions
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 08:55:46 +02:00
parent 89bc31460f
commit ee4e901611
23 changed files with 612 additions and 1023 deletions

View file

@ -13,6 +13,7 @@ from __future__ import annotations
import hashlib
import json
import re
from datetime import datetime, timezone
from typing import Any
@ -97,21 +98,13 @@ def _parse_time(value: object, name: str) -> datetime:
return parsed.astimezone(timezone.utc)
def validate_approval_claim(
def _validate_observation(
claim: object,
*,
approval_id: str,
expected_binding_digest: str = "",
expected_pdp_digest: str = "",
now: datetime | None = None,
) -> dict[str, Any]:
"""Run the published consumer checks. Any failure means do not act.
Implements ``approval-claim.md`` "Required verification": issuer, valid_now,
not consumed, binding digest match (native or PDP), freshness, reason_code.
The distinct-approver threshold is folded into ``valid_now`` by the issuer;
the claim does not expose approver entries, so it cannot be re-checked here.
"""
"""Validate issuer, shape, validity and freshness; no action correspondence."""
if not isinstance(claim, dict):
raise DecisionError("approval claim must be an object")
if claim.get("schema_version") != SCHEMA_VERSION:
@ -142,42 +135,6 @@ def validate_approval_claim(
binding = claim.get("binding")
if not isinstance(binding, dict):
raise DecisionError("approval claim binding must be an object")
native = str(binding.get("digest", "") or "")
pdp = str(binding.get("pdp_digest", "") or "")
# Prefer the PDP digest: it is expressed in flex-auth's vocabulary, which is
# the one the caller actually proposed. The native digest is over
# approval-engine's own vocabulary and is only comparable when the caller
# supplies a binding built in that vocabulary (see the note in
# resolve_consume_binding about the missing mapping).
if expected_pdp_digest:
# GH-DEC-2026-008 / approval-engine schema v3: pdp_path is the issuer's
# DECLARATION that this approval was requested against a bound
# CheckRequest, and it guarantees pdp_digest is non-null. Path intent is
# never inferred from a pdp_digest that merely happens to be present --
# a digest recorded for some other reason is not a declaration anybody
# made, and approvals issued before schema v3 carry pdp_path false
# regardless of any digest they hold.
if binding.get("pdp_path") is not True:
raise DecisionError(
"approval claim does not declare binding.pdp_path; it was not "
"issued against a bound CheckRequest and cannot authorize this "
"action (GH-DEC-2026-008). Request an approval bound at issue."
)
if not pdp:
raise DecisionError(
"approval claim declares pdp_path but records no pdp_digest, and "
"no published mapping exists between approval-engine and "
"secrets-engine action/target vocabularies; the claim cannot be "
"tied to this exact action"
)
if pdp != expected_pdp_digest:
raise DecisionError("approval claim pdp digest does not match the request")
elif expected_binding_digest:
if native != expected_binding_digest:
raise DecisionError("approval claim binding digest does not match the request")
else:
raise DecisionError("approval claim comparison requires an expected digest")
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
freshness = claim.get("freshness")
if not isinstance(freshness, dict):
@ -194,3 +151,41 @@ def validate_approval_claim(
if _parse_time(validity.get("not_before"), "validity.not_before") > current:
raise DecisionError("approval claim is not yet valid")
return claim
def observe_pdp_approval_claim(claim: object, *, approval_id: str,
now: datetime | None = None) -> dict[str, Any]:
"""Validate a fresh fact for submission to the PDP, not authority to consume.
Action correspondence cannot be established until the evaluator returns its
enriched approval binding. This function deliberately makes no such claim.
"""
observed = _validate_observation(claim, approval_id=approval_id, now=now)
binding = observed["binding"]
if binding.get("pdp_path") is not True:
raise DecisionError("approval claim does not declare binding.pdp_path; request an approval bound at issue")
pdp = binding.get("pdp_digest")
if not isinstance(pdp, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", pdp):
raise DecisionError("approval claim records no canonical pdp_digest; no published mapping permits a native fallback")
return observed
def validate_approval_claim(claim: object, *, approval_id: str,
expected_binding_digest: str = "", expected_pdp_digest: str = "",
now: datetime | None = None) -> dict[str, Any]:
"""Validate the fact and compare an independently supplied binding.
A PDP digest supplied here must come from the evaluated decision, never a
local reconstruction of the unenriched request.
"""
observed = _validate_observation(claim, approval_id=approval_id, now=now)
if expected_pdp_digest:
observe_pdp_approval_claim(observed, approval_id=approval_id, now=now)
if observed["binding"]["pdp_digest"] != expected_pdp_digest:
raise DecisionError("approval claim pdp digest does not match the request")
elif expected_binding_digest:
if observed["binding"].get("digest") != expected_binding_digest:
raise DecisionError("approval claim binding digest does not match the request")
else:
raise DecisionError("approval claim comparison requires an expected digest")
return observed