secrets-engine.catalog-lane.lifecycle v1 contained no reference to input.tenant — not in well_formed, not in the denial ladder, not in a test. A rotate on lane:glas-primary under tenant:coulomb returned allow against the deployed package (decision:066e629bbf0c0924). Found while answering glas-harness's tenant-alignment request, which had asked for wrong-tenant denial evidence. There was none to return. Three covers failed the same way: every one of the 29 fixtures carried tenant:platform, so the suite could not report on the field; T02's own gate named "wrong-tenant deny" and was recorded done unmet; and the engine hashes tenant into request_digest but never compares it. Four other published packages carry the branch — this one was the outlier. v2 adds wrong_tenant above wrong_system, three Rego tests and three fixtures (28/28, 32/32). The absent-tenant test caught a second defect in the first draft: a bare input.tenant != comparison is undefined on a missing key, so the branch dropped and the ladder reported the wrong rung. request_tenant := object.get(input, "tenant", "") fixes it. v2 supersedes rather than amends v1 because the defect failed open: a consumer pinned to _VERSION=v1 would keep receiving allows with no signal the rule beneath the version string had changed. The earlier dual-control correction stayed at v1 because it denied everything. Replay envelopes regenerated at v2; both request_digest values are byte-identical, so secrets-engine's digest join needs no re-pinning. The sweep this prompted found tenant-engine unscoped on tenant as well — deployed, and verified allowing tenant:coulomb. Not the same fix: its request tenant names the target rather than the caller, so a constant would break it. Recorded and carried by FLEX-WP-0022 rather than patched unilaterally. FLEX-WP-0021 closes at T05; the pin still serves v1 until a redeploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014aQMM1dPXaPiXVn6DwwtLd Assistant: claude-code Assistant-Model: opus Assistant-Process: 715613@bnt-lap001 Assistant-Session: fabd95c1-4c9e-4080-8849-8707ae025f80
20 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 | v2 | 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 — 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".
Correction, 2026-09-06 — v1 had no tenant rule at all, and it failed open
v1 shipped and deployed without a single reference to input.tenant. Every
fixture and every check request carried tenant: tenant:platform, so nothing
in the package's own coverage could notice, and FLEX-WP-0021-T02's stated
gate — "wrong-tenant deny" among the required fixtures — was recorded as met
when it was not. A rotate on lane:glas-primary sent under
tenant: tenant:coulomb returned allow, catalog_lane_policy_matched,
against the deployed package:
decision:066e629bbf0c0924 effect: allow policy_version: v1
binding.tenant: tenant:coulomb
Every other published package in this repo has the branch —
railiance-platform, qonto-assistant, ops-warden, user-engine. This one
was the outlier, and the engine does not supply the check: tenant is hashed
into binding.request_digest and carried in the decision record, but nothing
in the evaluation path compares it. Tenant scoping is the policy package's
job, and a package that omits it is not scoped to a tenant at all.
Why this is v2 and the dual-control correction stayed v1
The correction below rewrote an unsatisfiable rule: it denied everything, so
no consumer could have relied on it and nothing had been wrongly allowed. This
one runs the other way. A consumer that had already pinned
SECRETS_ENGINE_AUTHORIZATION_POLICY_VERSION=v1 would keep getting allows it
should never have had, and could not tell from the version string that the
rule changed underneath it. A fail-open correction has to be visible as a
version change; a fail-closed one does not. v1 is superseded, not amended.
The four traps
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, 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 isapproval-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.actionandbinding.targetuseapproval-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 inFLEX-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:
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
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"}
known_tenant := "tenant:platform"
# Read through object.get: an absent `tenant` makes a bare `input.tenant !=`
# comparison undefined, which drops the branch and reports `no_matching_rule`
# instead of the real cause. Defaulting to "" keeps the ladder truthful.
request_tenant := object.get(input, "tenant", "")
decision := {"effect": "allow", "reason": "catalog_lane_policy_matched"} if {
allowed
} else := {"effect": "deny", "reason": first_denial} if {
true
}
well_formed if {
request_tenant == known_tenant
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_tenant" if {
request_tenant != known_tenant
} else := "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": {}
}
# 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")
}
# The tenant branch is checked on an otherwise-valid request, so a pass proves
# the tenant alone carried the denial rather than some other malformed field.
test_wrong_tenant_denied if {
catalog_lane.decision.reason == "wrong_tenant" with input as object.union(
lane("rotate"), {"tenant": "tenant:coulomb"}
)
}
# A tenant-less request must not fall through to allow. `input.tenant` is
# absent rather than empty, so the comparison has to hold on a missing key.
test_absent_tenant_denied if {
catalog_lane.decision.reason == "wrong_tenant" with input as object.remove(
lane("rotate"), {"tenant"}
)
}
# Wrong tenant outranks the dual-control branch: a foreign tenant presenting a
# perfectly valid approval is denied for the tenant, not asked for a better claim.
test_wrong_tenant_outranks_dual_control if {
catalog_lane.decision.reason == "wrong_tenant" with input as object.union(
approved_destroy, {"tenant": "tenant:coulomb"}
)
}
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"}}
)
}