Add schemaguard; correct check_request subject.type against shipped reality
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 53s

approval-engine suggested a conformance check after the examples-contradict-
prose class hit a third repository -- theirs. Fifteen lines, they said, and
it would have caught our caring fixture and our provenance omission. Worth
stealing, so we stole it.

internal/schemaguard validates published examples against published schemas.
It implements only the JSON Schema subset these schemas use, and the property
that makes it trustworthy is that an unrecognised keyword FAILS rather than
skips: a validator that silently approves what it does not understand invites
reliance it cannot support. It found three things on first run.

ONE, AND THE LARGEST: check_request.schema.json pointed subject.type at
CARING's subject_type enum (Human, Service, ...), and no consumer sends that
vocabulary. user-engine sends human, tenant-engine and secrets-engine send
service, and ops-warden sends adm/agt/atm -- an actor-type vocabulary CARING
does not model at all. Our published schema declared three live integrations
non-conformant. A rule that outlaws shipped correct behaviour is the rule
that is wrong, so the $ref is replaced with an opaque non-empty string and a
description saying why. CARING's enum remains correct where it belongs: the
registry's subject_manifest.yaml, where Service is right.

TWO: policy_package_note, which this session added to the caring example's
decision provenance, is undeclared under additionalProperties:false. Our own
annotation broke the conformance it was annotating. Moved to the envelope's
outer provenance.

THREE: the secrets-engine fixtures carried partial approval-claims, missing
binding, freshness and validity. A partial claim in a fixture is how a
consumer learns the wrong shape -- the same mechanism that produced the
destroy defect. They are now complete and valid against approval-engine's
schema, including the now-required binding.pdp_digest, and a test validates
them against that schema when the sibling repo is present.

The destroy replay fixture is regenerated accordingly and the replay README's
pinned digests updated, since a stale digest table is the same defect wearing
a different hat.

Closed the binding-mapping open item. approval-engine declined to publish a
vocabulary mapping and their reasoning is better than the request: a PIP
asserting secrets.kv.destroy MEANS destroy would author semantics over two
vocabularies it owns neither of, and a wrong mapping silently accepts a claim
approved for a different action. pdp_digest is the mapping precisely because
it does not translate. It is now always present and nullable, so the destroy
gate is pdp_digest non-null and equal, enforced at the PEP.

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
This commit is contained in:
tegwick 2026-09-06 14:21:28 +02:00
parent c3ede0b494
commit 9f3e7e363a
10 changed files with 761 additions and 36 deletions

View file

@ -26,6 +26,27 @@
"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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
},
"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"
}
}

View file

@ -345,6 +345,27 @@
"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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
},
"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"
}
}
@ -412,6 +433,27 @@
"state": "requested",
"valid_now": false,
"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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
},
"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": "insufficient_approvers"
}
}
@ -451,6 +493,27 @@
"state": "consumed",
"valid_now": false,
"consumed": true,
"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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
},
"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": "consumed"
}
}
@ -490,6 +553,27 @@
"state": "revoked",
"valid_now": false,
"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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
},
"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": "revoked"
}
}
@ -529,6 +613,27 @@
"state": "approved",
"valid_now": false,
"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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
},
"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": "not_yet_valid"
}
}
@ -568,6 +673,27 @@
"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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
},
"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"
}
}
@ -607,6 +733,27 @@
"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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
},
"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"
}
}

View file

@ -169,23 +169,47 @@ validates across both.
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.
- **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.
### Open item: no published binding mapping
### The binding mapping: `pdp_digest` is the answer, and there will be no table
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.
**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:
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`.
- 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
@ -305,6 +329,9 @@ lane(action) := {
"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",
@ -313,6 +340,24 @@ valid_claim := {
"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:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
},
"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"
}

View file

@ -23,10 +23,10 @@ go run ./cmd/flex-auth check \
| Field | `rotate` | `destroy` |
| --- | --- | --- |
| `binding.request_digest` | `sha256:de67324f…4345` | `sha256:570d1128…7f56` |
| `binding.request_digest` | `sha256:de67324f…4345` | `sha256:fc155db…bdf3` |
| `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` |
| `provenance.input_claim_digests.context` | absent (empty context) | `sha256:b0d2203…c221` |
Verified identical across two runs.
@ -39,6 +39,12 @@ 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`.
The `destroy` request's `context.approval` is a **complete** approval-claim,
valid against `approval-engine/schemas/approval_claim.schema.json` including the
now-required `binding.pdp_digest`. It was regenerated on 2026-09-06 when that
claim was completed, so its digests differ from the first emission — a partial
claim in a fixture is how a consumer learns the wrong shape.
## Not a deployment
These come from `flex-auth/local` in `standalone` mode

View file

@ -1,5 +1,5 @@
{
"id": "decision:669a12badaa3d82e",
"id": "decision:395efe37c5066e8a",
"contract_version": "flex-auth.decision-record.v1",
"request_id": "check:secrets-engine-destroy",
"effect": "allow",
@ -68,22 +68,43 @@
"context": {
"approval": {
"approval_id": "3d1c0a8e-6b7f-4c21-9a0e-1f2b3c4d5e6f",
"binding": {
"action": "secrets.kv.destroy",
"actor": "agt-secrets-engine",
"digest": "sha256:3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f3f",
"pdp_digest": "sha256:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56",
"principal": "bernd",
"purpose": "rotate-exposed-key",
"target": {
"id": "lane-openbao-root",
"stage": "prod"
}
},
"consumed": false,
"freshness": {
"not_after": "2026-09-06T12:00:30+00:00",
"observed_at": "2026-09-06T12:00:00+00:00",
"ttl_seconds": 30
},
"issuer": "approval-engine",
"kind": "approval-claim",
"reason_code": "ok",
"schema_version": "0.1",
"state": "valid",
"valid_now": true
"valid_now": true,
"validity": {
"expires_at": "2026-09-06T15:00:00+00:00",
"not_before": "2026-09-06T11:00:00+00:00"
}
}
},
"request_digest": "sha256:570d112890586d3cbf00c0e81c85ae7806f40f00afa1a2c0a23fd5e077a27f56"
"request_digest": "sha256:fc155dba88f8ab18b3032ed086ba2f455158c6981106e7829d520ab7b036bdf3"
},
"lifetime": {
"kind": "ttl",
"ttl": "15m",
"not_before": "2026-09-06T06:13:43Z",
"expires_at": "2026-09-06T06:28:43Z"
"not_before": "2026-09-06T12:18:21Z",
"expires_at": "2026-09-06T12:33:21Z"
},
"diagnostics": {
"action": "destroy",
@ -101,9 +122,9 @@
"policy_package_digest": "sha256:fe0070b79f66442ae6c218697a49c470c6c8f670aa57a30c078a5284d097bd8c",
"registry_snapshot_digest": "sha256:f5a309bc0b36721fd6d9ad7f53eb21222162bc2eac62a0ab0802a9a1d51340bb",
"input_claim_digests": {
"context": "sha256:45fa9f41271c684f2c26570be11869a8eda28e7b3b25f11e01f9378b6f89fdc8"
"context": "sha256:b0d2203cd2b43a9ba573c21c038154c131afba4255e313affd7ecf810e2cc221"
},
"decision_time": "2026-09-06T06:13:43Z"
"decision_time": "2026-09-06T12:18:21Z"
},
"caring": {
"profile": "caring-0.4.0-rc2",