flex-auth/docs/secrets-engine-action-vocabulary.md
tegwick 68ad039a3f Fix the destroy rule: it was written against an invented claim shape
approval-engine flagged the class one message earlier -- a contract whose
examples contradict its prose gets implemented as its examples -- and
yesterday's package was a fresh instance of it, committed while flagging
it.

The published rule required context.approval.status == "approved" and
counted context.approval.approvals[].subject_id. Neither field exists.
approval-engine's approval_claim.schema.json has `state` (whose operative
value is `valid`, not `approved`) and carries no approver list at all. The
rule was unsatisfiable: every live destroy would have denied
dual_control_required no matter how good the approval was. It failed
closed, so it was never a hole, but it was policy written against a shape
of our own devising rather than a published one.

The rule now consumes valid_now from the real claim, guarded on kind and
issuer. valid_now is the summary predicate that already folds in the
distinct-approver threshold, with reason_code insufficient_approvers for
a claim that failed it -- so this is also the correct layering, not just
the correct shape. Counting approvers here is exactly the duplication
GH-DEC-2026-005 removes; the compensating property is reconstructability
at the issuer under 9.6, which is approval-engine's.

Recorded as a correction section in the package and the vocabulary doc
rather than quietly rewritten. 25 Rego tests and 29 fixtures pass,
covering insufficient_approvers, consumed, revoked, approved-but-not-yet-
valid, foreign issuer, and wrong kind.

Two things the package deliberately does not do, both now written down:
it does not compare binding.pdp_digest, because the request digest is
computed after policy evaluation and a Rego rule cannot see it; and it
makes no cross-check that the claim was approved for this action and
target, because the claim's binding uses approval-engine's vocabulary and
no mapping between the two is published. Inventing one would silently
accept a claim approved for something else. Both belong to the PEP until
a mapping exists, and that is worth closing before SECRETS-WP-0007-T04
makes destroy reachable.

Also swept the other published fixtures on approval-engine's reasoning.
One more instance: the inner decision in examples/caring/action_authorization.json
declared contract_version flex-auth.decision-record.v1 while its
provenance omitted policy_package_digest, registry_snapshot_digest, and
input_claim_digests -- all published contract fields since 2026-09-02.
Completed. The remaining example context vocabularies are consumer-owned
and match their integrations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JTbVXpEiXA7mNJVpDnEPcB

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 412054@bnt-lap001
Assistant-Session: 3968fae1-8d59-4209-9bd6-c22594b8ab19
2026-09-06 08:11:14 +02:00

121 lines
5.6 KiB
Markdown

# secrets-engine action vocabulary
Status: published
Date: 2026-09-06
Workplan: `FLEX-WP-0021-T01`
Source: secrets-engine `docs/gated-actions.md`, read out of their `cli.py`
Package: `secrets-engine.catalog-lane.lifecycle` v1
(`examples/secrets-engine/policy_package.md`)
`FLEX-WP-0021-T01` required secrets-engine to **name** this list rather than
letting flex-auth derive it, and made an inferred action a blocker rather than
a default. That was not ceremony: four of the twelve would have been inferred
wrongly, and one action that looks obvious does not exist at all.
The example vocabulary `secrets-engine.lifecycle/v1` this replaces is **not**
this list and was never published (`FLEX-DEC-2026-005`).
## Request shape
| Field | Value |
| --- | --- |
| `resource.type` | `secret-catalog-lane` |
| `resource.system` | `secrets-engine` |
| `resource.id` | the catalog id |
| `resource.attributes.stage` | deployment stage |
| `resource.attributes.fields` | sorted; populated for `provision`, `rotate`, `verify`, `exec`; empty list otherwise |
| `resource.attributes.policy_targets` / `auth_targets` | sorted |
| `context.approval` | approval claim; required for `destroy` only |
Empty lists are sent rather than omitted or guessed.
## The twelve actions
| Action | Reaches OpenBao | Notes |
| --- | --- | --- |
| `apply` | yes | `apply --dry-run` does not reach the gate |
| `provision` | yes | carries `fields` |
| `rotate` | yes | carries `fields` |
| `verify` | yes | carries `fields` |
| `handoff` | yes | |
| `wrap` | yes | |
| `exec` | yes | carries `fields` |
| `deactivate` | yes | also the gate for the CLI verb `revoke` |
| `suspend` | yes | |
| `destroy` | yes | dual control; not reachable live yet |
| `compromise` | no | local delivery overlay only |
| `reactivate` | no | local delivery overlay only |
## Four things an inferred list gets wrong
**1. `revoke` is not an action.** The CLI verb `revoke` gates as `deactivate`,
which is also reached from `lifecycle deactivate`. There is no `revoke` value
and one must not be added. The package asserts this
(`test_revoke_is_not_an_action``unknown_action`).
**2. `destroy` is defined but not reachable live.** Its handler raises before
the gate, so only `--dry-run` renders. It stays in the vocabulary as the
dual-control case; no live `destroy` Check arrives until
`SECRETS-WP-0007-T04` lands. Keeping it in the package now means the rule is
reviewed and fixtured *before* the path opens rather than in the same change
that opens it.
**3. `compromise` and `reactivate` touch no OpenBao object.** They mutate local
delivery-overlay state only. They are gated because they change delivery
posture, not because they write to the backend — a distinction worth recording,
because a reviewer looking for backend writes would otherwise read their
presence as a mistake.
**4. Seven CLI surfaces never reach the gate** and must not appear in the
package: `plan`, `apply --dry-run`, `route`, `audit`, `catalog`,
`decision inspect`, and `evidence`.
`apply --dry-run` is the one flex-auth cannot enforce. `apply` is an action and
`apply --dry-run` is not, but both would arrive as `apply` — the distinction is
invisible to the PDP. What keeps them apart is that the PEP does not call the
gate for a dry run. flex-auth records the limit rather than implying a control
it does not have.
## Dual control on `destroy`
`destroy` requires an **approval-claim** on `context.approval`, issued by
`approval-engine`, whose `valid_now` is `true`. That is the whole check.
The shape is `approval-engine/schemas/approval_claim.schema.json`.
`valid_now` is a summary predicate — true only when the object is approved,
inside its validity window, and not consumed, superseded, revoked, or expired —
and **the distinct-approver threshold is folded into it**. A claim that failed
the threshold returns `reason_code: insufficient_approvers`.
flex-auth consumes the claim as an input claim and never mutates it
(`security-layer-model_v0.7` §9.4). Per `FLEX-DEC-2026-006` the approval fact is
`approval-engine`'s step-1 artifact and the decision is step 2; the PEP
validates across both.
The package does **not** count approvers, re-derive validity/freshness/
signature/supersession, or compare `binding.pdp_digest`. Counting approvers is
the duplication the split removes (`GH-DEC-2026-005`); the compensating property
is reconstructability at the issuer under §9.6, which is `approval-engine`'s.
The digest comparison is real and preferred but the request digest is computed
after policy evaluation, so a Rego rule cannot see it — it belongs in the PEP.
### Correction, 2026-09-06
The first published rule required `context.approval.status == "approved"` and
counted `context.approval.approvals[].subject_id`. Neither field exists: the
claim's field is `state` (and `valid`, not `approved`, is the operative value),
and it carries no approver list. The rule was unsatisfiable — every live
`destroy` would have denied `dual_control_required` regardless of the approval.
It failed closed, so it was never a hole, but it was a policy written against an
invented shape. Corrected the same day.
### Open item: no published binding mapping
The claim's `binding.action` and `binding.target` use `approval-engine`'s
vocabulary (`secrets.kv.destroy`, `{"id": ..., "stage": ...}`), not this one
(`destroy`, `lane:...`). No mapping between them is published, so the package
makes no cross-check that the claim was approved for *this* action and target,
and must not invent one — a wrong mapping would silently accept a claim approved
for something else. Until a mapping exists that correspondence is the PEP's, via
`binding.pdp_digest`. Worth closing before `SECRETS-WP-0007-T04` makes `destroy`
reachable.