fix: bind approval consumption to actual Flex Auth submissions
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-5.6-luna
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
tegwick 2026-09-09 08:55:46 +02:00
parent 89bc31460f
commit ee4e901611
23 changed files with 612 additions and 1023 deletions

View file

@ -1,8 +1,8 @@
# Approval consumption (PEP)
Status: engine consumer and the PIP claim/validate join are implemented; live
production remains fail-closed until approval-engine and access-engine actually
serve the durable objects.
Status: consumer replay and claim/check/consume join are implemented and exercised
against real local components. Live activation requires current PDP contract
adoption, client-side and audit admission, and the deployed approval service.
Normative protocol: `gate-house/docs/contracts/approval-consumption.md`
(`GH-DEC-2026-003`). Implementation surface:
@ -24,7 +24,7 @@ does not redefine it.
| Step | Artifact | Owner | Validated by |
| --- | --- | --- | --- |
| 1 | approval-claim | approval-engine | `approval_claim.validate_approval_claim` |
| 1 | approval-claim | approval-engine | `approval_claim.observe_pdp_approval_claim`, then decision-time correspondence |
| 2 | DecisionEnvelope | flex-auth | `authorization.validate_decision_envelope` |
The claim carries the *approval fact*: issuer, `valid_now`, consumption state,
@ -36,81 +36,43 @@ policy package/version pin. Neither republishes the other's data.
(`FLEX-DEC-2026-006`). It cannot be served from a step-1 call, and nothing here
validates it. A ratified post-decision form remains a deferred option.
There are **two different digests** over the same proposed action, and they are
never compared to each other:
### Replay, approval correspondence and consume identity
- `claim.binding.digest` — approval-engine native, `sha256` over canonical JSON
of `{action, actor, principal, purpose, target}`.
- `decision.binding.request_digest` — flex-auth canonical CheckRequest digest.
FLEX-DEC-2026-012 distinguishes three decision bindings:
`claim.binding.pdp_digest` is the **only** comparison usable, and its absence
fails the action closed. 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>`). No mapping between
them exists and none is coming: gate-house rejected one outright in
`GH-DEC-2026-008`, because a translation can be confidently wrong and fails
**open** by silently accepting a claim approved for a different action. An
identity check cannot. So this engine computes no native digest.
| Field | Use |
| --- | --- |
| `submitted_request_digest` | Compare to the canonical request actually sent, including the full `context.approval` claim. Missing or different refuses. |
| `approval_binding_digest` | Compare to the claim's issuer-recorded `binding.pdp_digest`. Both values originate at the evaluator, before and after approval respectively. |
| `request_digest` | Preserve the evaluator's enriched request identity for audit and the consume CAS request, alongside its decision id. |
### The two gates on the pdp path
The consumer first observes issuer, approval id, validity, freshness, unconsumed
state, reason and explicit `pdp_path: true` with a canonical `pdp_digest`.
`resolve_approval_observation` returns an observation, never a consume binding.
It carries that exact claim into `context.approval` for the PDP call. Step 2
checks allow, submitted replay identity, policy package/version, decision
lifetime and the evaluator-origin approval pair. Claim freshness is rechecked
after the call. Only then does `authorize_action` produce a consume binding.
**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.
Registry facts can override caller attributes. The consumer does not duplicate
that evaluation or reconstruct the enriched request/approval digest. The native
approval digest uses a different vocabulary and is never a fallback. A
claim-free decision cannot satisfy the approval join. Changing or removing a
claim changes the submitted replay identity even when the approval-free digest
stays constant.
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.
Canonical hashing covers tenant, subject, action, resource and nonempty context;
request id, policy_version and caring_context are excluded by the producer's
contract. Independent producer input/output fixtures verify this boundary.
**2. The digest identity, against the right comparand.**
CAS retry resends the **same validated enriched request digest**. Fetching a new
claim or changing a request is not the same consume operation. Consumption is
not backend exactly-once evidence: a response-lost OpenBao operation still needs
its own reconciliation before any repeated side effect.
```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
`tenant`, `subject`, `action`, `resource`, `context` — and nothing else.
`id` is correlation only, `policy_version` lives in provenance, and
`caring_context` is hashed separately. Including any of them yields a digest
that matches no issued decision. `digest_material` enforces the exclusion and
`tests/test_decision_replay.py` pins it against two real envelopes.
A consumer re-hashing the *original unenriched* request will not match a
decision that turned on registry attributes: the binding is the evaluator's
statement of what it hashed, so recompute from the binding tuple.
The supported owner loopback forwarding path remains required for PDP transport.
A matching digest does not authenticate a malicious responder. Detached signed
decisions remain FLEX-WP-0024; this correction grants no new network trust.
The approval-engine object id is never inferred from a State Hub decision UUID.
It comes from catalog `approval.authorization_id`, and is the value in the