fix: compare structured binding fields, not a digest we cannot reproduce
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

The live proof in 03c0569 showed validate_decision_envelope rejecting every
real allow. The evaluator normalizes before hashing -- the request tenant is
copied onto subject and resource, and a registry hit copies type, tenant and
selected attributes onto the refs -- so binding.request_digest covers
material we never sent. Byte-equality against our unenriched request was
unsatisfiable, not merely mismatched.

THE RULE WAS ALREADY PUBLISHED. flex-auth's canonical-request-digest.md
section "Normalization" states the enrichment and tells consumers what to do
instead: compare structured binding fields to the proposed action, treat
request_digest as the evaluator's statement of what it hashed, and recompute
independently over the tuple the binding carries. I raised this with them as
an unpublished gap and asked them to pick between three shapes; it was in
their contract already and the answer was the first of the three. Nothing
was blocked on them, and this follows the published rule rather than one I
inferred.

- _require_binding_corresponds: everything we proposed must survive
  unchanged -- tenant, action, context, subject.id/type,
  resource.id/type/system, and every attribute we sent.
- Enrichment may add only type, tenant, attributes. Any other added field is
  refused, and an enriched tenant must be the request tenant, so a
  cross-tenant binding cannot arrive wearing our request's clothes.
- request_digest is still verified, now against binding_tuple(binding) for
  self-consistency rather than against material we never sent.
- The envelope's top-level subject/resource get the same rule; they are
  enriched too.

Proved against the artifact: the real decision:0f9c98f14545c42d now
validates, and the unrefreshed envelope is refused on lifetime -- reaching
the lifetime check at all is the evidence the binding checks pass on a real
decision. Negatives cover a restated resource.attributes.stage, a foreign
subject.tenant, and an unexpected enrichment field.

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-07 09:04:57 +02:00
parent 03c0569820
commit 10baad914e
6 changed files with 283 additions and 62 deletions

View file

@ -321,6 +321,111 @@ def _check_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_
def validate_decision_envelope(
envelope: object,
expected_request: object,
@ -361,37 +466,25 @@ def validate_decision_envelope(
raise DecisionError("flex-auth decision request id does not match request")
binding = _required_dict(envelope, "binding")
bound_request: dict[str, Any] = {}
if binding.get("tenant"):
bound_request["tenant"] = binding["tenant"]
bound_request.update(
{
"subject": binding.get("subject"),
"action": binding.get("action"),
"resource": binding.get("resource"),
"context": binding.get("context", {}),
}
_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"
)
expected_bound: dict[str, Any] = {}
if expected.get("tenant"):
expected_bound["tenant"] = expected["tenant"]
expected_bound.update(
{
"subject": expected["subject"],
"action": expected["action"],
"resource": expected["resource"],
"context": expected.get("context", {}),
}
_require_ref_corresponds(
_resource_ref(envelope.get("resource")), expected["resource"], _tenant, "resource"
)
if canonical_check_request(bound_request) != canonical_check_request(expected_bound):
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"]:
raise DecisionError("flex-auth decision resource does not match request")
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
lifetime = _required_dict(envelope, "lifetime")