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
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 |
|
team:platform-security |
|
|
|
|
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:
revokeis not an action. The CLI verbrevokegates asdeactivate, which is also reached fromlifecycle deactivate. There is norevokevalue and one must not be added —test_revoke_is_not_an_actionasserts it deniesunknown_action.destroyis defined but not reachable live. Its handler raises before the gate, so only--dry-runrenders today. It stays in the vocabulary as the dual-control case; no livedestroyCheck arrives untilSECRETS-WP-0007-T04lands.compromiseandreactivatetouch no OpenBao object. They mutate local overlay state only. They are gated because they change delivery posture, not because they write to the backend.- Seven CLI surfaces never reach the gate and must not appear here:
plan,apply --dry-run,route,audit,catalog,decision inspect, andevidence.test_ungated_surfaces_are_not_actionsasserts 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"}}
)
}