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

@ -1,8 +1,8 @@
"""Fail-closed consumer validation for flex-auth action authorizations.
The canonical contract is flex-auth revision c473f19. State Hub does not yet
provide the durable authoritative endpoint, so this module validates supplied
objects but does not resolve or enable production actions by itself.
Replay identity follows FLEX-DEC-2026-012. The authenticated evaluator owns
registry enrichment; the consumer binds its exact submitted request and checks
policy, lifetime and approval correspondence before consumption.
"""
from __future__ import annotations
@ -50,6 +50,8 @@ class ValidatedDecision:
action: str
subject_id: str
expires_at: str
request_digest: str
submitted_request_digest: str
def build_action_request(
@ -272,158 +274,30 @@ 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,
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.
"""
"""Compare evaluator-origin values; never reproduce registry enrichment."""
context = expected.get("context", {})
carried = APPROVAL_CONTEXT_KEY in context
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"
)
#: Fields the evaluator's documented normalization may add to a ref, per
#: canonical-request-digest.md "Normalization": the request tenant is copied onto
#: subject and resource when they omit it, and a registry hit copies type,
#: tenant, and selected attributes onto the refs the digest sees.
_ENRICHABLE_REF_FIELDS = ("type", "tenant", "attributes")
def _require_ref_corresponds(bound: Any, sent: dict[str, Any], tenant: str, name: str) -> None:
"""The bound ref must be our ref, plus only documented enrichment."""
if not isinstance(bound, dict):
raise DecisionError(f"flex-auth decision binding {name} must be an object")
for key, value in sent.items():
if key == "attributes":
continue
if bound.get(key) != value:
raise DecisionError(
f"flex-auth decision binding {name}.{key} does not match the "
"proposed action"
)
for key in bound:
if key not in sent and key not in _ENRICHABLE_REF_FIELDS:
raise DecisionError(
f"flex-auth decision binding {name} carries unexpected field "
f"'{key}'; enrichment may add only {_ENRICHABLE_REF_FIELDS}"
)
# Normalization rule 1: an enriched tenant is the REQUEST's tenant. A ref
# bearing some other tenant would be a cross-tenant binding wearing our
# request's clothes.
bound_tenant = bound.get("tenant")
if bound_tenant is not None and tenant and bound_tenant != tenant:
raise DecisionError(
f"flex-auth decision binding {name}.tenant '{bound_tenant}' is not "
f"the request tenant '{tenant}'"
)
sent_attributes = sent.get("attributes")
if isinstance(sent_attributes, dict):
bound_attributes = bound.get("attributes")
if not isinstance(bound_attributes, dict):
raise DecisionError(
f"flex-auth decision binding {name}.attributes must be an object"
)
# Every attribute we proposed must survive unchanged. The registry may
# add its own; it may not restate ours differently. stage, fields,
# policy_targets and auth_targets are security-bearing and are ours.
for key, value in sent_attributes.items():
if bound_attributes.get(key) != value:
raise DecisionError(
f"flex-auth decision binding {name}.attributes.{key} does "
"not match the proposed action"
)
def _require_binding_corresponds(
binding: dict[str, Any], expected: dict[str, Any]
) -> None:
"""Compare structured binding fields to the proposed action.
This is what canonical-request-digest.md tells a consumer to do, and why it
is not a digest comparison: "A consumer that re-hashes the original
unenriched request will not match a decision that turned on registry
attributes. Compare structured binding fields to the proposed action, and
treat request_digest as the evaluator's statement of what it hashed."
The evaluator normalizes before hashing -- it copies the request tenant onto
subject and resource, and a registry hit copies type, tenant and selected
attributes onto the refs. Those additions are material we never sent and
cannot reproduce, so byte-equality against our own request is unsatisfiable
against every real decision.
What must still hold, and is enforced here: everything we DID propose
survives unchanged, enrichment appears only where the contract permits it,
and an enriched tenant is our request's tenant rather than another one.
"""
tenant = str(expected.get("tenant", "") or "")
if tenant and binding.get("tenant") != tenant:
raise DecisionError("flex-auth decision binding tenant does not match request")
if binding.get("action") != expected["action"]:
raise DecisionError("flex-auth decision binding action does not match request")
if binding.get("context", {}) != expected.get("context", {}):
raise DecisionError("flex-auth decision binding context does not match request")
_require_ref_corresponds(binding.get("subject"), expected["subject"], tenant, "subject")
_require_ref_corresponds(binding.get("resource"), expected["resource"], tenant, "resource")
def binding_tuple(binding: dict[str, Any]) -> dict[str, Any]:
"""The normalized tuple the binding carries, for independent re-hashing.
canonical-request-digest.md: "To recompute independently, hash the same
normalized tuple the binding carries (tenant, subject, action, resource,
context)."
"""
tuple_: dict[str, Any] = {}
if binding.get("tenant"):
tuple_["tenant"] = binding["tenant"]
tuple_.update(
{
"subject": binding.get("subject"),
"action": binding.get("action"),
"resource": binding.get("resource"),
"context": binding.get("context", {}),
}
)
return tuple_
if not carried:
if present is not None or expected_digest:
raise DecisionError("approval binding requires a carried approval claim")
return
if present is None:
raise DecisionError("request carried an approval claim but the decision records no approval_binding_digest")
if not isinstance(present, str) or not DIGEST_RE.fullmatch(present):
raise DecisionError("flex-auth approval binding digest is malformed")
claim = context[APPROVAL_CONTEXT_KEY]
claim_binding = claim.get("binding", {}) if isinstance(claim, dict) else {}
if not isinstance(claim_binding, dict):
raise DecisionError("carried approval claim binding must be an object")
recorded = claim_binding.get("pdp_digest")
if (claim_binding.get("pdp_path") is not True
or not isinstance(recorded, str) or not DIGEST_RE.fullmatch(recorded)):
raise DecisionError("carried approval claim requires a canonical pdp_path binding")
if present != recorded or (expected_digest and present != expected_digest):
raise DecisionError("approval claim pdp digest does not match the decision's approval binding digest")
def validate_decision_envelope(
@ -466,25 +340,15 @@ def validate_decision_envelope(
raise DecisionError("flex-auth decision request id does not match request")
binding = _required_dict(envelope, "binding")
_require_binding_corresponds(binding, expected)
# The digest is the evaluator's statement of what it hashed, so it is
# verified for self-consistency against the tuple the binding carries --
# never against our unenriched request, which no real decision can match.
bound_tuple = binding_tuple(binding)
if binding.get("request_digest") != request_digest(bound_tuple):
raise DecisionError(
"flex-auth request digest does not match the binding it carries"
)
_check_approval_binding_digest(binding, bound_tuple, expected_approval_binding_digest)
# The envelope's top-level refs are enriched too, so they get the same
# correspondence rule rather than byte-equality against what we sent.
_tenant = str(expected.get("tenant", "") or "")
_require_ref_corresponds(
_subject_ref(envelope.get("subject")), expected["subject"], _tenant, "subject"
)
_require_ref_corresponds(
_resource_ref(envelope.get("resource")), expected["resource"], _tenant, "resource"
)
submitted = binding.get("submitted_request_digest")
if submitted != request_digest(expected):
raise DecisionError("flex-auth submitted request digest is missing or does not match the proposed action")
# Keep the evaluator's enriched digest as audit/consume evidence. It is not
# locally computable, and registry facts may override caller attributes.
evaluated = binding.get("request_digest")
if not isinstance(evaluated, str) or not DIGEST_RE.fullmatch(evaluated):
raise DecisionError("flex-auth evaluated request digest is malformed")
_check_approval_binding_digest(binding, expected, expected_approval_binding_digest)
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
lifetime = _required_dict(envelope, "lifetime")
@ -506,4 +370,6 @@ def validate_decision_envelope(
action=expected["action"],
subject_id=expected["subject"]["id"],
expires_at=expires.isoformat(),
request_digest=evaluated,
submitted_request_digest=submitted,
)