Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
190 lines
10 KiB
Markdown
190 lines
10 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. For undeclared lanes, build/test and the
|
|
three-factor unsafe-demo exception retain their existing behavior. An explicitly
|
|
declared human control requires observation, PDP ALLOW and CAS consumption at
|
|
every stage; no demo or lane-review fallback discharges it.
|
|
|
|
## Declared human-control workflow
|
|
|
|
The factory lane explicitly declares `approval.human_control: true`. This
|
|
implements its existing human action-review requirement and GH-DEC-2026-016;
|
|
it does not infer human control from a CCR model, stage, actor, or approver entry,
|
|
and does not make a local authorization decision. Other catalog entries default
|
|
to undeclared. The catalog value must be a boolean, and `bootstrap-only` cannot
|
|
be combined with a declared human control.
|
|
|
|
The generated CheckRequest includes `context.human_control: true`. This changes
|
|
its evaluator-bound identity. The separate request-creation caller must issue an
|
|
unapproved Approval Engine object with **top-level `human_control: true`** and
|
|
`pdp_path: true`, using the actual evaluator-origin digest of that exact claim-free
|
|
request. A human subsequently supplies `/entries` through the admitted approver
|
|
surface. Do not copy an old request digest or retrofit an existing approval.
|
|
Approval Engine retains its unchanged five-field native act digest; the consumer
|
|
joins through the distinct evaluator-origin PDP digest as before.
|
|
|
|
At observation, this engine requires **`claim.binding.human_control is true`**
|
|
in addition to current validity, freshness and the PDP path. Missing, false,
|
|
string or numeric declarations refuse before the PDP call. The declaration is
|
|
checked again after PDP validation and carried in the internal consume binding
|
|
and non-secret `approval_human_control` evidence. A declared lane cannot skip
|
|
consumption through a build/test or unsafe-demo fallback. The consumer does not
|
|
reconstruct approver identities or counts: Approval Engine owns that evidence.
|
|
|
|
The repeatable `tools/exercise_approval_identity.py --exec-owner --human-control`
|
|
exercise creates a declared request through the actual engine API, proves a real
|
|
KeyCape service JWT cannot bind it, proves an undeclared claim cannot reach the
|
|
PDP/consume/backend path, and proves the real evaluator preserves the requirement.
|
|
The positive human entry is **seeded in the disposable store**, while requester,
|
|
claim and consume use real local service authentication. The backend and child
|
|
payload are synthetic; this is not human JWT/PKCE or live custody evidence.
|
|
See [the component receipt](evidence/2026-09-10-human-control-consumer-exercise.json).
|
|
|
|
Native narrow requester identity, browser human approval, admitted custody and
|
|
current deployed services remain APPROVAL-WP-0002-T01/T03/T05,
|
|
INFD-WP-0001-T07/T08 and SECRETS-WP-0009-T03. The factory catalog's exec owner
|
|
remains pending; adding this requirement grants no runtime or credential access.
|
|
|
|
## 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. Undeclared lanes retain their existing behavior;
|
|
a lane declaring `approval.human_control: true` refuses an absent join.
|
|
Production and declared human-control paths need:
|
|
|
|
| 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.
|