flex-auth/examples/secrets-engine/policy_package.md
tegwick d98323b2bb
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 41s
fix(secrets-engine): v2 adds the tenant rule v1 never had
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
2026-09-06 20:38:45 +02:00

560 lines
20 KiB
Markdown

---
id: secrets-engine.catalog-lane.lifecycle
name: secrets-engine catalog-lane lifecycle authorization
namespace: secrets-engine:secret-catalog-lane
version: v2
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`".
## 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:
```text
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
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"}
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
```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")
}
# 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"}}
)
}
```