Assistant: codex Assistant-Model: gpt-5.6-luna Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
148 lines
7.7 KiB
Markdown
148 lines
7.7 KiB
Markdown
# Approval consumption (PEP)
|
|
|
|
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:
|
|
`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.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,
|
|
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.
|
|
|
|
### Replay, approval correspondence and consume identity
|
|
|
|
FLEX-DEC-2026-012 distinguishes three decision bindings:
|
|
|
|
| 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 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.
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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.
|
|
|
|
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
|
|
`{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` | explicit bearer credential outside Git; mutually exclusive with the client-secret provider |
|
|
| `SECRETS_ENGINE_APPROVAL_CLIENT_SECRET_FILE` | protected temporary KeyCape approval-client secret; fresh in-memory token per request ([contract](approval-service-auth.md)) |
|
|
| `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.
|