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
493 lines
17 KiB
Markdown
493 lines
17 KiB
Markdown
---
|
|
id: secrets-engine.catalog-lane.lifecycle
|
|
name: secrets-engine catalog-lane lifecycle authorization
|
|
namespace: secrets-engine:secret-catalog-lane
|
|
version: v1
|
|
status: ready
|
|
package: flexauth.secrets_engine.catalog_lane
|
|
allow_ttl: 15m
|
|
actions:
|
|
- apply
|
|
- provision
|
|
- rotate
|
|
- verify
|
|
- handoff
|
|
- wrap
|
|
- exec
|
|
- deactivate
|
|
- suspend
|
|
- destroy
|
|
- compromise
|
|
- reactivate
|
|
owner: team:platform-security
|
|
fixtures:
|
|
- policy_fixtures.yaml
|
|
caring:
|
|
profile: caring-0.4.0-rc2
|
|
enforce: false
|
|
canonical_roles:
|
|
- Operator
|
|
organization_relations:
|
|
- ServiceProvider
|
|
scopes:
|
|
- level: Platform
|
|
id: platform:secrets-engine
|
|
tenant: tenant:platform
|
|
planes:
|
|
- Secret
|
|
- Policy
|
|
- Audit
|
|
capabilities:
|
|
- Create
|
|
- EditAny
|
|
- Execute
|
|
- Archive
|
|
- Restore
|
|
- View
|
|
- Audit
|
|
exposure_modes:
|
|
- Metadata
|
|
conditions:
|
|
- DualApprovalRequired
|
|
restrictions:
|
|
- PrivilegeEscalationBlocked
|
|
activation:
|
|
mode: local
|
|
metadata:
|
|
source: examples/secrets-engine/policy_package.md
|
|
flex_auth_contract: protected-system-v0
|
|
action_vocabulary: docs/secrets-engine-action-vocabulary.md
|
|
---
|
|
|
|
# secrets-engine catalog-lane lifecycle authorization
|
|
|
|
This package authorizes `secrets-engine`'s gated CLI operations over a secret
|
|
catalog lane. `secrets-engine` keeps custody of the secret material, the
|
|
OpenBao objects, and the local delivery overlay; flex-auth decides whether a
|
|
specific lifecycle operation is allowed now.
|
|
|
|
Opened by `FLEX-DEC-2026-005` and carried by `FLEX-WP-0021`.
|
|
|
|
## The vocabulary is theirs, not ours
|
|
|
|
The twelve action values below were **delivered by secrets-engine**
|
|
(`FLEX-WP-0021-T01`, their `docs/gated-actions.md`), read out of `cli.py`
|
|
rather than derived from the `secrets-engine.lifecycle/v1` example vocabulary
|
|
that this package replaces. `FLEX-WP-0021-T01` made an inferred action a
|
|
blocker rather than a default, and four of the twelve would have been inferred
|
|
wrongly. They are recorded in
|
|
[`../../docs/secrets-engine-action-vocabulary.md`](../../docs/secrets-engine-action-vocabulary.md).
|
|
|
|
The four that an inferred list would have gotten wrong are encoded here, not
|
|
assumed — see "The four traps" below.
|
|
|
|
## Correction, 2026-09-06 — the dual-control rule was written against an invented shape
|
|
|
|
The first published version of this rule required `context.approval.status ==
|
|
"approved"` and counted `context.approval.approvals[].subject_id`. **Neither
|
|
field exists.** The claim's field is `state` (whose `approved` is not the
|
|
operative value — `valid` is), and it carries no approver list at all. That 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. It was, however, a policy written
|
|
against an invented shape rather than a published one — the same class of error
|
|
`approval-engine` had flagged one message earlier, committed while flagging it.
|
|
Recorded here rather than quietly rewritten. The rule now consumes
|
|
`valid_now` from the published claim; see "Dual control on `destroy`".
|
|
|
|
## The four traps
|
|
|
|
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 — `test_revoke_is_not_an_action` asserts it
|
|
denies `unknown_action`.
|
|
2. **`destroy` is defined but not reachable live.** Its handler raises before
|
|
the gate, so only `--dry-run` renders today. It stays in the vocabulary as
|
|
the dual-control case; no live `destroy` Check arrives until
|
|
`SECRETS-WP-0007-T04` lands.
|
|
3. **`compromise` and `reactivate` touch no OpenBao object.** They mutate local
|
|
overlay state only. They are gated because they change delivery posture, not
|
|
because they write to the backend.
|
|
4. **Seven CLI surfaces never reach the gate** and must not appear here:
|
|
`plan`, `apply --dry-run`, `route`, `audit`, `catalog`, `decision inspect`,
|
|
and `evidence`. `test_ungated_surfaces_are_not_actions` asserts they deny.
|
|
|
|
On the fourth: `apply` *is* an action and `apply --dry-run` is not, but that
|
|
distinction is invisible to flex-auth — both would arrive as `apply`. The PEP
|
|
does not call the gate for a dry run, and that is the only thing keeping them
|
|
apart. flex-auth cannot enforce it and does not pretend to.
|
|
|
|
## Request shape
|
|
|
|
`resource.type` is `secret-catalog-lane`, `resource.system` is
|
|
`secrets-engine`, and the catalog id is `resource.id`
|
|
(`FLEX-DEC-2026-005`; secrets-engine implemented this in their commit
|
|
`627810b`). `resource.attributes` carries `stage` plus sorted `fields`,
|
|
`policy_targets`, and `auth_targets`. `fields` is populated for `provision`,
|
|
`rotate`, `verify`, and `exec`; the rest send an empty list rather than a
|
|
guess.
|
|
|
|
## Allow lifetime
|
|
|
|
`allow_ttl: 15m`, stated explicitly rather than inherited from the engine
|
|
default. These decisions authorize live secret operations, so the window in
|
|
which one may be relied on is a property worth writing down in the package
|
|
where a reviewer sees it, not one to leave implicit.
|
|
|
|
Per `FLEX-DEC-2026-004`, that lifetime is authority to *issue* the operation,
|
|
not authority to keep using anything the operation produced. A wrapped or
|
|
delivered secret's own lifetime is secrets-engine's to bound.
|
|
|
|
## Dual control on `destroy`
|
|
|
|
`destroy` is the one action that requires more than a known caller. It requires
|
|
an **approval-claim** on `context.approval`, issued by `approval-engine`, whose
|
|
`valid_now` is `true`.
|
|
|
|
That is the whole check, and the shape is
|
|
`approval-engine/schemas/approval_claim.schema.json` — not a shape of
|
|
flex-auth's devising. `valid_now` is a summary predicate: true only when the
|
|
object is approved, inside its validity window, and not consumed, superseded,
|
|
revoked, or expired. **The distinct-approver threshold is folded into it**, and
|
|
`reason_code: insufficient_approvers` is how a claim that failed the threshold
|
|
comes back.
|
|
|
|
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 this decision is step 2; the PEP
|
|
validates across both.
|
|
|
|
### What this package deliberately does not do
|
|
|
|
- **It does not count approvers.** The claim carries no approver list, and
|
|
re-deriving the threshold here is exactly the duplication the step-1/step-2
|
|
split removes (`GH-DEC-2026-005`). The compensating property is
|
|
reconstructability at the issuer under §9.6 — detection, not prevention —
|
|
and it is `approval-engine`'s, not ours.
|
|
- **It does not re-derive validity, freshness, signature, or supersession.**
|
|
Those are `approval-engine`'s to assert and the PEP's to verify against the
|
|
live claim. A PDP re-deriving them from a caller-supplied blob would be
|
|
inventing an authority it does not have.
|
|
- **It does not compare `binding.pdp_digest`.** That comparison is the whole
|
|
binding mapping (see below), but the request digest is computed by the engine
|
|
*after* policy evaluation, so a Rego rule cannot see it. It belongs in the PEP,
|
|
which must require the value non-null and equal.
|
|
|
|
### The binding mapping: `pdp_digest` is the answer, and there will be no table
|
|
|
|
**Closed 2026-09-06.** flex-auth asked `approval-engine` whether a published
|
|
action/target mapping was worth having, and offered to co-author it. They
|
|
declined, and were right to:
|
|
|
|
- The claim's `binding.action` and `binding.target` use `approval-engine`'s
|
|
vocabulary (`secrets.kv.destroy`, `{"id": ..., "stage": ...}`); this package's
|
|
use ours (`destroy`, `lane:...`). A PIP asserting that one **means** the other
|
|
would be authoring policy semantics over two vocabularies it does not own —
|
|
the same layer boundary flex-auth invoked against its own composed object in
|
|
`FLEX-DEC-2026-006`.
|
|
- The failure is asymmetric. A wrong mapping silently accepts a claim approved
|
|
for a *different* action, which is strictly worse than no mapping.
|
|
|
|
`binding.pdp_digest` is the mapping, and it is stronger than a name table
|
|
because it does not translate at all:
|
|
|
|
```text
|
|
claim.binding.pdp_digest == decision.binding.request_digest
|
|
```
|
|
|
|
That compares the PDP's own digest to the PDP's own digest, in one vocabulary,
|
|
with nobody asserting equivalence between two.
|
|
|
|
**`pdp_digest` is now always present and may be null** (`approval-engine`
|
|
commit `6d0dfc8`), so its absence is a stated fact rather than a missing key. A
|
|
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, and
|
|
no vocabulary comparison recovers that.
|
|
|
|
**The gate for `destroy` is therefore: `pdp_digest` non-null and equal.** That
|
|
check is the PEP's, not this package's, for the reason given above — the
|
|
request digest is computed after policy evaluation and a Rego rule cannot see
|
|
it. `secrets-engine` must refuse a null-`pdp_digest` claim on this lane before
|
|
`SECRETS-WP-0007-T04` makes `destroy` reachable.
|
|
|
|
### Decision: one subject, so no `action_not_granted` branch
|
|
|
|
**Decision (`FLEX-WP-0021-T02`, 2026-09-06): the denial ladder stops at
|
|
`unknown_subject` plus `dual_control_required`.**
|
|
|
|
secrets-engine named exactly one calling identity. With one subject holding all
|
|
twelve actions, an `action_not_granted` branch could never fire. Following the
|
|
precedent set in `tenant-engine.write-api.mutate` (`FLEX-WP-0010-T02`), a rule
|
|
that cannot fail is worse than no rule: it reads to a later reviewer as though
|
|
per-action grants were separately controlled when they are not.
|
|
|
|
`dual_control_required` is a real branch — it fires whenever `destroy` arrives
|
|
without a satisfying claim, and the fixtures exercise both sides.
|
|
|
|
**Revisit when** a second calling identity is registered against
|
|
`system: "secrets-engine"`, or when secrets-engine splits its CLI identity by
|
|
lane or stage. Adding the branch then is additive to this package: no consumer
|
|
change and no request-shape change.
|
|
|
|
## Rules
|
|
|
|
```rego
|
|
import future.keywords.contains
|
|
import future.keywords.if
|
|
import future.keywords.in
|
|
|
|
valid_actions := {
|
|
"apply",
|
|
"provision",
|
|
"rotate",
|
|
"verify",
|
|
"handoff",
|
|
"wrap",
|
|
"exec",
|
|
"deactivate",
|
|
"suspend",
|
|
"destroy",
|
|
"compromise",
|
|
"reactivate",
|
|
}
|
|
|
|
dual_control_actions := {"destroy"}
|
|
|
|
known_subjects := {"secrets-engine"}
|
|
|
|
decision := {"effect": "allow", "reason": "catalog_lane_policy_matched"} if {
|
|
allowed
|
|
} else := {"effect": "deny", "reason": first_denial} if {
|
|
true
|
|
}
|
|
|
|
well_formed if {
|
|
input.resource.system == "secrets-engine"
|
|
input.resource.type == "secret-catalog-lane"
|
|
input.action in valid_actions
|
|
input.subject.type == "service"
|
|
input.subject.id in known_subjects
|
|
}
|
|
|
|
allowed if {
|
|
well_formed
|
|
not input.action in dual_control_actions
|
|
}
|
|
|
|
allowed if {
|
|
well_formed
|
|
input.action in dual_control_actions
|
|
dual_control_satisfied
|
|
}
|
|
|
|
dual_control_satisfied if {
|
|
claim := input.context.approval
|
|
claim.kind == "approval-claim"
|
|
claim.issuer == "approval-engine"
|
|
claim.valid_now == true
|
|
}
|
|
|
|
default first_denial := "no_matching_rule"
|
|
|
|
first_denial := "wrong_system" if {
|
|
input.resource.system != "secrets-engine"
|
|
} else := "wrong_resource_type" if {
|
|
input.resource.type != "secret-catalog-lane"
|
|
} else := "unknown_action" if {
|
|
not input.action in valid_actions
|
|
} else := "wrong_subject_type" if {
|
|
input.subject.type != "service"
|
|
} else := "unknown_subject" if {
|
|
not input.subject.id in known_subjects
|
|
} else := "dual_control_required" if {
|
|
input.action in dual_control_actions
|
|
}
|
|
```
|
|
|
|
## Tests
|
|
|
|
```rego test
|
|
package flexauth.secrets_engine.catalog_lane_test
|
|
|
|
import future.keywords.every
|
|
import future.keywords.if
|
|
import future.keywords.in
|
|
import data.flexauth.secrets_engine.catalog_lane
|
|
|
|
lane(action) := {
|
|
"id": "check:secrets-engine",
|
|
"tenant": "tenant:platform",
|
|
"subject": {"id": "secrets-engine", "type": "service"},
|
|
"action": action,
|
|
"resource": {
|
|
"id": "lane:glas-primary",
|
|
"type": "secret-catalog-lane",
|
|
"system": "secrets-engine",
|
|
"attributes": {"stage": "prod", "fields": [], "policy_targets": [], "auth_targets": []}
|
|
},
|
|
"context": {}
|
|
}
|
|
|
|
# A complete approval-claim, matching every required field of
|
|
# approval-engine/schemas/approval_claim.schema.json. Partial claims in a
|
|
# fixture are how a consumer learns the wrong shape.
|
|
valid_claim := {
|
|
"schema_version": "0.1",
|
|
"kind": "approval-claim",
|
|
"issuer": "approval-engine",
|
|
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
|
"state": "valid",
|
|
"valid_now": true,
|
|
"consumed": false,
|
|
"binding": {
|
|
"action": "secrets.kv.destroy",
|
|
"target": {"id": "lane-openbao-root", "stage": "prod"},
|
|
"actor": "agt-secrets-engine",
|
|
"principal": "bernd",
|
|
"purpose": "rotate-exposed-key",
|
|
"digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
|
|
"pdp_digest": "sha256:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
|
|
},
|
|
"freshness": {
|
|
"observed_at": "2026-09-06T12:00:00+00:00",
|
|
"ttl_seconds": 30,
|
|
"not_after": "2026-09-06T12:00:30+00:00"
|
|
},
|
|
"validity": {
|
|
"not_before": "2026-09-06T11:00:00+00:00",
|
|
"expires_at": "2026-09-06T15:00:00+00:00"
|
|
},
|
|
"reason_code": "ok"
|
|
}
|
|
|
|
destroy_with(claim) := object.union(lane("destroy"), {"context": {"approval": claim}})
|
|
|
|
approved_destroy := destroy_with(valid_claim)
|
|
|
|
test_apply_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("apply")
|
|
}
|
|
|
|
test_provision_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("provision")
|
|
}
|
|
|
|
test_rotate_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("rotate")
|
|
}
|
|
|
|
test_verify_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("verify")
|
|
}
|
|
|
|
test_handoff_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("handoff")
|
|
}
|
|
|
|
test_wrap_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("wrap")
|
|
}
|
|
|
|
test_exec_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("exec")
|
|
}
|
|
|
|
test_deactivate_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("deactivate")
|
|
}
|
|
|
|
test_suspend_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("suspend")
|
|
}
|
|
|
|
test_compromise_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("compromise")
|
|
}
|
|
|
|
test_reactivate_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as lane("reactivate")
|
|
}
|
|
|
|
test_destroy_with_dual_control_allowed if {
|
|
catalog_lane.decision.effect == "allow" with input as approved_destroy
|
|
}
|
|
|
|
test_destroy_without_claim_denied if {
|
|
catalog_lane.decision.reason == "dual_control_required" with input as lane("destroy")
|
|
}
|
|
|
|
test_destroy_insufficient_approvers_denied if {
|
|
catalog_lane.decision.reason == "dual_control_required" with input as destroy_with(
|
|
object.union(valid_claim, {"state": "requested", "valid_now": false, "reason_code": "insufficient_approvers"})
|
|
)
|
|
}
|
|
|
|
test_destroy_consumed_claim_denied if {
|
|
catalog_lane.decision.reason == "dual_control_required" with input as destroy_with(
|
|
object.union(valid_claim, {"state": "consumed", "valid_now": false, "consumed": true, "reason_code": "consumed"})
|
|
)
|
|
}
|
|
|
|
test_destroy_revoked_claim_denied if {
|
|
catalog_lane.decision.reason == "dual_control_required" with input as destroy_with(
|
|
object.union(valid_claim, {"state": "revoked", "valid_now": false, "reason_code": "revoked"})
|
|
)
|
|
}
|
|
|
|
test_destroy_approved_but_not_valid_now_denied if {
|
|
catalog_lane.decision.reason == "dual_control_required" with input as destroy_with(
|
|
object.union(valid_claim, {"state": "approved", "valid_now": false, "reason_code": "not_yet_valid"})
|
|
)
|
|
}
|
|
|
|
test_destroy_foreign_issuer_denied if {
|
|
catalog_lane.decision.reason == "dual_control_required" with input as destroy_with(
|
|
object.union(valid_claim, {"issuer": "some-other-engine"})
|
|
)
|
|
}
|
|
|
|
test_destroy_wrong_kind_denied if {
|
|
catalog_lane.decision.reason == "dual_control_required" with input as destroy_with(
|
|
object.union(valid_claim, {"kind": "action-authorization"})
|
|
)
|
|
}
|
|
|
|
test_revoke_is_not_an_action if {
|
|
catalog_lane.decision.reason == "unknown_action" with input as lane("revoke")
|
|
}
|
|
|
|
test_ungated_surfaces_are_not_actions if {
|
|
every surface in ["plan", "route", "audit", "catalog", "decision", "evidence", "inspect"] {
|
|
catalog_lane.decision.reason == "unknown_action" with input as lane(surface)
|
|
}
|
|
}
|
|
|
|
test_unknown_subject_denied if {
|
|
catalog_lane.decision.reason == "unknown_subject" with input as object.union(
|
|
lane("rotate"),
|
|
{"subject": {"id": "some-other-service", "type": "service"}}
|
|
)
|
|
}
|
|
|
|
test_wrong_subject_type_denied if {
|
|
catalog_lane.decision.reason == "wrong_subject_type" with input as object.union(
|
|
lane("rotate"),
|
|
{"subject": {"id": "secrets-engine", "type": "human"}}
|
|
)
|
|
}
|
|
|
|
test_wrong_system_denied if {
|
|
catalog_lane.decision.reason == "wrong_system" with input as object.union(
|
|
lane("rotate"),
|
|
{"resource": {"id": "lane:glas-primary", "type": "secret-catalog-lane", "system": "some-other-system"}}
|
|
)
|
|
}
|
|
|
|
test_wrong_resource_type_denied if {
|
|
catalog_lane.decision.reason == "wrong_resource_type" with input as object.union(
|
|
lane("rotate"),
|
|
{"resource": {"id": "lane:glas-primary", "type": "secret", "system": "secrets-engine"}}
|
|
)
|
|
}
|
|
```
|