feat: bind the destroy gate to approval_binding_digest and pdp_path
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

The vocabulary mapping this path was waiting on is not coming: gate-house
rejected it in GH-DEC-2026-008, because a translation can be confidently
wrong and fails open by accepting a claim approved for a different action.
The stronger option arrived instead, and both halves are enforced here.

flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) to
fix the circularity this repo reported: a pdp_digest recorded at issue time
can never equal the request_digest of the request that carries the claim in
its hashed context, so with GH-DEC-2026-008 requiring that equality, destroy
would have failed closed forever on a check no correct record could pass.

- authorization.approval_binding_digest implements the published exclusion
  rule, including Go's context,omitempty behaviour when stripping empties
  the context; digest_material drops an empty context for the same reason.
- validate_decision_envelope recomputes the field rather than trusting it,
  refuses a claim-bearing request whose decision records none, and compares
  the claim's digest from step 1 against it -- never against request_digest,
  which still covers the claim so it stays a sound replay identity.
- validate_approval_claim requires binding.pdp_path true before using
  pdp_digest at all. Path intent is never inferred from a digest that
  happens to be present; pre-schema-v3 approvals carry pdp_path false
  regardless of any digest they hold.

Replay fixtures re-vendored from dd3ce4c. The destroy pins moved a second
and final time; approval_binding_digest did not, which is the point. The
fixture now demonstrates the property instead of asserting it: we rederive
fa07becf... from its own request through our canonical implementation,
proving we hash the same material flex-auth does rather than pinning a
constant we cannot reproduce.

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-06 20:39:59 +02:00
parent 67b28f48a8
commit c44306b1b2
11 changed files with 411 additions and 40 deletions

View file

@ -43,16 +43,62 @@ never compared to each other:
of `{action, actor, principal, purpose, target}`. of `{action, actor, principal, purpose, target}`.
- `decision.binding.request_digest` — flex-auth canonical CheckRequest digest. - `decision.binding.request_digest` — flex-auth canonical CheckRequest digest.
`claim.binding.pdp_digest` is the **only** comparison usable today, and its `claim.binding.pdp_digest` is the **only** comparison usable, and its absence
absence fails the action closed. The claim's `binding.action` and fails the action closed. The claim's `binding.action` and `binding.target` speak
`binding.target` speak approval-engine's vocabulary (`secrets.kv.destroy`, approval-engine's vocabulary (`secrets.kv.destroy`, `{"id": ..., "stage": ...}`)
`{"id": ..., "stage": ...}`) while ours speaks the catalog's (`destroy`, while ours speaks the catalog's (`destroy`, `catalog:<id>`). No mapping between
`catalog:<id>`), and no mapping between them is published. flex-auth makes no them exists and none is coming: gate-house rejected one outright in
cross-check either and states the correspondence is ours. Computing a native `GH-DEC-2026-008`, because a translation can be confidently wrong and fails
digest from our own vocabulary would compare two different languages and never **open** by silently accepting a claim approved for a different action. An
match, so this engine does not compute one. Closing that gap needs a published identity check cannot. So this engine computes no native digest.
mapping co-authored by approval-engine and flex-auth; it is a prerequisite for
`SECRETS-WP-0007-T04` making `destroy` reachable. ### The two gates on the pdp path
**1. `binding.pdp_path` must be `true`.** This is approval-engine's declaration
(schema v3) that the approval was requested against a bound CheckRequest. Their
`create()` refuses `pdp_path` true without a `pdp_digest`, so the declaration
*guarantees* the digest. The converse does not hold, and we must not infer it: a
digest recorded for some other reason is no declaration anybody made, and every
approval issued before schema v3 carries `pdp_path` false regardless of any
digest it holds.
The cost is real and is ours to carry — an approval requested without a bound
CheckRequest is not usable here and never becomes usable later. `GH-DEC-2026-008`
holds that correct: an approval granted against an unspecified action does not
become an approval for a specific one because a consumer later found a use for
it. If a real destroy workflow cannot bind at issue, that is the falsifier
gate-house wrote into the reversal, and it should be **raised**, not worked
around.
**2. The digest identity, against the right comparand.**
```text
claim.binding.pdp_digest == decision.binding.approval_binding_digest correct
claim.binding.pdp_digest == decision.binding.request_digest can never pass
```
`pdp_digest` is recorded at *issue* time, and issue precedes the decision. A
request that carries the claim inside its hashed `context` therefore has a
different `request_digest` by construction — the claim is part of the material
being hashed. This engine found that circularity against the T03 replay fixture;
flex-auth fixed it in `FLEX-DEC-2026-007` by publishing
`binding.approval_binding_digest`, the same canonical digest with
`context.approval` removed, which is stable across attaching the claim.
`approval_binding_digest` is **not** a replay identity. `request_digest` still
covers the claim and still moves when it changes, because two requests differing
only in which approval was presented must not share a replay identity — one
allows, the other denies `dual_control_required`. Collapsing them would let an
allow obtained with a valid claim be replayed against a request carrying none.
Our own CheckRequest is claim-free today (`context` is `{"purpose": ...}`), so
flex-auth emits no `approval_binding_digest` for it and the identity holds
transitively: step 1 compares the claim's `pdp_digest` to the canonical digest of
the exact claim-free request we are about to send, and step 2 confirms the
decision's `request_digest` is that same value. `approval_binding_digest(request)`
implements the published exclusion rule so that the check is already correct if
we ever carry the claim in context; when the field is present it is recomputed
and never taken on faith.
### What is hashed in the flex-auth digest ### What is hashed in the flex-auth digest

View file

@ -150,11 +150,25 @@ def validate_approval_claim(
# supplies a binding built in that vocabulary (see the note in # supplies a binding built in that vocabulary (see the note in
# resolve_consume_binding about the missing mapping). # resolve_consume_binding about the missing mapping).
if expected_pdp_digest: if expected_pdp_digest:
# GH-DEC-2026-008 / approval-engine schema v3: pdp_path is the issuer's
# DECLARATION that this approval was requested against a bound
# CheckRequest, and it guarantees pdp_digest is non-null. Path intent is
# never inferred from a pdp_digest that merely happens to be present --
# a digest recorded for some other reason is not a declaration anybody
# made, and approvals issued before schema v3 carry pdp_path false
# regardless of any digest they hold.
if binding.get("pdp_path") is not True:
raise DecisionError(
"approval claim does not declare binding.pdp_path; it was not "
"issued against a bound CheckRequest and cannot authorize this "
"action (GH-DEC-2026-008). Request an approval bound at issue."
)
if not pdp: if not pdp:
raise DecisionError( raise DecisionError(
"approval claim records no pdp_digest, and no published mapping " "approval claim declares pdp_path but records no pdp_digest, and "
"exists between approval-engine and secrets-engine action/target " "no published mapping exists between approval-engine and "
"vocabularies; the claim cannot be tied to this exact action" "secrets-engine action/target vocabularies; the claim cannot be "
"tied to this exact action"
) )
if pdp != expected_pdp_digest: if pdp != expected_pdp_digest:
raise DecisionError("approval claim pdp digest does not match the request") raise DecisionError("approval claim pdp digest does not match the request")

View file

@ -326,6 +326,10 @@ def authorize_action(
expected_request, expected_request,
accepted_policy_packages={package}, accepted_policy_packages={package},
accepted_policy_versions={version}, accepted_policy_versions={version},
# The claim's pdp_digest, established in step 1. If this request carried
# the claim in context, the decision must name the same claim-free
# envelope in binding.approval_binding_digest (FLEX-DEC-2026-007).
expected_approval_binding_digest=binding.request_digest,
) )
if validated.action != action: if validated.action != action:
raise DecisionError("access-engine decision does not bind this action") raise DecisionError("access-engine decision does not bind this action")

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
@ -16,6 +17,8 @@ from typing import Any
from secrets_engine.catalog import CatalogEntry from secrets_engine.catalog import CatalogEntry
from secrets_engine.errors import DecisionError from secrets_engine.errors import DecisionError
DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
SCHEMA_VERSION = "0.1" SCHEMA_VERSION = "0.1"
CONTRACT_VERSION = "flex-auth.decision-record.v1" CONTRACT_VERSION = "flex-auth.decision-record.v1"
@ -171,7 +174,13 @@ def digest_material(request: object) -> dict[str, Any]:
against every correctly issued decision. against every correctly issued decision.
""" """
canonical = canonical_check_request(request) canonical = canonical_check_request(request)
return {k: v for k, v in canonical.items() if k not in _UNHASHED_FIELDS} material = {k: v for k, v in canonical.items() if k not in _UNHASHED_FIELDS}
# flex-auth tags Context `json:"context,omitempty"`, which drops the key for
# an empty map as well as a nil one. Keeping an empty object here would hash
# different material than the evaluator did.
if material.get("context") == {}:
del material["context"]
return material
def request_digest(request: object) -> str: def request_digest(request: object) -> str:
@ -182,6 +191,35 @@ def request_digest(request: object) -> str:
return "sha256:" + hashlib.sha256(encoded).hexdigest() return "sha256:" + hashlib.sha256(encoded).hexdigest()
#: The context key an approval claim travels on, per flex-auth's
#: ``ApprovalContextKey``.
APPROVAL_CONTEXT_KEY = "approval"
def approval_binding_digest(request: object) -> str:
"""flex-auth's ``binding.approval_binding_digest`` for this request.
The same canonical digest with ``context.approval`` removed, so it is stable
across attaching the claim. A request that carries no claim has no separate
binding digest, and flex-auth returns the plain request digest there.
This exists because a ``pdp_digest`` recorded at approval-issue time can
never equal the ``request_digest`` of the request that later carries the
claim: the claim is part of the hashed context. See
``flex-auth/docs/canonical-request-digest.md`` "The approval-binding digest"
and FLEX-DEC-2026-007. It is deliberately NOT a replay identity.
"""
canonical = canonical_check_request(request)
context = canonical.get("context")
if not isinstance(context, dict) or APPROVAL_CONTEXT_KEY not in context:
return request_digest(request)
stripped = dict(canonical)
stripped["context"] = {
k: v for k, v in context.items() if k != APPROVAL_CONTEXT_KEY
}
return request_digest(stripped)
def _require_exact_target_sets(request: dict[str, Any]) -> None: def _require_exact_target_sets(request: dict[str, Any]) -> None:
resource = _required_dict(request, "resource") resource = _required_dict(request, "resource")
attributes = resource.get("attributes", {}) attributes = resource.get("attributes", {})
@ -199,12 +237,63 @@ 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,
) -> 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.
"""
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"
)
def validate_decision_envelope( def validate_decision_envelope(
envelope: object, envelope: object,
expected_request: object, expected_request: object,
*, *,
accepted_policy_packages: set[str], accepted_policy_packages: set[str],
accepted_policy_versions: set[str], accepted_policy_versions: set[str],
expected_approval_binding_digest: str = "",
now: datetime | None = None, now: datetime | None = None,
) -> ValidatedDecision: ) -> ValidatedDecision:
"""Validate a flex-auth DecisionEnvelope against the proposed action. """Validate a flex-auth DecisionEnvelope against the proposed action.
@ -264,6 +353,7 @@ def validate_decision_envelope(
raise DecisionError("flex-auth decision binding does not match request") raise DecisionError("flex-auth decision binding does not match request")
if binding.get("request_digest") != request_digest(expected): if binding.get("request_digest") != request_digest(expected):
raise DecisionError("flex-auth request digest does not match request") 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"]: if _subject_ref(envelope.get("subject")) != expected["subject"]:
raise DecisionError("flex-auth decision subject does not match request") raise DecisionError("flex-auth decision subject does not match request")
if _resource_ref(envelope.get("resource")) != expected["resource"]: if _resource_ref(envelope.get("resource")) != expected["resource"]:

View file

@ -40,6 +40,9 @@ class AuthorizationStub:
self.claim_valid_now = True self.claim_valid_now = True
self.claim_reason_code = "ok" self.claim_reason_code = "ok"
self.include_pdp_digest = True self.include_pdp_digest = True
#: approval-engine schema v3 declares whether the approval was
#: requested against a bound CheckRequest; false is the pre-v3 shape
self.claim_pdp_path = True
self.effect = "allow" self.effect = "allow"
self.consume_status = 200 self.consume_status = 200
self._pdp_digest = "" self._pdp_digest = ""
@ -118,6 +121,7 @@ class AuthorizationStub:
"target": {"id": "lane-under-test", "stage": "prod"}, "target": {"id": "lane-under-test", "stage": "prod"},
"digest": "sha256:" + "3" * 64, "digest": "sha256:" + "3" * 64,
} }
binding["pdp_path"] = self.claim_pdp_path
if self.include_pdp_digest: if self.include_pdp_digest:
binding["pdp_digest"] = self._pdp_digest binding["pdp_digest"] = self._pdp_digest
return { return {

View file

@ -8,16 +8,29 @@ published `secrets-engine.catalog-lane.lifecycle` v1 package via
Vendored so the digest contract test is hermetic. Regenerate upstream and Vendored so the digest contract test is hermetic. Regenerate upstream and
re-copy if the contract version changes. re-copy if the contract version changes.
Re-copied 2026-09-06 after flex-auth regenerated `decision_destroy_dual_control` Re-copied 2026-09-06 (twice, both upstream regenerations):
to carry a **complete** approval-claim on `context.approval` (including the
now-required `binding.pdp_digest`). Because `context` is part of the digest 1. commit `9f3e7e3` completed the approval-claim on `context.approval`. Because
material, completing the claim changed both the request digest and the context `context` is hashed material, completing the claim moved the request digest.
input-claim digest. The pins below are the post-regeneration values. 2. commit `dd3ce4c` (`FLEX-DEC-2026-007`) published
`binding.approval_binding_digest` and set the embedded claim's
`binding.pdp_digest` to it with `binding.pdp_path` true. The request digest
moved once more with the claim's contents; the approval-binding digest did
**not**, which is the property the fixture now demonstrates rather than
asserts.
`decision_rotate.json` is unchanged and carries no `approval_binding_digest`
the field is omitted on claim-free decisions rather than duplicated onto them.
**Pinned here** (stable across runs, per the upstream README): **Pinned here** (stable across runs, per the upstream README):
`binding.request_digest`, `provenance.policy_package_digest`, `binding.request_digest`, `binding.approval_binding_digest`,
`provenance.registry_snapshot_digest`, and the presence/absence of `provenance.policy_package_digest`, `provenance.registry_snapshot_digest`, and
`provenance.input_claim_digests.context`. the presence/absence of `provenance.input_claim_digests.context`.
`approval_binding_digest` is not only pinned but **rederived** by
`test_pdp_digest_equals_the_published_approval_binding_digest`: our canonical
implementation must reproduce it from the fixture's own request. A pin asserts
the constant; rederiving it proves we hash the same material flex-auth does.
**Never pin:** `id`, `provenance.decision_time`, `lifetime.not_before`, **Never pin:** `id`, `provenance.decision_time`, `lifetime.not_before`,
`lifetime.expires_at` — all move with the clock. `lifetime.expires_at` — all move with the clock.

View file

@ -1,5 +1,5 @@
{ {
"id": "decision:395efe37c5066e8a", "id": "decision:44a40c339a020772",
"contract_version": "flex-auth.decision-record.v1", "contract_version": "flex-auth.decision-record.v1",
"request_id": "check:secrets-engine-destroy", "request_id": "check:secrets-engine-destroy",
"effect": "allow", "effect": "allow",
@ -72,7 +72,8 @@
"action": "secrets.kv.destroy", "action": "secrets.kv.destroy",
"actor": "agt-secrets-engine", "actor": "agt-secrets-engine",
"digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f", "digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
"pdp_digest": "sha256:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56", "pdp_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56",
"pdp_path": true,
"principal": "bernd", "principal": "bernd",
"purpose": "rotate-exposed-key", "purpose": "rotate-exposed-key",
"target": { "target": {
@ -98,13 +99,14 @@
} }
} }
}, },
"request_digest": "sha256:fc155dba88f8ab18b3032ed086ba2f455158c6981106e7829d520ab7b036bdf3" "request_digest": "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a",
"approval_binding_digest": "sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56"
}, },
"lifetime": { "lifetime": {
"kind": "ttl", "kind": "ttl",
"ttl": "15m", "ttl": "15m",
"not_before": "2026-09-06T12:18:21Z", "not_before": "2026-09-06T12:51:16Z",
"expires_at": "2026-09-06T12:33:21Z" "expires_at": "2026-09-06T13:06:16Z"
}, },
"diagnostics": { "diagnostics": {
"action": "destroy", "action": "destroy",
@ -122,9 +124,9 @@
"policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c", "policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c",
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb", "registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
"input_claim_digests": { "input_claim_digests": {
"context": "sha256:b0d2203cd2b43a9ba573c21c038154c131afba4255e313affd7ecf810e2cc221" "context": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800"
}, },
"decision_time": "2026-09-06T12:18:21Z" "decision_time": "2026-09-06T12:51:16Z"
}, },
"caring": { "caring": {
"profile": "caring-0.4.0-rc2", "profile": "caring-0.4.0-rc2",

View file

@ -122,6 +122,7 @@ def test_principal_falls_back_to_actor_when_absent():
def test_pdp_digest_is_preferred_when_the_issuer_recorded_one(): def test_pdp_digest_is_preferred_when_the_issuer_recorded_one():
claim = _claim() claim = _claim()
claim["binding"]["pdp_digest"] = "sha256:" + "a" * 64 claim["binding"]["pdp_digest"] = "sha256:" + "a" * 64
claim["binding"]["pdp_path"] = True
claim["binding"]["digest"] = "sha256:" + "b" * 64 # native no longer matches claim["binding"]["digest"] = "sha256:" + "b" * 64 # native no longer matches
assert _validate(claim, expected_pdp_digest="sha256:" + "a" * 64) assert _validate(claim, expected_pdp_digest="sha256:" + "a" * 64)

View file

@ -84,6 +84,7 @@ def _served(entry=None, action="deactivate", fields=("api_token",), **over):
# comparison usable today: the native digest speaks # comparison usable today: the native digest speaks
# approval-engine's vocabulary and no mapping to ours is published. # approval-engine's vocabulary and no mapping to ours is published.
"pdp_digest": request_digest(request), "pdp_digest": request_digest(request),
"pdp_path": True,
}, },
"freshness": { "freshness": {
"observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "observed_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
@ -191,6 +192,30 @@ def test_superseded_claim_fails_closed(tmp_path):
_resolve(_Cfg(_token(tmp_path)), _entry(), _served(valid_now=False, reason_code="superseded")) _resolve(_Cfg(_token(tmp_path)), _entry(), _served(valid_now=False, reason_code="superseded"))
def test_claim_without_pdp_path_fails_closed(tmp_path):
"""pdp_path is a declaration, and it is never inferred from a digest.
approval-engine schema v3 refuses to issue pdp_path true without a
pdp_digest, so a true declaration guarantees the digest. The converse does
not hold: a digest recorded for some other reason is not a statement that
this approval was requested against a bound CheckRequest, and approvals
issued before v3 carry pdp_path false regardless of any digest they hold
(GH-DEC-2026-008).
"""
claim = _served()
claim["binding"]["pdp_path"] = False
with pytest.raises(DecisionError, match="does not declare binding.pdp_path"):
_resolve(_Cfg(_token(tmp_path)), _entry(), claim)
def test_claim_omitting_pdp_path_fails_closed(tmp_path):
"""An absent declaration is not a true one."""
claim = _served()
claim["binding"].pop("pdp_path")
with pytest.raises(DecisionError, match="does not declare binding.pdp_path"):
_resolve(_Cfg(_token(tmp_path)), _entry(), claim)
def test_claim_without_pdp_digest_fails_closed_naming_the_missing_mapping(tmp_path): def test_claim_without_pdp_digest_fails_closed_naming_the_missing_mapping(tmp_path):
"""No published vocabulary mapping means the claim cannot be tied to this action. """No published vocabulary mapping means the claim cannot be tied to this action.

View file

@ -17,6 +17,7 @@ from pathlib import Path
import pytest import pytest
from secrets_engine.authorization import ( from secrets_engine.authorization import (
approval_binding_digest,
digest_material, digest_material,
request_digest, request_digest,
validate_decision_envelope, validate_decision_envelope,
@ -37,9 +38,9 @@ CASES = {
}, },
"destroy": { "destroy": {
"file": "decision_destroy_dual_control.json", "file": "decision_destroy_dual_control.json",
"digest": "sha256:fc155dba88f8ab18b3032ed086ba2f455158c6981106e7829d520ab7b036bdf3", "digest": "sha256:c749ee2dc3cdf927a70a3e5b27cff4d97a438d3264153b4b2e3bcacbaf82091a",
"action": "destroy", "action": "destroy",
"context_claim_digest": "sha256:b0d2203cd2b43a9ba573c21c038154c131afba4255e313affd7ecf810e2cc221", "context_claim_digest": "sha256:8b73d29ecef286d42e03d2420531d6c45219f325a7ae004c1ecfc781203a2800",
}, },
} }
@ -180,14 +181,19 @@ def test_embedded_claim_uses_approval_engine_vocabulary_not_ours():
assert claim["binding"]["target"]["id"] != envelope["resource"]["id"] assert claim["binding"]["target"]["id"] != envelope["resource"]["id"]
APPROVAL_BINDING_DIGEST = (
"sha256:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56"
)
def test_embedded_claim_pdp_digest_cannot_equal_the_carrying_request_digest(): def test_embedded_claim_pdp_digest_cannot_equal_the_carrying_request_digest():
"""Carrying the claim inside a hashed context makes the two unequal. """Carrying the claim inside a hashed context makes the two unequal.
context is part of the digest material, so embedding an approval-claim context is part of the digest material, so embedding an approval-claim
changes the request digest of the very request that carries it. A changes the request digest of the very request that carries it. A
pdp_digest recorded at issue time therefore cannot equal the final digest pdp_digest recorded at issue time therefore cannot equal the final digest
of the dual-control request. Raised with approval-engine and flex-auth; this of the dual-control request. This is why comparing pdp_digest against
test records the property so a future change is visible rather than silent. request_digest can never pass and would fail destroy closed forever.
""" """
envelope = _envelope("destroy") envelope = _envelope("destroy")
binding = envelope["binding"] binding = envelope["binding"]
@ -195,8 +201,112 @@ def test_embedded_claim_pdp_digest_cannot_equal_the_carrying_request_digest():
request = _request_from(envelope) request = _request_from(envelope)
assert request_digest(request) == binding["request_digest"] assert request_digest(request) == binding["request_digest"]
assert pdp != binding["request_digest"] assert pdp != binding["request_digest"]
without_claim = dict(request)
without_claim["context"] = {
k: v for k, v in binding["context"].items() if k != "approval" def test_pdp_digest_equals_the_published_approval_binding_digest():
} """FLEX-DEC-2026-007 closed the circularity, and we reproduce the value.
assert pdp != request_digest(without_claim)
``approval_binding_digest`` is the canonical digest with context.approval
removed. Recomputing it here from our own canonical implementation is the
hermetic proof that this engine hashes the same material flex-auth does --
a pin alone would only assert the constant, not that we can derive it.
"""
envelope = _envelope("destroy")
binding = envelope["binding"]
pdp = binding["context"]["approval"]["binding"]["pdp_digest"]
assert binding["approval_binding_digest"] == APPROVAL_BINDING_DIGEST
assert pdp == APPROVAL_BINDING_DIGEST
assert approval_binding_digest(_request_from(envelope)) == APPROVAL_BINDING_DIGEST
def test_approval_binding_digest_is_not_a_replay_identity():
"""It must not collapse into request_digest, or an allow becomes replayable.
Two requests differing only in which approval was presented share an
approval_binding_digest while their decisions differ -- one allows, one
denies dual_control_required. The fixture asserts the two digests disagree
on a claim-bearing request so the distinction stays real.
"""
envelope = _envelope("destroy")
binding = envelope["binding"]
assert binding["approval_binding_digest"] != binding["request_digest"]
def test_ordinary_decision_carries_no_approval_binding_digest():
"""The field is omitted, not duplicated, on a claim-free decision."""
binding = _envelope("rotate")["binding"]
assert "approval" not in binding.get("context", {})
assert "approval_binding_digest" not in binding
def test_embedded_claim_declares_the_pdp_path():
"""pdp_path is the issuer's declaration, and it is what our PEP requires.
Path intent is never inferred from a pdp_digest that happens to be present;
approvals issued before approval-engine schema v3 carry pdp_path false
regardless of any digest they hold.
"""
claim = _envelope("destroy")["binding"]["context"]["approval"]
assert claim["binding"]["pdp_path"] is True
def test_decision_validation_ties_the_claim_to_the_approval_binding_digest():
"""Step 2 checks the identity against the real envelope, not a local guess.
The PEP knows the claim's pdp_digest from step 1. When the request it sent
carried that claim, the decision must name the same claim-free envelope, or
the approval was issued against some other request.
"""
envelope = _refresh_lifetime(_envelope("destroy"))
request = _request_from(envelope)
result = validate_decision_envelope(
envelope,
request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v1"},
expected_approval_binding_digest=APPROVAL_BINDING_DIGEST,
)
assert result.action == "destroy"
with pytest.raises(DecisionError, match="approval binding digest"):
validate_decision_envelope(
envelope,
request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v1"},
expected_approval_binding_digest="sha256:" + "c" * 64,
)
def test_claim_bearing_request_without_a_binding_digest_fails_closed():
"""A decision that records no binding digest cannot tie the claim to itself.
Comparing against request_digest instead would be the fail-open direction
the whole field exists to prevent, so the absence is refused outright.
"""
envelope = _refresh_lifetime(_envelope("destroy"))
request = _request_from(envelope)
del envelope["binding"]["approval_binding_digest"]
with pytest.raises(DecisionError, match="records no approval_binding_digest"):
validate_decision_envelope(
envelope,
request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v1"},
expected_approval_binding_digest=APPROVAL_BINDING_DIGEST,
)
def test_a_forged_binding_digest_is_recomputed_not_trusted():
"""The field is verified against our own canonical digest, never taken on faith."""
envelope = _refresh_lifetime(_envelope("destroy"))
request = _request_from(envelope)
envelope["binding"]["approval_binding_digest"] = "sha256:" + "d" * 64
with pytest.raises(DecisionError, match="does not match this request"):
validate_decision_envelope(
envelope,
request,
accepted_policy_packages={"secrets-engine.catalog-lane.lifecycle"},
accepted_policy_versions={"v1"},
)

View file

@ -481,13 +481,75 @@ to end; what remains is deployment only.
pattern: unit tests with self-consistent fakes hid all three, and each was pattern: unit tests with self-consistent fakes hid all three, and each was
found only by a real artifact or a real chain. found only by a real artifact or a real chain.
Destroy gate closed 2026-09-06 (inbound `FLEX-DEC-2026-007`, `GH-DEC-2026-008`,
approval-engine schema v3). The vocabulary mapping this task was waiting on is
not coming, and it should not have been waited on: gate-house rejected it
outright, for the reason flex-auth and approval-engine both gave independently —
a translation can be confidently wrong and fails **open** by accepting a claim
approved for a different action. The stronger option arrived instead, and both
halves of it are now enforced here.
- **The circularity we reported is fixed at source.** A `pdp_digest` recorded at
approval-issue time can never equal the `request_digest` of a request that
carries the claim in its hashed context. With `GH-DEC-2026-008` requiring that
equality on the PDP path, destroy would have been permanently un-allowable,
failing closed forever on a check no correct record could pass. flex-auth
published `binding.approval_binding_digest` (commit `dd3ce4c`): the same
canonical digest with `context.approval` removed, stable across attaching the
claim. The comparison is against that field, never `request_digest`.
- **`request_digest` deliberately still covers the claim**, so the two digests
disagree on a claim-bearing request and that disagreement is load-bearing: two
requests differing only in which approval was presented must not share a
replay identity, or an allow obtained with a valid claim could be replayed
against a request carrying none. `approval_binding_digest` is not a replay
identity and is not used as one.
- `authorization.approval_binding_digest` implements the published exclusion
rule, including flex-auth's `json:"context,omitempty"` behaviour of dropping a
context that strips empty. `digest_material` now drops an empty context for the
same reason. `validate_decision_envelope` recomputes the field when present
rather than trusting it, refuses a claim-bearing request whose decision records
none, and takes the claim's digest from step 1 as `expected_approval_binding_digest`.
- **`binding.pdp_path` is required true** before `pdp_digest` is used at all.
It is approval-engine's declaration that the approval was requested against a
bound CheckRequest; their `create()` refuses `pdp_path` true without a digest,
so the declaration guarantees the digest. The converse is not inferred: a
digest present for some other reason is no declaration, and pre-v3 approvals
carry `pdp_path` false regardless of any digest they hold.
- The cost is accepted rather than engineered around: an approval requested
without a bound CheckRequest is not usable on this path and will not become
usable later. If a real destroy workflow cannot bind at issue, that is the
falsifier gate-house wrote into the reversal and it gets raised, not
worked around.
The replay fixtures are re-vendored from `dd3ce4c`, and the pins moved a second
and final time (`request_digest` `sha256:c749ee2d…`, context input-claim digest
`sha256:8b73d29e…`; `approval_binding_digest` `sha256:fa07becf…`, which did
**not** move — that is the point). The fixture now demonstrates the property
instead of asserting it: `test_pdp_digest_equals_the_published_approval_binding_digest`
rederives `fa07becf…` from the fixture's own request through our canonical
implementation, which is a hermetic proof that this engine hashes the same
material flex-auth does. A pin alone would only have asserted the constant.
`decision_rotate.json` is unchanged and carries no `approval_binding_digest`;
a test pins that omission so the field is not silently duplicated onto ordinary
decisions.
Our own CheckRequest stays claim-free (`context` is `{"purpose": …}`), so the
identity holds transitively today; the code is already correct for the day we
carry a claim in context.
Still open and still ours: the T04 consumer-shape question flex-auth has asked
twice — operator CLI reaching an in-cluster pin, a workload we do not have yet,
or no pin at all. It is a deployment-shape answer, not engine work, and it is
the reply owed on that thread.
Remaining to go live is configuration and deployment, not engine work: Remaining to go live is configuration and deployment, not engine work:
`SECRETS_ENGINE_PDP_URL`/`_PDP_TOKEN_FILE` (awaiting the `flex-auth-secrets-engine` `SECRETS_ENGINE_PDP_URL`/`_PDP_TOKEN_FILE` (awaiting the `flex-auth-secrets-engine`
pin, `FLEX-WP-0021-T04`/`T05`), `SECRETS_ENGINE_APPROVAL_URL`/`_TOKEN_FILE` pin, `FLEX-WP-0021-T04`/`T05`), `SECRETS_ENGINE_APPROVAL_URL`/`_TOKEN_FILE`
(awaiting `APPROVAL-WP-0002-T03`), the policy pin (published but not to be set (awaiting `APPROVAL-WP-0002-T03`), the policy pin (published but not to be set
until T05), a KeyCape RS256 credential in place of the static Bearer token, and until T05), a KeyCape RS256 credential in place of the static Bearer token, and
`approval.authorization_id` on each lane. Destroy additionally needs the `approval.authorization_id` on each lane. Destroy's additional requirement is
vocabulary mapping or a `pdp_digest` guarantee. now met in code: the `pdp_path` + `approval_binding_digest` identity replaced the
vocabulary mapping that was never going to exist.
Define and enforce the decision contract needed by production commands. A Define and enforce the decision contract needed by production commands. A
resolved approval must bind at least: resolved approval must bind at least: