--- 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"}} ) } ```