secrets-engine/docs/approval-consumption.md

86 lines
3.9 KiB
Markdown
Raw Normal View History

# Approval consumption (PEP)
feat: implement the PIP claim + validate authorization join resolve_consume_binding was a `return None` stub, so protocol step 1 of docs/approval-consumption.md (GET /v1/approvals/{id}/claim) and the validation join never existed. validate_action_authorization had no caller in src/ at all - it was reachable only from tests. Production fail-closed was correct, but for an undocumented second reason, and WP-0007-T04's "what remains is not local engine work" was wrong. The join now reproduces the exact CheckRequest via build_action_request, fetches the durable ActionAuthorization, and validates request binding, digest, validity, authority, policy pin, and distinct-approver threshold before offering a consume binding. _require_lane_approval threads the exact field set for provision/rotate/verify/exec so the digest covers the real proposed action. Deliberate choices: - The approval-engine object id is never inferred from a State Hub decision UUID; flex-auth stated GET /decisions/{uuid} is not the durable object. - No default policy pin. flex-auth stated secrets-engine.lifecycle/v1 is example vocabulary, not a published package. - A half-configured join raises rather than returning None, so a partial deployment cannot be mistaken for an unconfigured one. Behavior is unchanged today: every new input is absent by default, so production still fails closed and plan/--dry-run still work. 234 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD Assistant: claude-code Assistant-Model: opus Assistant-Process: 393550@bnt-lap001 Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 01:01:55 +02:00
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
```
feat: implement the PIP claim + validate authorization join resolve_consume_binding was a `return None` stub, so protocol step 1 of docs/approval-consumption.md (GET /v1/approvals/{id}/claim) and the validation join never existed. validate_action_authorization had no caller in src/ at all - it was reachable only from tests. Production fail-closed was correct, but for an undocumented second reason, and WP-0007-T04's "what remains is not local engine work" was wrong. The join now reproduces the exact CheckRequest via build_action_request, fetches the durable ActionAuthorization, and validates request binding, digest, validity, authority, policy pin, and distinct-approver threshold before offering a consume binding. _require_lane_approval threads the exact field set for provision/rotate/verify/exec so the digest covers the real proposed action. Deliberate choices: - The approval-engine object id is never inferred from a State Hub decision UUID; flex-auth stated GET /decisions/{uuid} is not the durable object. - No default policy pin. flex-auth stated secrets-engine.lifecycle/v1 is example vocabulary, not a published package. - A half-configured join raises rather than returning None, so a partial deployment cannot be mistaken for an unconfigured one. Behavior is unchanged today: every new input is absent by default, so production still fails closed and plan/--dry-run still work. 234 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD Assistant: claude-code Assistant-Model: opus Assistant-Process: 393550@bnt-lap001 Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 01:01:55 +02:00
Step 1 is `resolve_consume_binding` (`approval_consume.py`): it reproduces the
exact CheckRequest with `build_action_request`, fetches the durable
`ActionAuthorization` from `GET /v1/approvals/{id}/claim`, and validates it with
`validate_action_authorization` before returning a consume binding. Until
2026-09-06 that function was a `return None` stub and the validator was
unreachable from `src/`; the join now exists.
The approval-engine object id is never inferred from a State Hub decision UUID.
It comes from catalog `approval.authorization_id` or a served
`decision.authorization_id`. The Check request `id` is an opaque correlator the
PEP cannot regenerate, so the served one is adopted; every security-relevant
field is still compared exactly and the binding digest is recomputed against the
served request.
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 |
feat: implement the PIP claim + validate authorization join resolve_consume_binding was a `return None` stub, so protocol step 1 of docs/approval-consumption.md (GET /v1/approvals/{id}/claim) and the validation join never existed. validate_action_authorization had no caller in src/ at all - it was reachable only from tests. Production fail-closed was correct, but for an undocumented second reason, and WP-0007-T04's "what remains is not local engine work" was wrong. The join now reproduces the exact CheckRequest via build_action_request, fetches the durable ActionAuthorization, and validates request binding, digest, validity, authority, policy pin, and distinct-approver threshold before offering a consume binding. _require_lane_approval threads the exact field set for provision/rotate/verify/exec so the digest covers the real proposed action. Deliberate choices: - The approval-engine object id is never inferred from a State Hub decision UUID; flex-auth stated GET /decisions/{uuid} is not the durable object. - No default policy pin. flex-auth stated secrets-engine.lifecycle/v1 is example vocabulary, not a published package. - A half-configured join raises rather than returning None, so a partial deployment cannot be mistaken for an unconfigured one. Behavior is unchanged today: every new input is absent by default, so production still fails closed and plan/--dry-run still work. 234 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD Assistant: claude-code Assistant-Model: opus Assistant-Process: 393550@bnt-lap001 Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 01:01:55 +02:00
| 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.
feat: implement the PIP claim + validate authorization join resolve_consume_binding was a `return None` stub, so protocol step 1 of docs/approval-consumption.md (GET /v1/approvals/{id}/claim) and the validation join never existed. validate_action_authorization had no caller in src/ at all - it was reachable only from tests. Production fail-closed was correct, but for an undocumented second reason, and WP-0007-T04's "what remains is not local engine work" was wrong. The join now reproduces the exact CheckRequest via build_action_request, fetches the durable ActionAuthorization, and validates request binding, digest, validity, authority, policy pin, and distinct-approver threshold before offering a consume binding. _require_lane_approval threads the exact field set for provision/rotate/verify/exec so the digest covers the real proposed action. Deliberate choices: - The approval-engine object id is never inferred from a State Hub decision UUID; flex-auth stated GET /decisions/{uuid} is not the durable object. - No default policy pin. flex-auth stated secrets-engine.lifecycle/v1 is example vocabulary, not a published package. - A half-configured join raises rather than returning None, so a partial deployment cannot be mistaken for an unconfigured one. Behavior is unchanged today: every new input is absent by default, so production still fails closed and plan/--dry-run still work. 234 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M65ovP3eiiPHubibvWs9mD Assistant: claude-code Assistant-Model: opus Assistant-Process: 393550@bnt-lap001 Assistant-Session: 4bb359f9-1f12-4410-9e76-079cf23c82e4
2026-09-06 01:01:55 +02:00
## 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 |
| `SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS` | distinct-approver threshold |
There is deliberately no default policy pin. flex-auth stated that the published
`secrets-engine.lifecycle` / `v1` names are example vocabulary on the envelope,
not a live package, so treating them as a default would pin production to a
package nobody publishes.
## 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.