approval-engine suggested a conformance check after the examples-contradict- prose class hit a third repository -- theirs. Fifteen lines, they said, and it would have caught our caring fixture and our provenance omission. Worth stealing, so we stole it. internal/schemaguard validates published examples against published schemas. It implements only the JSON Schema subset these schemas use, and the property that makes it trustworthy is that an unrecognised keyword FAILS rather than skips: a validator that silently approves what it does not understand invites reliance it cannot support. It found three things on first run. ONE, AND THE LARGEST: check_request.schema.json pointed subject.type at CARING's subject_type enum (Human, Service, ...), and no consumer sends that vocabulary. user-engine sends human, tenant-engine and secrets-engine send service, and ops-warden sends adm/agt/atm -- an actor-type vocabulary CARING does not model at all. Our published schema declared three live integrations non-conformant. A rule that outlaws shipped correct behaviour is the rule that is wrong, so the $ref is replaced with an opaque non-empty string and a description saying why. CARING's enum remains correct where it belongs: the registry's subject_manifest.yaml, where Service is right. TWO: policy_package_note, which this session added to the caring example's decision provenance, is undeclared under additionalProperties:false. Our own annotation broke the conformance it was annotating. Moved to the envelope's outer provenance. THREE: the secrets-engine fixtures carried partial approval-claims, missing binding, freshness and validity. A partial claim in a fixture is how a consumer learns the wrong shape -- the same mechanism that produced the destroy defect. They are now complete and valid against approval-engine's schema, including the now-required binding.pdp_digest, and a test validates them against that schema when the sibling repo is present. The destroy replay fixture is regenerated accordingly and the replay README's pinned digests updated, since a stale digest table is the same defect wearing a different hat. Closed the binding-mapping open item. approval-engine declined to publish a vocabulary mapping and their reasoning is better than the request: a PIP asserting secrets.kv.destroy MEANS destroy would author semantics over two vocabularies it owns neither of, and a wrong mapping silently accepts a claim approved for a different action. pdp_digest is the mapping precisely because it does not translate. It is now always present and nullable, so the destroy gate is pdp_digest non-null and equal, enforced at the PEP. 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
135 lines
6.2 KiB
Markdown
135 lines
6.2 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.
|
|
|
|
### Closed: `pdp_digest` is the mapping, and there will be no table
|
|
|
|
`approval-engine` declined to publish an action/target mapping (their commit
|
|
`6d0dfc8`) and was right to. A PIP asserting that `secrets.kv.destroy` **means**
|
|
`destroy` would author policy semantics over two vocabularies it does not own —
|
|
the same layer boundary flex-auth invoked against its own composed object — and
|
|
the failure is asymmetric: a wrong mapping silently accepts a claim approved for
|
|
a *different* action, which is worse than no mapping.
|
|
|
|
```text
|
|
claim.binding.pdp_digest == decision.binding.request_digest
|
|
```
|
|
|
|
is the mapping, and it is stronger than a name table because it does not
|
|
translate: it compares the PDP's own digest to the PDP's own digest, in one
|
|
vocabulary, with nobody asserting equivalence.
|
|
|
|
`binding.pdp_digest` is now **always present and may be null**, so its absence is
|
|
a stated fact rather than a missing key. Null means the approval was not issued
|
|
against a PDP decision — it proves an approval exists, not that it was issued
|
|
against the request being decided.
|
|
|
|
**The `destroy` gate is therefore: `pdp_digest` non-null and equal**, enforced by
|
|
the PEP, before `SECRETS-WP-0007-T04` makes `destroy` reachable.
|