flex-auth/docs/secrets-engine-action-vocabulary.md

122 lines
5.6 KiB
Markdown
Raw Normal View History

Publish secrets-engine.catalog-lane.lifecycle v1 (FLEX-WP-0021 T01, T02) secrets-engine delivered the action vocabulary T01 asked for: twelve actions read out of cli.py, not the example vocabulary. The gate earned its keep -- four would have been inferred wrongly, and revoke, the obvious thirteenth, does not exist as an action at all. It gates as deactivate, which is also reached from lifecycle deactivate. docs/secrets-engine-action-vocabulary.md records the list and the four traps. examples/secrets-engine/ carries the package, both manifests, a loadable registry snapshot, 26 fixtures, five check requests, and a README. validate -kind policy is valid with 22/22 Rego tests and 26/26 fixtures. allow_ttl is 15m, stated in the package rather than inherited from the engine default: these decisions authorize live secret operations, so the reliance window belongs where a reviewer sees it. Per FLEX-DEC-2026-004 it is authority to issue, not authority to keep using what the operation produced. destroy is the dual-control case, gated on a context.approval claim marked approved with two distinct approvers, repeated entries counting once. flex-auth checks what the claim says and deliberately does not re-derive its temporal validity, signature, or supersession -- those are approval-engine's to assert and the PEP's to verify against the live claim, per the split accepted in FLEX-DEC-2026-006. It stays in the package though its handler raises before the gate, so the rule is reviewed and fixtured before SECRETS-WP-0007-T04 opens the path. Following the FLEX-WP-0010-T02 precedent the denial ladder has no action_not_granted branch: one subject holding all twelve actions could never reach it, and a rule that cannot fail reads as control that is not there. Registering a second calling identity is the revisit trigger. Not deployed. No flex-auth-secrets-engine pin exists yet (T04), so their policy pin stays unset and fail-closed until T05. 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:02:52 +02:00
# 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`
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
`destroy` requires an **approval-claim** on `context.approval`, issued by
`approval-engine`, whose `valid_now` is `true`. That is the whole check.
Publish secrets-engine.catalog-lane.lifecycle v1 (FLEX-WP-0021 T01, T02) secrets-engine delivered the action vocabulary T01 asked for: twelve actions read out of cli.py, not the example vocabulary. The gate earned its keep -- four would have been inferred wrongly, and revoke, the obvious thirteenth, does not exist as an action at all. It gates as deactivate, which is also reached from lifecycle deactivate. docs/secrets-engine-action-vocabulary.md records the list and the four traps. examples/secrets-engine/ carries the package, both manifests, a loadable registry snapshot, 26 fixtures, five check requests, and a README. validate -kind policy is valid with 22/22 Rego tests and 26/26 fixtures. allow_ttl is 15m, stated in the package rather than inherited from the engine default: these decisions authorize live secret operations, so the reliance window belongs where a reviewer sees it. Per FLEX-DEC-2026-004 it is authority to issue, not authority to keep using what the operation produced. destroy is the dual-control case, gated on a context.approval claim marked approved with two distinct approvers, repeated entries counting once. flex-auth checks what the claim says and deliberately does not re-derive its temporal validity, signature, or supersession -- those are approval-engine's to assert and the PEP's to verify against the live claim, per the split accepted in FLEX-DEC-2026-006. It stays in the package though its handler raises before the gate, so the rule is reviewed and fixtured before SECRETS-WP-0007-T04 opens the path. Following the FLEX-WP-0010-T02 precedent the denial ladder has no action_not_granted branch: one subject holding all twelve actions could never reach it, and a rule that cannot fail reads as control that is not there. Registering a second calling identity is the revisit trigger. Not deployed. No flex-auth-secrets-engine pin exists yet (T04), so their policy pin stays unset and fail-closed until T05. 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:02:52 +02:00
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
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
Publish secrets-engine.catalog-lane.lifecycle v1 (FLEX-WP-0021 T01, T02) secrets-engine delivered the action vocabulary T01 asked for: twelve actions read out of cli.py, not the example vocabulary. The gate earned its keep -- four would have been inferred wrongly, and revoke, the obvious thirteenth, does not exist as an action at all. It gates as deactivate, which is also reached from lifecycle deactivate. docs/secrets-engine-action-vocabulary.md records the list and the four traps. examples/secrets-engine/ carries the package, both manifests, a loadable registry snapshot, 26 fixtures, five check requests, and a README. validate -kind policy is valid with 22/22 Rego tests and 26/26 fixtures. allow_ttl is 15m, stated in the package rather than inherited from the engine default: these decisions authorize live secret operations, so the reliance window belongs where a reviewer sees it. Per FLEX-DEC-2026-004 it is authority to issue, not authority to keep using what the operation produced. destroy is the dual-control case, gated on a context.approval claim marked approved with two distinct approvers, repeated entries counting once. flex-auth checks what the claim says and deliberately does not re-derive its temporal validity, signature, or supersession -- those are approval-engine's to assert and the PEP's to verify against the live claim, per the split accepted in FLEX-DEC-2026-006. It stays in the package though its handler raises before the gate, so the rule is reviewed and fixtured before SECRETS-WP-0007-T04 opens the path. Following the FLEX-WP-0010-T02 precedent the denial ladder has no action_not_granted branch: one subject holding all twelve actions could never reach it, and a rule that cannot fail reads as control that is not there. Registering a second calling identity is the revisit trigger. Not deployed. No flex-auth-secrets-engine pin exists yet (T04), so their policy pin stays unset and fail-closed until T05. 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:02:52 +02:00
(`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
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
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.