Compare commits
2 commits
6bf5eb7ff9
...
9e10d1cec1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e10d1cec1 | |||
| 68ad039a3f |
10 changed files with 599 additions and 100 deletions
|
|
@ -78,14 +78,44 @@ it does not have.
|
|||
|
||||
## Dual control on `destroy`
|
||||
|
||||
`destroy` requires `context.approval` marked `approved` with at least two
|
||||
distinct approver subject ids. Repeated entries from one subject count once.
|
||||
`destroy` requires an **approval-claim** on `context.approval`, issued by
|
||||
`approval-engine`, whose `valid_now` is `true`. That is the whole check.
|
||||
|
||||
flex-auth consumes that claim as an input claim and never mutates it
|
||||
The shape is `approval-engine/schemas/approval_claim.schema.json`.
|
||||
`valid_now` is a summary predicate — true only when the object is approved,
|
||||
inside its validity window, and not consumed, superseded, revoked, or expired —
|
||||
and **the distinct-approver threshold is folded into it**. A claim that failed
|
||||
the threshold returns `reason_code: insufficient_approvers`.
|
||||
|
||||
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 the decision is step 2; the PEP
|
||||
validates across both. This package checks that the claim *says* approved with
|
||||
distinct approvers. It does **not** re-derive the claim's temporal validity,
|
||||
signature, or supersession state — those belong to `approval-engine` to assert
|
||||
and to the PEP 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.
|
||||
validates across both.
|
||||
|
||||
The package does **not** count approvers, re-derive validity/freshness/
|
||||
signature/supersession, or compare `binding.pdp_digest`. Counting approvers is
|
||||
the duplication the split removes (`GH-DEC-2026-005`); the compensating property
|
||||
is reconstructability at the issuer under §9.6, which is `approval-engine`'s.
|
||||
The digest comparison is real and preferred but the request digest is computed
|
||||
after policy evaluation, so a Rego rule cannot see it — it belongs in the PEP.
|
||||
|
||||
### Correction, 2026-09-06
|
||||
|
||||
The first published rule required `context.approval.status == "approved"` and
|
||||
counted `context.approval.approvals[].subject_id`. Neither field exists: the
|
||||
claim's field is `state` (and `valid`, not `approved`, is the operative value),
|
||||
and it carries no approver list. The rule was unsatisfiable — every live
|
||||
`destroy` would have denied `dual_control_required` regardless of the approval.
|
||||
It failed closed, so it was never a hole, but it was a policy written against an
|
||||
invented shape. Corrected the same day.
|
||||
|
||||
### Open item: no published binding mapping
|
||||
|
||||
The claim's `binding.action` and `binding.target` use `approval-engine`'s
|
||||
vocabulary (`secrets.kv.destroy`, `{"id": ..., "stage": ...}`), not this one
|
||||
(`destroy`, `lane:...`). No mapping between them is published, so the package
|
||||
makes no cross-check that the claim was approved for *this* action and target,
|
||||
and must not invent one — a wrong mapping would silently accept a claim approved
|
||||
for something else. Until a mapping exists that correspondence is the PEP's, via
|
||||
`binding.pdp_digest`. Worth closing before `SECRETS-WP-0007-T04` makes `destroy`
|
||||
reachable.
|
||||
|
|
|
|||
|
|
@ -107,6 +107,11 @@
|
|||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v1",
|
||||
"policy_package_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"registry_snapshot_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
"input_claim_digests": {
|
||||
"context": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
},
|
||||
"decision_time": "2026-08-23T10:00:30Z",
|
||||
"policy_package_note": "Reserved coordinate, not yet published (FLEX-DEC-2026-005). Illustrative only."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ catalog-lane operations. Opened by `FLEX-DEC-2026-005`, carried by
|
|||
| `protected_system_manifest.yaml` | the `secret-catalog-lane` resource type and twelve actions |
|
||||
| `subject_manifest.yaml` | the single `secrets-engine` service identity |
|
||||
| `registry_snapshot.json` | loadable snapshot combining both manifests |
|
||||
| `policy_fixtures.yaml` | 26 fixtures — 11 allows, dual control both ways, and every denial branch |
|
||||
| `policy_fixtures.yaml` | 29 fixtures — 11 allows, dual control both ways, and every denial branch |
|
||||
| `check_request_*.json` | standalone requests for `POST /v1/check` |
|
||||
|
||||
The action vocabulary is **secrets-engine's**, delivered under
|
||||
|
|
@ -25,7 +25,7 @@ go run ./cmd/flex-auth validate -kind policy -file examples/secrets-engine/polic
|
|||
go run ./cmd/flex-auth load-registry -file examples/secrets-engine/registry_snapshot.json
|
||||
```
|
||||
|
||||
22 Rego tests and 26 fixtures.
|
||||
25 Rego tests and 29 fixtures.
|
||||
|
||||
## Not yet deployed
|
||||
|
||||
|
|
|
|||
|
|
@ -19,15 +19,14 @@
|
|||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"status": "approved",
|
||||
"approvals": [
|
||||
{
|
||||
"subject_id": "human:alice"
|
||||
},
|
||||
{
|
||||
"subject_id": "human:bob"
|
||||
}
|
||||
]
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"state": "valid",
|
||||
"valid_now": true,
|
||||
"consumed": false,
|
||||
"reason_code": "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -338,15 +338,14 @@
|
|||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"status": "approved",
|
||||
"approvals": [
|
||||
{
|
||||
"subject_id": "human:alice"
|
||||
},
|
||||
{
|
||||
"subject_id": "human:bob"
|
||||
}
|
||||
]
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"state": "valid",
|
||||
"valid_now": true,
|
||||
"consumed": false,
|
||||
"reason_code": "ok"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -384,7 +383,7 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"id": "fixture:secrets-engine-destroy-single-approver-deny",
|
||||
"id": "fixture:secrets-engine-destroy-insufficient-approvers-deny",
|
||||
"request": {
|
||||
"id": "check:secrets-engine-destroy",
|
||||
"tenant": "tenant:platform",
|
||||
|
|
@ -406,12 +405,14 @@
|
|||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"status": "approved",
|
||||
"approvals": [
|
||||
{
|
||||
"subject_id": "human:alice"
|
||||
}
|
||||
]
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"state": "requested",
|
||||
"valid_now": false,
|
||||
"consumed": false,
|
||||
"reason_code": "insufficient_approvers"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -421,7 +422,7 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"id": "fixture:secrets-engine-destroy-repeated-approver-deny",
|
||||
"id": "fixture:secrets-engine-destroy-consumed-claim-deny",
|
||||
"request": {
|
||||
"id": "check:secrets-engine-destroy",
|
||||
"tenant": "tenant:platform",
|
||||
|
|
@ -443,15 +444,14 @@
|
|||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"status": "approved",
|
||||
"approvals": [
|
||||
{
|
||||
"subject_id": "human:alice"
|
||||
},
|
||||
{
|
||||
"subject_id": "human:alice"
|
||||
}
|
||||
]
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"state": "consumed",
|
||||
"valid_now": false,
|
||||
"consumed": true,
|
||||
"reason_code": "consumed"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -461,7 +461,7 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"id": "fixture:secrets-engine-destroy-pending-claim-deny",
|
||||
"id": "fixture:secrets-engine-destroy-revoked-claim-deny",
|
||||
"request": {
|
||||
"id": "check:secrets-engine-destroy",
|
||||
"tenant": "tenant:platform",
|
||||
|
|
@ -483,15 +483,131 @@
|
|||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"status": "pending",
|
||||
"approvals": [
|
||||
{
|
||||
"subject_id": "human:alice"
|
||||
},
|
||||
{
|
||||
"subject_id": "human:bob"
|
||||
}
|
||||
]
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"state": "revoked",
|
||||
"valid_now": false,
|
||||
"consumed": false,
|
||||
"reason_code": "revoked"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"effect": "deny",
|
||||
"reason": "dual_control_required"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fixture:secrets-engine-destroy-approved-not-valid-now-deny",
|
||||
"request": {
|
||||
"id": "check:secrets-engine-destroy",
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service"
|
||||
},
|
||||
"action": "destroy",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"attributes": {
|
||||
"stage": "prod",
|
||||
"fields": [],
|
||||
"policy_targets": [],
|
||||
"auth_targets": []
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"state": "approved",
|
||||
"valid_now": false,
|
||||
"consumed": false,
|
||||
"reason_code": "not_yet_valid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"effect": "deny",
|
||||
"reason": "dual_control_required"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fixture:secrets-engine-destroy-foreign-issuer-deny",
|
||||
"request": {
|
||||
"id": "check:secrets-engine-destroy",
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service"
|
||||
},
|
||||
"action": "destroy",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"attributes": {
|
||||
"stage": "prod",
|
||||
"fields": [],
|
||||
"policy_targets": [],
|
||||
"auth_targets": []
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"schema_version": "0.1",
|
||||
"kind": "approval-claim",
|
||||
"issuer": "some-other-engine",
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"state": "valid",
|
||||
"valid_now": true,
|
||||
"consumed": false,
|
||||
"reason_code": "ok"
|
||||
}
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"effect": "deny",
|
||||
"reason": "dual_control_required"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fixture:secrets-engine-destroy-wrong-kind-deny",
|
||||
"request": {
|
||||
"id": "check:secrets-engine-destroy",
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service"
|
||||
},
|
||||
"action": "destroy",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"attributes": {
|
||||
"stage": "prod",
|
||||
"fields": [],
|
||||
"policy_targets": [],
|
||||
"auth_targets": []
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"schema_version": "0.1",
|
||||
"kind": "action-authorization",
|
||||
"issuer": "approval-engine",
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"state": "valid",
|
||||
"valid_now": true,
|
||||
"consumed": false,
|
||||
"reason_code": "ok"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -79,7 +79,24 @@ 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:
|
||||
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`".
|
||||
|
||||
## 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`
|
||||
|
|
@ -125,18 +142,50 @@ 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.
|
||||
an **approval-claim** on `context.approval`, issued by `approval-engine`, whose
|
||||
`valid_now` is `true`.
|
||||
|
||||
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.
|
||||
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 real and
|
||||
preferred (`approval-engine/docs/approval-claim.md`), but the request digest
|
||||
is computed by the engine *after* policy evaluation, so a Rego rule cannot
|
||||
see it. It belongs in the PEP and in the engine, not here.
|
||||
|
||||
### Open item: no published binding mapping
|
||||
|
||||
The claim's `binding.action` and `binding.target` use `approval-engine`'s
|
||||
vocabulary (`secrets.kv.destroy`, `{"id": "lane-openbao-root", "stage": "prod"}`),
|
||||
not this package's (`destroy`, `lane:...`). There is no published mapping
|
||||
between the two, so this package makes **no** cross-check that the claim was
|
||||
approved for *this* action and target — and it must not invent one, because a
|
||||
wrong mapping would silently accept a claim approved for something else.
|
||||
|
||||
Until a mapping is published, that correspondence is the PEP's to enforce via
|
||||
`binding.pdp_digest`. This is worth closing before `destroy` becomes reachable
|
||||
under `SECRETS-WP-0007-T04`.
|
||||
|
||||
### Decision: one subject, so no `action_not_granted` branch
|
||||
|
||||
|
|
@ -208,14 +257,11 @@ allowed if {
|
|||
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
|
||||
claim := input.context.approval
|
||||
claim.kind == "approval-claim"
|
||||
claim.issuer == "approval-engine"
|
||||
claim.valid_now == true
|
||||
}
|
||||
|
||||
default first_denial := "no_matching_rule"
|
||||
|
|
@ -259,10 +305,20 @@ lane(action) := {
|
|||
"context": {}
|
||||
}
|
||||
|
||||
approved_destroy := object.union(lane("destroy"), {"context": {"approval": {
|
||||
"status": "approved",
|
||||
"approvals": [{"subject_id": "human:alice"}, {"subject_id": "human:bob"}]
|
||||
}}})
|
||||
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,
|
||||
"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")
|
||||
|
|
@ -316,33 +372,39 @@ 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_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_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_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_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_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"})
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
47
examples/secrets-engine/replay/README.md
Normal file
47
examples/secrets-engine/replay/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# T03 replay fixtures
|
||||
|
||||
Real `DecisionEnvelope`s emitted from the published package, for secrets-engine
|
||||
to verify its digest join (`627810b`) unchanged. `FLEX-WP-0021-T03`.
|
||||
|
||||
| File | From |
|
||||
| --- | --- |
|
||||
| `decision_rotate.json` | `../check_request_allow_rotate.json` — plain allow, empty context |
|
||||
| `decision_destroy_dual_control.json` | `../check_request_allow_destroy_dual_control.json` — dual control, valid approval-claim |
|
||||
|
||||
Regenerate either with:
|
||||
|
||||
```bash
|
||||
go run ./cmd/flex-auth check \
|
||||
-policy examples/secrets-engine/policy_package.md \
|
||||
-registry examples/secrets-engine/registry_snapshot.json \
|
||||
-request examples/secrets-engine/check_request_allow_rotate.json
|
||||
```
|
||||
|
||||
## What is stable and what is not
|
||||
|
||||
**Stable across runs** — these are the fields to pin a contract test against:
|
||||
|
||||
| Field | `rotate` | `destroy` |
|
||||
| --- | --- | --- |
|
||||
| `binding.request_digest` | `sha256:de67324f…4345` | `sha256:570d1128…7f56` |
|
||||
| `provenance.policy_package_digest` | `sha256:fe0070b7…bd8c` | same |
|
||||
| `provenance.registry_snapshot_digest` | `sha256:f5a309bc…40bb` | same |
|
||||
| `provenance.input_claim_digests.context` | absent (empty context) | `sha256:45fa9f41…fdc8` |
|
||||
|
||||
Verified identical across two runs.
|
||||
|
||||
**Not stable:** `id`, `provenance.decision_time`, and `lifetime.not_before` /
|
||||
`lifetime.expires_at` move with the clock. `lifetime.ttl` is `15m` from the
|
||||
package's `allow_ttl`. Do not pin the record as a whole.
|
||||
|
||||
`input_claim_digests.context` appears only when the request carries a non-empty
|
||||
context — which is why both fixtures are here rather than just one. A consumer
|
||||
asserting the field is always present would pass on `destroy` and fail on
|
||||
`rotate`.
|
||||
|
||||
## Not a deployment
|
||||
|
||||
These come from `flex-auth/local` in `standalone` mode
|
||||
(`provenance.evaluator` / `mode`), not from a cluster pin. No
|
||||
`flex-auth-secrets-engine` pin exists yet (`FLEX-WP-0021-T04`), and the consumer
|
||||
policy pin stays unset until `T05`.
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
"id": "decision:669a12badaa3d82e",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-destroy",
|
||||
"effect": "allow",
|
||||
"reason": "catalog_lane_policy_matched",
|
||||
"matched_policy_version": "v1",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "destroy",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"approval": {
|
||||
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
|
||||
"consumed": false,
|
||||
"issuer": "approval-engine",
|
||||
"kind": "approval-claim",
|
||||
"reason_code": "ok",
|
||||
"schema_version": "0.1",
|
||||
"state": "valid",
|
||||
"valid_now": true
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-06T06:13:43Z",
|
||||
"expires_at": "2026-09-06T06:28:43Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "destroy",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v1",
|
||||
"policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"input_claim_digests": {
|
||||
"context": "sha256:45fa9f41271c684f2c26570be11869a8eda28e7b3b25f11e01f9378b6f89fdc8"
|
||||
},
|
||||
"decision_time": "2026-09-06T06:13:43Z"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
110
examples/secrets-engine/replay/decision_rotate.json
Normal file
110
examples/secrets-engine/replay/decision_rotate.json
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
{
|
||||
"id": "decision:49309356905a2ad3",
|
||||
"contract_version": "flex-auth.decision-record.v1",
|
||||
"request_id": "check:secrets-engine-rotate",
|
||||
"effect": "allow",
|
||||
"reason": "catalog_lane_policy_matched",
|
||||
"matched_policy_version": "v1",
|
||||
"matched_rule": "catalog_lane_policy_matched",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"binding": {
|
||||
"tenant": "tenant:platform",
|
||||
"subject": {
|
||||
"id": "secrets-engine",
|
||||
"type": "service",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"description": "secrets-engine's own service identity, the single calling identity for the twelve gated catalog-lane actions it sends to POST /v1/check. Because it is the only subject, the package has no action_not_granted branch (FLEX-WP-0021-T02); registering a second identity is the revisit trigger.",
|
||||
"display_name": "secrets-engine service principal",
|
||||
"groups": [
|
||||
"group:secrets-engine-lane-operators"
|
||||
],
|
||||
"organization_relation": "ServiceProvider",
|
||||
"roles": [
|
||||
"Operator"
|
||||
]
|
||||
}
|
||||
},
|
||||
"action": "rotate",
|
||||
"resource": {
|
||||
"id": "lane:glas-primary",
|
||||
"type": "secret-catalog-lane",
|
||||
"system": "secrets-engine",
|
||||
"tenant": "tenant:platform",
|
||||
"attributes": {
|
||||
"auth_targets": [],
|
||||
"fields": [
|
||||
"password"
|
||||
],
|
||||
"policy_targets": [],
|
||||
"stage": "prod"
|
||||
}
|
||||
},
|
||||
"request_digest": "sha256:de67324f54187055307a833235f83ced9fcd3a20952a27b3d19493ed39734345"
|
||||
},
|
||||
"lifetime": {
|
||||
"kind": "ttl",
|
||||
"ttl": "15m",
|
||||
"not_before": "2026-09-06T06:13:21Z",
|
||||
"expires_at": "2026-09-06T06:28:21Z"
|
||||
},
|
||||
"diagnostics": {
|
||||
"action": "rotate",
|
||||
"matched_relationship": "",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_status": "ready",
|
||||
"registry_resource": false,
|
||||
"registry_subject": true
|
||||
},
|
||||
"provenance": {
|
||||
"evaluator": "flex-auth/local",
|
||||
"mode": "standalone",
|
||||
"policy_package": "secrets-engine.catalog-lane.lifecycle",
|
||||
"policy_version": "v1",
|
||||
"policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c",
|
||||
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
|
||||
"decision_time": "2026-09-06T06:13:21Z"
|
||||
},
|
||||
"caring": {
|
||||
"profile": "caring-0.4.0-rc2",
|
||||
"conformance_findings": [
|
||||
{
|
||||
"code": "CARING-DESCRIPTOR-MISSING",
|
||||
"severity": "warning",
|
||||
"message": "no CARING descriptor matched the request",
|
||||
"fields": [
|
||||
"caring_context"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -126,7 +126,7 @@ all twelve actions could never reach it.
|
|||
|
||||
```task
|
||||
id: FLEX-WP-0021-T03
|
||||
status: todo
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "8f7e5cdd-777e-5f54-9b92-c72b79f65672"
|
||||
```
|
||||
|
|
@ -143,6 +143,15 @@ Owner: `flex-auth` to emit; `secrets-engine` to verify.
|
|||
Gate: secrets-engine confirms its validator accepts the real record unchanged.
|
||||
A validator change on their side is their work-record, not closed from here.
|
||||
|
||||
**Emitted 2026-09-06**, awaiting their confirmation.
|
||||
`examples/secrets-engine/replay/` carries two real envelopes — a plain allow
|
||||
(`rotate`, empty context) and the dual-control allow (`destroy` with a valid
|
||||
approval-claim). All three digests plus `input_claim_digests.context` verified
|
||||
identical across two runs; `id`, `decision_time`, and the `lifetime` bounds move
|
||||
with the clock and are documented as unpinnable. Both fixtures are included
|
||||
because `input_claim_digests.context` appears only for a non-empty context, so a
|
||||
consumer asserting it is always present would pass on one and fail on the other.
|
||||
|
||||
## 4. Stand up the `flex-auth-secrets-engine` pin
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue