flex-auth/examples/secrets-engine/policy_package.md
tegwick dd3ce4c109
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Build and Publish Container Image / build-and-push (push) Successful in 52s
Publish approval_binding_digest: a claim cannot name the request carrying it
secrets-engine confirmed T03, and re-verifying against the regenerated
destroy fixture found something neither repository can fix alone: a
pdp_digest recorded at issue time can never equal the request_digest of a
request that carries the claim in its context, because the claim is part
of the context that is hashed. Embedding the claim changes the very
digest the claim would need to name.

Not fixture staleness. It holds for every dual-control request whose
claim travels in context -- the shape GH-DEC-2026-008 had just ruled
mandatory. Left unresolved that ruling was unimplementable for exactly
the case it was written for, and destroy would have been permanently
un-allowable in production, failing closed forever on a check that could
never pass.

flex-auth owns the canonical request digest, so the fix is ours.
binding.approval_binding_digest is the same material with
context.approval removed, emitted only when a claim was carried. An
approval issued against a claim-free Check records that Check's
request_digest; the claim-bearing request reproduces it here.

DELIBERATELY ADDITIVE, and the reason matters. The tempting fix is to
drop context.approval from request_digest entirely. That is wrong:
request_digest is the replay identity, and two requests differing only in
which approval was presented must not share one, because their decisions
differ -- one allows, the other denies dual_control_required. Collapsing
them would let an allow obtained with a valid claim be replayed against a
request carrying none. So request_digest still covers the claim and still
moves; approval_binding_digest deliberately does not, and is documented
as not a replay identity. The tests assert the two functions DISAGREE on
a claim-bearing request, which is approval-engine's formulation of how to
defend a distinction that looks like duplication.

The fixture now demonstrates the property rather than asserting it: its
claim's pdp_digest equals the envelope's approval_binding_digest with
pdp_path true, and changing the claim's contents moved request_digest
while leaving approval_binding_digest untouched. Two files a consumer can
diff.

Also picked up approval-engine's new required binding.pdp_path via the
cross-repo schema test added yesterday -- which is the test doing exactly
what it was built for, one day later.

T03 is done. secrets-engine's own digest-material defect, which our two
real envelopes caught, is recorded in the workplan.

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 14:52:33 +02:00

494 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:fa07becfaa471394d06aee5fa3cd66352bf0cc69ef24900240489684cda8cd56",
"pdp_path": true
},
"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"}}
)
}
```