fix: exclude correlation fields from the flex-auth request digest
Verified the digest join against flex-auth's T03 replay fixtures and found request_digest was hashing fields docs/canonical-request-digest.md excludes. The material is tenant, subject, action, resource, context only: id is correlation, policy_version lives in provenance, caring_context is hashed separately. This engine included all three when present. Because the join adopts the served request id, every real production request would have carried one, so the computed digest would have matched no issued decision and failed closed against every correct allow. Same unsatisfiable shape as the removed AUTHORITY constant. The old pinned constant was computed with the id inside the material, so it was wrong and its passing proved nothing. Replaced with fixture-driven tests over two real envelopes (vendored with provenance) plus a structural test that correlation fields do not move the digest. Both fixtures are needed: input_claim_digests.context appears only with a non-empty context. Also stops computing the native claim digest. The claim's binding.action and binding.target speak approval-engine's vocabulary while ours speaks the catalog's, and no mapping is published; flex-auth makes no cross-check and states the correspondence is ours via pdp_digest. A claim recording no pdp_digest now fails closed naming the missing mapping rather than comparing two different languages. That mapping is a prerequisite for destroy. 274 tests pass. Production still fails closed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD Assistant: claude-code Assistant-Model: opus Assistant-Process: 393550@bnt-lap001 Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
This commit is contained in:
parent
083bee7333
commit
6e9c15228c
11 changed files with 562 additions and 24 deletions
|
|
@ -144,8 +144,18 @@ def validate_approval_claim(
|
|||
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 when the issuer recorded one at issue time.
|
||||
if expected_pdp_digest and pdp:
|
||||
# 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:
|
||||
if not pdp:
|
||||
raise DecisionError(
|
||||
"approval claim 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:
|
||||
|
|
|
|||
|
|
@ -18,11 +18,7 @@ from typing import Any, Callable
|
|||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from secrets_engine.approval_claim import (
|
||||
binding_from_check_request,
|
||||
claim_binding_digest,
|
||||
validate_approval_claim,
|
||||
)
|
||||
from secrets_engine.approval_claim import validate_approval_claim
|
||||
from secrets_engine.authorization import build_action_request, request_digest
|
||||
from secrets_engine.errors import DecisionError
|
||||
from secrets_engine.openbao import read_strict_token_file
|
||||
|
|
@ -193,10 +189,19 @@ def resolve_consume_binding(
|
|||
policy_targets=policy_targets,
|
||||
auth_targets=auth_targets,
|
||||
)
|
||||
# Two different digests over the same proposed action, by contract: the
|
||||
# approval-engine native binding digest, and the flex-auth CheckRequest
|
||||
# digest. They are not interchangeable and are never compared to each other.
|
||||
native_digest = claim_binding_digest(**binding_from_check_request(expected_request))
|
||||
# Two different digests over the same proposed action, by contract; they are
|
||||
# never compared to each other.
|
||||
#
|
||||
# Only the PDP digest is usable for the action/target correspondence today.
|
||||
# The claim's binding.action and binding.target speak approval-engine's
|
||||
# vocabulary ("secrets.kv.destroy", {"id": ..., "stage": ...}) while ours
|
||||
# speaks the catalog's ("destroy", "catalog:<id>"), and no mapping between
|
||||
# them is published. flex-auth makes no cross-check either and states the
|
||||
# correspondence is ours, via pdp_digest. Computing a native digest from our
|
||||
# own vocabulary would compare two different languages and never match --
|
||||
# the same unsatisfiable-rule defect flex-auth fixed in 68ad039 -- so we do
|
||||
# not compute one, and validate_approval_claim fails closed with a named
|
||||
# reason when the issuer recorded no pdp_digest.
|
||||
pdp_digest = request_digest(expected_request)
|
||||
|
||||
claim = fetch_approval_claim(
|
||||
|
|
@ -208,7 +213,6 @@ def resolve_consume_binding(
|
|||
validate_approval_claim(
|
||||
claim,
|
||||
approval_id=authorization_id,
|
||||
expected_binding_digest=native_digest,
|
||||
expected_pdp_digest=pdp_digest,
|
||||
)
|
||||
binding = claim.get("binding") or {}
|
||||
|
|
|
|||
|
|
@ -156,8 +156,26 @@ def canonical_check_request(request: object) -> dict[str, Any]:
|
|||
return canonical
|
||||
|
||||
|
||||
def request_digest(request: object) -> str:
|
||||
#: Correlation-only or separately-hashed fields, excluded from the digest
|
||||
#: material by docs/canonical-request-digest.md "What is hashed".
|
||||
_UNHASHED_FIELDS = ("id", "policy_version", "caring_context")
|
||||
|
||||
|
||||
def digest_material(request: object) -> dict[str, Any]:
|
||||
"""The exact tuple flex-auth hashes: tenant, subject, action, resource, context.
|
||||
|
||||
``id`` is correlation only, ``policy_version`` is recorded in provenance, and
|
||||
``caring_context`` is hashed separately as
|
||||
``provenance.input_claim_digests.caring_context``. Including any of them
|
||||
produces a digest that matches no real DecisionEnvelope, which fails closed
|
||||
against every correctly issued decision.
|
||||
"""
|
||||
canonical = canonical_check_request(request)
|
||||
return {k: v for k, v in canonical.items() if k not in _UNHASHED_FIELDS}
|
||||
|
||||
|
||||
def request_digest(request: object) -> str:
|
||||
canonical = digest_material(request)
|
||||
encoded = json.dumps(
|
||||
canonical, ensure_ascii=False, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue