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
185 lines
9.3 KiB
Markdown
185 lines
9.3 KiB
Markdown
# 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.
|
|
|
|
Normative protocol: `gate-house/docs/contracts/approval-consumption.md`
|
|
(`GH-DEC-2026-003`). Implementation surface:
|
|
`approval-engine/docs/approval-consumption.md`. This document records how
|
|
secrets-engine, as the PEP for OpenBao writes, consumes that protocol. It
|
|
does not redefine it.
|
|
|
|
## Sequence
|
|
|
|
```text
|
|
1. PIP GET /v1/approvals/{id}/claim
|
|
2. PDP access-engine Check / decide → ALLOW
|
|
3. PEP POST /v1/approvals/{id}/consume → CAS
|
|
4. PEP OpenBao call → only after consume succeeds
|
|
```
|
|
|
|
**Each artifact is validated by the layer that owns its data**
|
|
(`GH-DEC-2026-005`). There is no single bundled object:
|
|
|
|
| Step | Artifact | Owner | Validated by |
|
|
| --- | --- | --- | --- |
|
|
| 1 | approval-claim | approval-engine | `approval_claim.validate_approval_claim` |
|
|
| 2 | DecisionEnvelope | flex-auth | `authorization.validate_decision_envelope` |
|
|
|
|
The claim carries the *approval fact*: issuer, `valid_now`, consumption state,
|
|
binding digest, freshness, `reason_code`. The envelope carries the *decision*:
|
|
effect, exact CheckRequest binding, canonical request digest, lifetime, and the
|
|
policy package/version pin. Neither republishes the other's data.
|
|
|
|
`ActionAuthorization` is **deferred and was never ratified**
|
|
(`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:
|
|
|
|
- `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.
|
|
|
|
`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.
|
|
|
|
### 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
|
|
|
|
`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 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
|
|
`{id}` path segment echoed back as `approval_id`.
|
|
|
|
**There is no authority constant.** The engine previously required
|
|
`provenance.authority == "state-hub"`, which contradicted its own source: State
|
|
Hub is a read model and holds no runtime approval authority, so that check
|
|
failed closed against every correctly issued record. The approval fact's
|
|
authority is approval-engine (checked as `issuer`); the decision's is flex-auth.
|
|
|
|
Every live privileged production handler passes `_require_lane_approval`,
|
|
which calls `require_production_consume` before `OpenBaoClient.resolve`.
|
|
Dry-run and `plan` do not consume. Build/test remain fail-open relative to
|
|
approval-engine. The three-factor unsafe-demo exception is not a consume
|
|
path.
|
|
|
|
## Fail closed
|
|
|
|
| Condition | Result |
|
|
| --- | --- |
|
|
| No served consume binding | refuse; no OpenBao |
|
|
| URL/token/authorization id all unset | binding is `None` → refuse; no OpenBao |
|
|
| Partially configured join (missing subject or policy pin) | raise; never degrade to "unconfigured" |
|
|
| Claim digest, action, or field set mismatch | refuse; no OpenBao |
|
|
| Missing `SECRETS_ENGINE_APPROVAL_URL` or token file | refuse; no OpenBao |
|
|
| HTTP 409 / different digest | refuse; no OpenBao |
|
|
| Same digest after consume | idempotent success; OpenBao may proceed |
|
|
| 401/403/404/503/unreachable | refuse; no OpenBao |
|
|
| Side effect fails after consume | approval is spent; no unconsume |
|
|
|
|
The consume response is mutation evidence (`status=consumed` plus the
|
|
presented digest). It is not a permission. Evidence records approval id,
|
|
digest, idempotence, and consumed-at only. No token, secret, or accessor.
|
|
|
|
## Required configuration
|
|
|
|
The join is absent by default, so an unconfigured engine behaves exactly as it
|
|
did before. Production additionally needs:
|
|
|
|
| Variable | Meaning |
|
|
| --- | --- |
|
|
| `SECRETS_ENGINE_APPROVAL_URL` | approval-engine base URL (claim + consume) |
|
|
| `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | mode-0600 credential, outside Git |
|
|
| `SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal |
|
|
| `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin (step 2) |
|
|
| `SECRETS_ENGINE_PDP_URL` / `_PDP_TOKEN_FILE` | the per-consumer access-engine pin |
|
|
|
|
The distinct-approver threshold is no longer a consumer-side check. The claim
|
|
does not expose approver entries; approval-engine folds that requirement into
|
|
`valid_now`, which is true only when enough distinct authenticated approvers
|
|
have been recorded and the object is not consumed, superseded, revoked, or
|
|
expired.
|
|
|
|
There is deliberately no default policy pin. The reserved coordinate is
|
|
`secrets-engine.catalog-lane.lifecycle` / `v1`, but that is a *reservation, not
|
|
a publication* (`FLEX-DEC-2026-005`) and must not be configured until
|
|
`FLEX-WP-0021-T02` publishes the package. `docs/gated-actions.md` supplies the
|
|
action vocabulary that package is built from.
|
|
|
|
Step 2 additionally needs the `flex-auth-secrets-engine` Service DNS. No
|
|
estate-wide PDP exists by design — flex-auth runs per-consumer cluster-local
|
|
pins — and that pin has not been created, so the PDP call is not wired yet.
|
|
|
|
## What this does not do
|
|
|
|
- It does not enable live production. Unreachable-engine stance still
|
|
fail-closes production until an access-engine decision record is served
|
|
(`SECRETS-WP-0007-T04` / `SECRETS-WP-0008-T02`).
|
|
- It does not render or cache an authorization decision.
|
|
- It does not infer consumption from a decision record or from local
|
|
evidence.
|