flex-auth/examples/secrets-engine/policy_package.md
tegwick f75db59a8c
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Container Image / build-and-push (push) Successful in 52s
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

12 KiB

id name namespace version status package allow_ttl actions owner fixtures caring activation metadata
secrets-engine.catalog-lane.lifecycle secrets-engine catalog-lane lifecycle authorization secrets-engine:secret-catalog-lane v1 ready flexauth.secrets_engine.catalog_lane 15m
apply
provision
rotate
verify
handoff
wrap
exec
deactivate
suspend
destroy
compromise
reactivate
team:platform-security
policy_fixtures.yaml
profile enforce canonical_roles organization_relations scopes planes capabilities exposure_modes conditions restrictions
caring-0.4.0-rc2 false
Operator
ServiceProvider
level id tenant
Platform platform:secrets-engine tenant:platform
Secret
Policy
Audit
Create
EditAny
Execute
Archive
Restore
View
Audit
Metadata
DualApprovalRequired
PrivilegeEscalationBlocked
mode
local
source flex_auth_contract action_vocabulary
examples/secrets-engine/policy_package.md protected-system-v0 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.

The four that an inferred list would have gotten wrong are encoded here, not assumed:

  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, marked approved, naming at least two distinct approver subject ids.

flex-auth consumes that 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. Concretely, this package checks that the claim says approved with distinct approvers. It does not re-derive the claim's temporal validity, its signature, or its supersession state — those are approval-engine's to assert and the PEP's to verify against the live claim. A PDP that re-derived them from a caller-supplied blob would be inventing an authority it does not have.

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

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
}

approver_ids := {id |
  some entry in input.context.approval.approvals
  id := entry.subject_id
}

dual_control_satisfied if {
  input.context.approval.status == "approved"
  count(approver_ids) >= 2
}

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

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": {}
}

approved_destroy := object.union(lane("destroy"), {"context": {"approval": {
  "status": "approved",
  "approvals": [{"subject_id": "human:alice"}, {"subject_id": "human:bob"}]
}}})

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_single_approver_denied if {
  catalog_lane.decision.reason == "dual_control_required" with input as object.union(
    lane("destroy"),
    {"context": {"approval": {
      "status": "approved",
      "approvals": [{"subject_id": "human:alice"}]
    }}}
  )
}

test_destroy_repeated_approver_counts_once if {
  catalog_lane.decision.reason == "dual_control_required" with input as object.union(
    lane("destroy"),
    {"context": {"approval": {
      "status": "approved",
      "approvals": [{"subject_id": "human:alice"}, {"subject_id": "human:alice"}]
    }}}
  )
}

test_destroy_unapproved_claim_denied if {
  catalog_lane.decision.reason == "dual_control_required" with input as object.union(
    lane("destroy"),
    {"context": {"approval": {
      "status": "pending",
      "approvals": [{"subject_id": "human:alice"}, {"subject_id": "human:bob"}]
    }}}
  )
}

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