387 lines
12 KiB
Markdown
387 lines
12 KiB
Markdown
|
|
---
|
||
|
|
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:
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
```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
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
|
||
|
|
```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": {}
|
||
|
|
}
|
||
|
|
|
||
|
|
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"}}
|
||
|
|
)
|
||
|
|
}
|
||
|
|
```
|