Add schemaguard; correct check_request subject.type against shipped reality
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:
parent
c3ede0b494
commit
9f3e7e363a
10 changed files with 761 additions and 36 deletions
|
|
@ -109,13 +109,27 @@ and it carries no approver list. The rule was unsatisfiable — every live
|
|||
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
|
||||
### Closed: `pdp_digest` is the mapping, and there will be no table
|
||||
|
||||
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.
|
||||
`approval-engine` declined to publish an action/target mapping (their commit
|
||||
`6d0dfc8`) and was right to. A PIP asserting that `secrets.kv.destroy` **means**
|
||||
`destroy` would author policy semantics over two vocabularies it does not own —
|
||||
the same layer boundary flex-auth invoked against its own composed object — and
|
||||
the failure is asymmetric: a wrong mapping silently accepts a claim approved for
|
||||
a *different* action, which is worse than no mapping.
|
||||
|
||||
```text
|
||||
claim.binding.pdp_digest == decision.binding.request_digest
|
||||
```
|
||||
|
||||
is the mapping, and it is stronger than a name table because it does not
|
||||
translate: it compares the PDP's own digest to the PDP's own digest, in one
|
||||
vocabulary, with nobody asserting equivalence.
|
||||
|
||||
`binding.pdp_digest` is now **always present and may be null**, so its absence is
|
||||
a stated fact rather than a missing key. 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.
|
||||
|
||||
**The `destroy` gate is therefore: `pdp_digest` non-null and equal**, enforced by
|
||||
the PEP, before `SECRETS-WP-0007-T04` makes `destroy` reachable.
|
||||
|
|
|
|||
|
|
@ -112,13 +112,13 @@
|
|||
"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."
|
||||
"decision_time": "2026-08-23T10:00:30Z"
|
||||
}
|
||||
},
|
||||
"provenance": {
|
||||
"note": "DEFERRED EXAMPLE (FLEX-DEC-2026-006). Not a contract. The approval fact's authority is approval-engine; the decision's authority is flex-auth. State Hub is never the authority for either -- do not derive a provenance.authority constant from this file.",
|
||||
"approval_authority": "approval-engine",
|
||||
"decision_authority": "flex-auth"
|
||||
"decision_authority": "flex-auth",
|
||||
"policy_package_note": "Reserved coordinate secrets-engine.catalog-lane.lifecycle v1, not yet published (FLEX-DEC-2026-005). Illustrative only."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
125
internal/schemaguard/examples_test.go
Normal file
125
internal/schemaguard/examples_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package schemaguard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// repoRoot walks up from the package directory to the module root.
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join("..", "..")
|
||||
}
|
||||
|
||||
// TestPublishedExamplesMatchTheirSchemas is the check approval-engine suggested
|
||||
// after the same defect class hit three repositories: a published example that
|
||||
// contradicts the schema it claims gets implemented in preference to the prose.
|
||||
func TestPublishedExamplesMatchTheirSchemas(t *testing.T) {
|
||||
root := repoRoot(t)
|
||||
cases := []struct {
|
||||
schema string
|
||||
example string
|
||||
}{
|
||||
{"schemas/decision_envelope.schema.json", "examples/caring/decision_envelope.json"},
|
||||
{"schemas/decision_envelope.schema.json", "examples/secrets-engine/replay/decision_rotate.json"},
|
||||
{"schemas/decision_envelope.schema.json", "examples/secrets-engine/replay/decision_destroy_dual_control.json"},
|
||||
{"schemas/action_authorization.schema.json", "examples/caring/action_authorization.json"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.example, func(t *testing.T) {
|
||||
s, err := Load(filepath.Join(root, tc.schema))
|
||||
if err != nil {
|
||||
t.Fatalf("load schema: %v", err)
|
||||
}
|
||||
problems, err := s.ValidateFile(filepath.Join(root, tc.example))
|
||||
if err != nil {
|
||||
t.Fatalf("read example: %v", err)
|
||||
}
|
||||
for _, p := range problems {
|
||||
t.Errorf("%s: %s", tc.example, p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryAllowExampleStatesItsLifetime pins the §9.7.1 obligation that
|
||||
// FLEX-WP-0019 closed. An allow without a lifetime is the gap G3 named, and an
|
||||
// example missing it teaches consumers the pre-WP-0019 shape.
|
||||
func TestEveryAllowExampleStatesItsLifetime(t *testing.T) {
|
||||
root := repoRoot(t)
|
||||
matches, err := filepath.Glob(filepath.Join(root, "examples", "*", "replay", "*.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
extra := filepath.Join(root, "examples", "caring", "decision_envelope.json")
|
||||
if _, err := os.Stat(extra); err == nil {
|
||||
matches = append(matches, extra)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("no decision examples found — this test would pass vacuously")
|
||||
}
|
||||
for _, path := range matches {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
if doc["effect"] != "allow" {
|
||||
continue
|
||||
}
|
||||
if _, ok := doc["lifetime"].(map[string]any); !ok {
|
||||
t.Errorf("%s: allow carries no lifetime (security-layer-model v0.7 §9.7.1)", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestApprovalClaimsInFixturesAreComplete validates the approval-claims embedded
|
||||
// in this repo's check requests against approval-engine's published schema.
|
||||
//
|
||||
// It is skipped when the sibling repository is not checked out, because a
|
||||
// cross-repo path is not a dependency this module can assert. It is worth
|
||||
// running where it can: the destroy rule was first written against an invented
|
||||
// claim shape, and a partial claim in a fixture is how a consumer learns one.
|
||||
func TestApprovalClaimsInFixturesAreComplete(t *testing.T) {
|
||||
schemaPath := filepath.Join(os.Getenv("HOME"), "approval-engine", "schemas", "approval_claim.schema.json")
|
||||
if _, err := os.Stat(schemaPath); err != nil {
|
||||
t.Skipf("approval-engine not checked out at %s", schemaPath)
|
||||
}
|
||||
s, err := Load(schemaPath)
|
||||
if err != nil {
|
||||
t.Fatalf("load claim schema: %v", err)
|
||||
}
|
||||
root := repoRoot(t)
|
||||
matches, err := filepath.Glob(filepath.Join(root, "examples", "*", "check_request_*.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checked := 0
|
||||
for _, path := range matches {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
ctx, _ := doc["context"].(map[string]any)
|
||||
claim, ok := ctx["approval"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
for _, p := range s.Validate(claim) {
|
||||
t.Errorf("%s: context.approval: %s", path, p)
|
||||
}
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Skip("no check request carries context.approval")
|
||||
}
|
||||
}
|
||||
342
internal/schemaguard/schemaguard.go
Normal file
342
internal/schemaguard/schemaguard.go
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
// Package schemaguard validates published example documents against the JSON
|
||||
// Schemas this repository publishes.
|
||||
//
|
||||
// It exists because of a defect class that hit three repositories in one week:
|
||||
// a contract whose examples contradict its prose gets implemented as its
|
||||
// examples. flex-auth shipped a caring fixture asserting an authority the
|
||||
// contract's own ownership section denied, and a decision record naming
|
||||
// flex-auth.decision-record.v1 while omitting three of that contract's
|
||||
// provenance fields. approval-engine suggested the check after the same class
|
||||
// caught its own two published claim examples.
|
||||
//
|
||||
// This is a deliberately small subset of JSON Schema — only the keywords the
|
||||
// schemas here actually use. The property that makes it trustworthy is that an
|
||||
// unrecognised keyword is a FAILURE, not a skip: a validator that silently
|
||||
// approves what it does not understand is worse than no validator, because it
|
||||
// invites reliance it cannot support.
|
||||
package schemaguard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// known lists every keyword this validator implements. Anything else in a
|
||||
// schema is reported rather than ignored.
|
||||
var known = map[string]bool{
|
||||
"$schema": true, "$id": true, "title": true, "description": true,
|
||||
"$defs": true, "$ref": true,
|
||||
"type": true, "required": true, "properties": true,
|
||||
"additionalProperties": true, "const": true, "enum": true,
|
||||
"pattern": true, "items": true, "minLength": true, "format": true,
|
||||
"if": true, "then": true, "allOf": true,
|
||||
"uniqueItems": true, "minimum": true, "maximum": true, "not": true,
|
||||
"minItems": true,
|
||||
}
|
||||
|
||||
// Schema is a parsed JSON Schema document.
|
||||
//
|
||||
// Sibling schemas are resolved by file name from the directory the schema was
|
||||
// loaded from. These schemas reference each other by absolute $id URL, but the
|
||||
// URLs are identifiers rather than fetchable locations, so resolving them over
|
||||
// the network would be both wrong and untestable.
|
||||
type Schema struct {
|
||||
root map[string]any
|
||||
dir string
|
||||
sibling map[string]map[string]any
|
||||
}
|
||||
|
||||
// Load reads a schema from disk.
|
||||
func Load(path string) (*Schema, error) {
|
||||
root, err := loadDoc(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Schema{root: root, dir: filepath.Dir(path), sibling: map[string]map[string]any{}}, nil
|
||||
}
|
||||
|
||||
func loadDoc(path string) (map[string]any, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(raw, &root); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return root, nil
|
||||
}
|
||||
|
||||
// Validate checks doc against the schema, returning every problem found rather
|
||||
// than stopping at the first.
|
||||
func (s *Schema) Validate(doc any) []string {
|
||||
return s.check("", s.root, doc)
|
||||
}
|
||||
|
||||
// ValidateFile reads a JSON document and validates it.
|
||||
func (s *Schema) ValidateFile(path string) ([]string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", path, err)
|
||||
}
|
||||
return s.Validate(doc), nil
|
||||
}
|
||||
|
||||
func at(path string) string {
|
||||
if path == "" {
|
||||
return "(root)"
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func (s *Schema) resolve(doc0 map[string]any, node map[string]any) (map[string]any, map[string]any, []string) {
|
||||
ref, ok := node["$ref"].(string)
|
||||
if !ok {
|
||||
return doc0, node, nil
|
||||
}
|
||||
doc := doc0
|
||||
frag := ref
|
||||
if !strings.HasPrefix(ref, "#") {
|
||||
loc, rest, _ := strings.Cut(ref, "#")
|
||||
name := path.Base(loc)
|
||||
other, cached := s.sibling[name]
|
||||
if !cached {
|
||||
loaded, err := loadDoc(filepath.Join(s.dir, name))
|
||||
if err != nil {
|
||||
return nil, nil, []string{fmt.Sprintf("$ref %q: sibling schema %s not readable in %s: %v", ref, name, s.dir, err)}
|
||||
}
|
||||
s.sibling[name] = loaded
|
||||
other = loaded
|
||||
}
|
||||
doc = other
|
||||
if rest == "" {
|
||||
return other, other, nil
|
||||
}
|
||||
frag = "#" + rest
|
||||
}
|
||||
const prefix = "#/$defs/"
|
||||
if !strings.HasPrefix(frag, prefix) {
|
||||
return nil, nil, []string{fmt.Sprintf("unsupported $ref fragment %q — schemaguard resolves only %s*", frag, prefix)}
|
||||
}
|
||||
defs, _ := doc["$defs"].(map[string]any)
|
||||
target, ok := defs[strings.TrimPrefix(frag, prefix)].(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil, []string{fmt.Sprintf("$ref %q does not resolve", ref)}
|
||||
}
|
||||
return doc, target, nil
|
||||
}
|
||||
|
||||
func (s *Schema) check(path string, node map[string]any, doc any) []string {
|
||||
return s.checkIn(s.root, path, node, doc)
|
||||
}
|
||||
|
||||
func (s *Schema) checkIn(doc0 map[string]any, path string, node map[string]any, doc any) []string {
|
||||
doc0, node, probs := s.resolve(doc0, node)
|
||||
if probs != nil {
|
||||
return probs
|
||||
}
|
||||
|
||||
for k := range node {
|
||||
if !known[k] {
|
||||
probs = append(probs, fmt.Sprintf("%s: schema uses keyword %q, which schemaguard does not implement — extend it rather than trusting this pass", at(path), k))
|
||||
}
|
||||
}
|
||||
|
||||
if want, ok := node["const"]; ok && !equal(want, doc) {
|
||||
probs = append(probs, fmt.Sprintf("%s: must be %v, got %v", at(path), want, doc))
|
||||
}
|
||||
if raw, ok := node["enum"].([]any); ok {
|
||||
hit := false
|
||||
for _, v := range raw {
|
||||
if equal(v, doc) {
|
||||
hit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hit {
|
||||
probs = append(probs, fmt.Sprintf("%s: %v is not one of %v", at(path), doc, raw))
|
||||
}
|
||||
}
|
||||
if t, ok := node["type"]; ok {
|
||||
if err := checkType(t, doc); err != "" {
|
||||
probs = append(probs, fmt.Sprintf("%s: %s", at(path), err))
|
||||
return probs // further checks would be noise
|
||||
}
|
||||
}
|
||||
if pat, ok := node["pattern"].(string); ok {
|
||||
if str, isStr := doc.(string); isStr {
|
||||
re, err := regexp.Compile(pat)
|
||||
switch {
|
||||
case err != nil:
|
||||
probs = append(probs, fmt.Sprintf("%s: schema pattern %q does not compile: %v", at(path), pat, err))
|
||||
case !re.MatchString(str):
|
||||
probs = append(probs, fmt.Sprintf("%s: %q does not match %s", at(path), str, pat))
|
||||
}
|
||||
}
|
||||
}
|
||||
if min, ok := node["minimum"].(float64); ok {
|
||||
if n, isNum := doc.(float64); isNum && n < min {
|
||||
probs = append(probs, fmt.Sprintf("%s: %v is below minimum %v", at(path), n, min))
|
||||
}
|
||||
}
|
||||
if max, ok := node["maximum"].(float64); ok {
|
||||
if n, isNum := doc.(float64); isNum && n > max {
|
||||
probs = append(probs, fmt.Sprintf("%s: %v is above maximum %v", at(path), n, max))
|
||||
}
|
||||
}
|
||||
if not, ok := node["not"].(map[string]any); ok {
|
||||
if len(s.checkIn(doc0, path, not, doc)) == 0 {
|
||||
probs = append(probs, fmt.Sprintf("%s: matches a schema it must not match", at(path)))
|
||||
}
|
||||
}
|
||||
if min, ok := node["minLength"].(float64); ok {
|
||||
if str, isStr := doc.(string); isStr && len(str) < int(min) {
|
||||
probs = append(probs, fmt.Sprintf("%s: shorter than minLength %d", at(path), int(min)))
|
||||
}
|
||||
}
|
||||
|
||||
if arr, ok := doc.([]any); ok {
|
||||
if unique, has := node["uniqueItems"].(bool); has && unique {
|
||||
seen := map[string]bool{}
|
||||
for _, v := range arr {
|
||||
key := fmt.Sprintf("%v", v)
|
||||
if seen[key] {
|
||||
probs = append(probs, fmt.Sprintf("%s: duplicate item %v but uniqueItems is set", at(path), v))
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
if min, has := node["minItems"].(float64); has && len(arr) < int(min) {
|
||||
probs = append(probs, fmt.Sprintf("%s: %d items, below minItems %d", at(path), len(arr), int(min)))
|
||||
}
|
||||
if items, has := node["items"].(map[string]any); has {
|
||||
for i, v := range arr {
|
||||
probs = append(probs, s.checkIn(doc0, fmt.Sprintf("%s[%d]", path, i), items, v)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
obj, isObj := doc.(map[string]any)
|
||||
if !isObj {
|
||||
return probs
|
||||
}
|
||||
|
||||
if req, ok := node["required"].([]any); ok {
|
||||
for _, r := range req {
|
||||
name, _ := r.(string)
|
||||
if _, present := obj[name]; !present {
|
||||
probs = append(probs, fmt.Sprintf("%s: missing required field %q", at(path), name))
|
||||
}
|
||||
}
|
||||
}
|
||||
props, _ := node["properties"].(map[string]any)
|
||||
if allow, ok := node["additionalProperties"].(bool); ok && !allow {
|
||||
var extra []string
|
||||
for k := range obj {
|
||||
if _, declared := props[k]; !declared {
|
||||
extra = append(extra, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(extra)
|
||||
for _, k := range extra {
|
||||
probs = append(probs, fmt.Sprintf("%s: undeclared field %q (additionalProperties is false)", at(path), k))
|
||||
}
|
||||
}
|
||||
for name, sub := range props {
|
||||
v, present := obj[name]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
subSchema, ok := sub.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
probs = append(probs, s.checkIn(doc0, join(path, name), subSchema, v)...)
|
||||
}
|
||||
|
||||
// if/then: apply then only when if validates cleanly.
|
||||
if cond, ok := node["if"].(map[string]any); ok {
|
||||
if then, hasThen := node["then"].(map[string]any); hasThen {
|
||||
if len(s.checkIn(doc0, path, cond, doc)) == 0 {
|
||||
probs = append(probs, s.checkIn(doc0, path, then, doc)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if all, ok := node["allOf"].([]any); ok {
|
||||
for _, sub := range all {
|
||||
if subSchema, isMap := sub.(map[string]any); isMap {
|
||||
probs = append(probs, s.checkIn(doc0, path, subSchema, doc)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return probs
|
||||
}
|
||||
|
||||
func join(path, name string) string {
|
||||
if path == "" {
|
||||
return name
|
||||
}
|
||||
return path + "." + name
|
||||
}
|
||||
|
||||
func checkType(t any, doc any) string {
|
||||
var want []string
|
||||
switch v := t.(type) {
|
||||
case string:
|
||||
want = []string{v}
|
||||
case []any:
|
||||
for _, x := range v {
|
||||
if s, ok := x.(string); ok {
|
||||
want = append(want, s)
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Sprintf("schema type %v is neither a string nor a list", t)
|
||||
}
|
||||
for _, w := range want {
|
||||
if matchesType(w, doc) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("expected type %s, got %T", strings.Join(want, "|"), doc)
|
||||
}
|
||||
|
||||
func matchesType(want string, doc any) bool {
|
||||
switch want {
|
||||
case "object":
|
||||
_, ok := doc.(map[string]any)
|
||||
return ok
|
||||
case "array":
|
||||
_, ok := doc.([]any)
|
||||
return ok
|
||||
case "string":
|
||||
_, ok := doc.(string)
|
||||
return ok
|
||||
case "boolean":
|
||||
_, ok := doc.(bool)
|
||||
return ok
|
||||
case "number":
|
||||
_, ok := doc.(float64)
|
||||
return ok
|
||||
case "integer":
|
||||
f, ok := doc.(float64)
|
||||
return ok && f == float64(int64(f))
|
||||
case "null":
|
||||
return doc == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func equal(a, b any) bool {
|
||||
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
||||
}
|
||||
|
|
@ -22,7 +22,11 @@
|
|||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"type": {"$ref": "https://flex-auth.netkingdom/schemas/caring_access_descriptor.schema.json#/$defs/subject_type"},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "The requesting system's own subject-type vocabulary, evaluated by its policy package as an opaque string. Deliberately NOT the CARING subject_type enum: that enum is the registry's vocabulary (subject_manifest.yaml, where Service is correct), and every shipped consumer sends its own on the request instead -- human (user-engine), service (tenant-engine, secrets-engine), adm/agt/atm (ops-warden, which is an actor-type vocabulary CARING does not model at all). Corrected 2026-09-06: the $ref to the CARING enum declared three live integrations non-conformant, and a rule that outlaws shipped correct behaviour is the rule that is wrong."
|
||||
},
|
||||
"tenant": {"type": "string", "minLength": 1},
|
||||
"attributes": {"type": "object", "additionalProperties": true}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue