fix: bind approval consumption to actual Flex Auth submissions
Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
This commit is contained in:
parent
89bc31460f
commit
ee4e901611
23 changed files with 612 additions and 1023 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -53,13 +53,14 @@ Run from this repository with Docker available and the owner source checkouts:
|
|||
uv run --with 'PyJWT[crypto]>=2.7,<3' python tools/exercise_approval_identity.py \
|
||||
--keycape-source /home/worsch/key-cape \
|
||||
--approval-engine-source /home/worsch/approval-engine \
|
||||
--flex-auth-source /home/worsch/flex-auth \
|
||||
--receipt /tmp/<new-metadata-receipt>.json
|
||||
```
|
||||
|
||||
The exercise starts the immutable admitted KeyCape image behind local HTTPS,
|
||||
uses synthetic signing keys/client secrets, and serves the real Approval Engine
|
||||
source with its JWT/JWKS verifier and SQLite store. It drives the actual Secrets
|
||||
Engine production gate, with only the PDP represented by a sequencing double.
|
||||
Engine production gate through the actual locally compiled Flex Auth evaluator.
|
||||
Operator create/approve, consumer claim/consume, same-digest retry, different
|
||||
digest, spent claim, wrong action, wrong secret and operator-scope denial are
|
||||
checked. All temporary processes and synthetic credentials are removed.
|
||||
|
|
@ -82,6 +83,15 @@ and sender custody remains AUDIT-WP-0009-T09 / APPROVAL-WP-0002-T01. The live
|
|||
Approval Engine deployment and native OpenBao delivery remain open. Synthetic
|
||||
acceptance grants no production access, action approval or model spending.
|
||||
|
||||
The newly consumed FLEX-DEC-2026-012 return remains SECRETS-WP-0008-T02:
|
||||
adopt submitted_request_digest and the evaluator-to-evaluator approval digest
|
||||
join before live activation. The current PDP double does not validate it.
|
||||
SECRETS-WP-0008-T02 now supplies the implemented FLEX-DEC-2026-012 replay
|
||||
and approval join. Verify the deployed PDP implements it before live activation.
|
||||
|
||||
|
||||
### 2026-09-09 replay correction accepted locally
|
||||
|
||||
The updated exercise requires `--flex-auth-source` and compiles its actual Go
|
||||
evaluator. It no longer substitutes a decision double. Thirteen checks cover
|
||||
signed issuer/claim/consume, registry precedence, changed submission replay,
|
||||
wrong action, same/different consume digest and actual dual-control policy.
|
||||
See `docs/evidence/2026-09-09-replay-contract-exercise.json`. Earlier nine-check
|
||||
PDP-double evidence remains historical. Live custody/deployment gates still apply.
|
||||
|
|
|
|||
47
docs/evidence/2026-09-09-replay-contract-exercise.json
Normal file
47
docs/evidence/2026-09-09-replay-contract-exercise.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"schema_version": 1,
|
||||
"target": "disposable local processes; synthetic credentials",
|
||||
"started_at": "2026-09-09T06:38:34.485308+00:00",
|
||||
"keycape_image": "forgejo.coulomb.social/coulomb/key-cape@sha256:7ff54c54e63ee172ae9e6e7fd2da96e427352f712343d74626ee6fe0f6f82611",
|
||||
"approval_engine_commit": "b46b0f26669dc83c944ee5145426bad03d5ef720",
|
||||
"flex_auth_commit": "88b354377c8e26b162f1234e673072f1c06dcd89",
|
||||
"keycape_contract_commit": "0f5535eed95f1223c83a28f5a0bd6fa594cecae8",
|
||||
"consumer_source_sha256": {
|
||||
"approval_auth.py": "6f3b033e7928e1c527bc19f5a1e01bf243540769a8646bd8cd47268da25637b5",
|
||||
"approval_consume.py": "9ccc6997fd4c1c82af8cf3dd440daf7b695d6272bb302eb083b73a2fe3674345",
|
||||
"approval_claim.py": "54dd469e4e3ac9472c4e21222d5a08f70723a63f01904a35922fcc0a07a41541",
|
||||
"authorization.py": "75c472f30cce1c3d66be1ded1eb6dcc62684668b0da5bf83d5fcdd37e92d1935",
|
||||
"config.py": "2f2f1b60664d89bbb806aed0c768378923062077740e1f96e4c359238eb06955",
|
||||
"service_auth.py": "653723ef5babce2157771416d93cb15cdbb7caca3c45509c3730520c55e4515c",
|
||||
"cli.py": "a789fcea59e9a03d7ef07bf3ec1baf58234058585149511333c3fb7570f5916a"
|
||||
},
|
||||
"limitations": [
|
||||
"standalone Flex Auth source, not deployed pin",
|
||||
"local Approval Engine source, not deployed image",
|
||||
"no live custody or client-side read grant",
|
||||
"no OpenBao effect or model execution"
|
||||
],
|
||||
"checks": {
|
||||
"operator_issued_and_approved_via_verified_jwt": true,
|
||||
"wrong_action_refused_before_consume": true,
|
||||
"producer_origin_join_with_carried_claim": true,
|
||||
"actual_consumer_claim_check_consume": true,
|
||||
"same_digest_retry_idempotent": true,
|
||||
"different_digest_refused": true,
|
||||
"spent_claim_refused": true,
|
||||
"operator_consume_scope_denied_by_issuer": true,
|
||||
"wrong_secret_refused": true,
|
||||
"no_access_token_file_created": true,
|
||||
"registry_override_accepts_exact_submission": true,
|
||||
"same_enriched_result_different_submission_refused": true,
|
||||
"real_dual_control_denial_then_claim_check_consume": true
|
||||
},
|
||||
"flex_auth_binary_sha256": "c7a1f35aa2cd7cf2733272923ace7fc4b4e55171db4ef8f3d0d6629183dcf1fa",
|
||||
"producer_input_sha256": {
|
||||
"policy_package.md": "657fa9312c9dcabee059ec31c89ee13396fe9aa54d852edc9e6559a74bbc139a",
|
||||
"registry_snapshot.json": "37fba44ec0e0fd9b1e17a05ad1b7e4f9bb6ec9e0fc0bb3133e8af2ac6f832d7c"
|
||||
},
|
||||
"status": "passed",
|
||||
"cleanup_complete": true,
|
||||
"finished_at": "2026-09-09T06:38:44.956593+00:00"
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue