fix: compare structured binding fields, not a digest we cannot reproduce
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:
parent
03c0569820
commit
10baad914e
6 changed files with 283 additions and 62 deletions
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue