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
|
|
@ -135,7 +135,7 @@ def test_state_hub_authority_is_no_longer_required():
|
|||
[
|
||||
(lambda d: d.update(effect="deny"), "effect is not allow"),
|
||||
(lambda d: d.update(effect="audit_only"), "effect is not allow"),
|
||||
(lambda d: d["binding"].update(action="destroy"), "binding does not match"),
|
||||
(lambda d: d["binding"].update(action="destroy"), "binding action does not match"),
|
||||
(lambda d: d["binding"].update(request_digest="sha256:" + "0" * 64),
|
||||
"request digest does not match"),
|
||||
(lambda d: d["provenance"].update(policy_package="other.package"),
|
||||
|
|
@ -144,7 +144,15 @@ def test_state_hub_authority_is_no_longer_required():
|
|||
"policy version is not accepted"),
|
||||
(lambda d: d.update(contract_version="flex-auth.decision-record.v2"),
|
||||
"contract version"),
|
||||
(lambda d: d["subject"].update(id="user:mallory"), "subject does not match"),
|
||||
(lambda d: d["subject"].update(id="user:mallory"),
|
||||
"subject.id does not match"),
|
||||
# Enrichment may add attributes; it may never restate what we sent.
|
||||
(lambda d: d["binding"]["resource"]["attributes"].update(stage="build"),
|
||||
"resource.attributes.stage does not match"),
|
||||
(lambda d: d["binding"]["subject"].update(tenant="tenant:coulomb"),
|
||||
"subject.tenant 'tenant:coulomb' is not the request tenant"),
|
||||
(lambda d: d["binding"]["subject"].update(surprise="x"),
|
||||
"carries unexpected field"),
|
||||
],
|
||||
)
|
||||
def test_invalid_envelopes_fail_closed(mutation, match):
|
||||
|
|
|
|||
|
|
@ -3,21 +3,28 @@
|
|||
tests/test_decision_replay.py passes every digest assertion because
|
||||
``_request_from()`` reconstructs the request out of ``envelope["binding"]`` --
|
||||
which is the *enriched* form the evaluator hashed. That is a self-consistent
|
||||
fake agreeing with itself, and it hid the gap below through three rounds of
|
||||
digest work.
|
||||
fake agreeing with itself, and it hid a real defect through three rounds of
|
||||
digest work: the validator required byte-equality between the binding and our
|
||||
unenriched request, which no real decision can satisfy.
|
||||
|
||||
This module builds the request the way the engine actually builds it and
|
||||
compares it to a decision the engine actually received, so the gap is pinned
|
||||
until flex-auth publishes the enrichment rule.
|
||||
The rule was already published in flex-auth's canonical-request-digest.md
|
||||
("Normalization"): the evaluator copies the request tenant onto subject and
|
||||
resource, a registry hit copies type/tenant/selected attributes onto the refs,
|
||||
and a consumer is told to compare structured binding fields to the proposed
|
||||
action rather than re-hash its own request. This module builds the request the
|
||||
way the engine actually builds it, compares it to a decision the engine actually
|
||||
received, and proves the corrected check accepts it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from secrets_engine.authorization import (
|
||||
binding_tuple,
|
||||
build_action_request,
|
||||
canonical_check_request,
|
||||
request_digest,
|
||||
|
|
@ -105,19 +112,70 @@ def test_our_digest_cannot_match_a_real_binding():
|
|||
assert canonical_check_request(bound) != canonical_check_request(sent)
|
||||
|
||||
|
||||
def test_we_currently_reject_every_real_allow_and_that_is_fail_closed():
|
||||
"""Pins today's behaviour honestly rather than asserting it is correct.
|
||||
def test_the_live_allow_now_validates_under_the_documented_rule():
|
||||
"""The real decision validates -- this is the fix, proved against the artifact.
|
||||
|
||||
Rejecting a valid allow is wrong, but it is wrong in the safe direction, so
|
||||
it stays until flex-auth publishes which fields may be enriched. Guessing
|
||||
the rule would mean accepting a binding that differs from our proposal in
|
||||
some way we decided was benign -- the fail-open shape GH-DEC-2026-008
|
||||
rejected for vocabularies and FLEX-DEC-2026-007 rejected for digests.
|
||||
canonical-request-digest.md "Normalization" is the published rule, and it
|
||||
says a consumer must compare structured binding fields to the proposed
|
||||
action rather than re-hash its own request. Under that rule this envelope,
|
||||
which our previous byte-equality check rejected outright, is accepted.
|
||||
|
||||
Only the lifetime is refreshed: the decision was issued on 2026-09-07 with a
|
||||
bounded lifetime, and pinning a clock-dependent field is what the fixture
|
||||
provenance forbids.
|
||||
"""
|
||||
with pytest.raises(DecisionError, match="binding does not match request"):
|
||||
env = _live()
|
||||
now = datetime.now(timezone.utc)
|
||||
env["lifetime"] = {
|
||||
"kind": "bounded",
|
||||
"not_before": (now - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"expires_at": (now + timedelta(minutes=10)).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
}
|
||||
result = validate_decision_envelope(
|
||||
env,
|
||||
_our_request(),
|
||||
accepted_policy_packages={PACKAGE},
|
||||
accepted_policy_versions={VERSION},
|
||||
)
|
||||
assert result.action == "rotate"
|
||||
assert result.subject_id == "secrets-engine"
|
||||
assert result.decision_id == "decision:0f9c98f14545c42d"
|
||||
|
||||
|
||||
def test_the_unrefreshed_live_decision_is_refused_on_lifetime():
|
||||
"""It is a real expired allow, so it must be refused -- and for that reason.
|
||||
|
||||
Reaching the lifetime check at all is the evidence that every binding check
|
||||
before it now passes against a real decision.
|
||||
"""
|
||||
with pytest.raises(DecisionError, match="lifetime has expired"):
|
||||
validate_decision_envelope(
|
||||
_live(),
|
||||
_our_request(),
|
||||
accepted_policy_packages={PACKAGE},
|
||||
accepted_policy_versions={VERSION},
|
||||
)
|
||||
|
||||
|
||||
def test_the_digest_is_verified_against_the_binding_it_carries():
|
||||
"""Independent recomputation, per the contract's own instruction.
|
||||
|
||||
"To recompute independently, hash the same normalized tuple the binding
|
||||
carries." So the digest is still checked -- it is simply checked for
|
||||
self-consistency rather than against material we never sent.
|
||||
"""
|
||||
binding = _live()["binding"]
|
||||
assert request_digest(binding_tuple(binding)) == binding["request_digest"]
|
||||
|
||||
|
||||
def test_a_tampered_binding_still_fails_the_digest():
|
||||
"""Self-consistency is a real check, not a formality."""
|
||||
env = _live()
|
||||
env["binding"]["resource"]["attributes"]["stage"] = "build"
|
||||
with pytest.raises(DecisionError, match="does not match"):
|
||||
validate_decision_envelope(
|
||||
env,
|
||||
_our_request(),
|
||||
accepted_policy_packages={PACKAGE},
|
||||
accepted_policy_versions={VERSION},
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue