diff --git a/docs/secrets-engine-action-vocabulary.md b/docs/secrets-engine-action-vocabulary.md index ca3208a..5ed177c 100644 --- a/docs/secrets-engine-action-vocabulary.md +++ b/docs/secrets-engine-action-vocabulary.md @@ -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. diff --git a/examples/caring/action_authorization.json b/examples/caring/action_authorization.json index 28dac82..a59e2c2 100644 --- a/examples/caring/action_authorization.json +++ b/examples/caring/action_authorization.json @@ -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." } } diff --git a/examples/secrets-engine/check_request_allow_destroy_dual_control.json b/examples/secrets-engine/check_request_allow_destroy_dual_control.json index bff2ab3..34b1558 100644 --- a/examples/secrets-engine/check_request_allow_destroy_dual_control.json +++ b/examples/secrets-engine/check_request_allow_destroy_dual_control.json @@ -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" } } diff --git a/examples/secrets-engine/policy_fixtures.yaml b/examples/secrets-engine/policy_fixtures.yaml index 6b99675..9b1847c 100644 --- a/examples/secrets-engine/policy_fixtures.yaml +++ b/examples/secrets-engine/policy_fixtures.yaml @@ -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" } } diff --git a/examples/secrets-engine/policy_package.md b/examples/secrets-engine/policy_package.md index c60139d..57f778a 100644 --- a/examples/secrets-engine/policy_package.md +++ b/examples/secrets-engine/policy_package.md @@ -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" } diff --git a/examples/secrets-engine/replay/README.md b/examples/secrets-engine/replay/README.md index 20368e5..01362ed 100644 --- a/examples/secrets-engine/replay/README.md +++ b/examples/secrets-engine/replay/README.md @@ -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 diff --git a/examples/secrets-engine/replay/decision_destroy_dual_control.json b/examples/secrets-engine/replay/decision_destroy_dual_control.json index c4e8ef4..c6c3aa1 100644 --- a/examples/secrets-engine/replay/decision_destroy_dual_control.json +++ b/examples/secrets-engine/replay/decision_destroy_dual_control.json @@ -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", diff --git a/internal/schemaguard/examples_test.go b/internal/schemaguard/examples_test.go new file mode 100644 index 0000000..1f62545 --- /dev/null +++ b/internal/schemaguard/examples_test.go @@ -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") + } +} diff --git a/internal/schemaguard/schemaguard.go b/internal/schemaguard/schemaguard.go new file mode 100644 index 0000000..351ffc4 --- /dev/null +++ b/internal/schemaguard/schemaguard.go @@ -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) +} diff --git a/schemas/check_request.schema.json b/schemas/check_request.schema.json index 7503afd..d634f48 100644 --- a/schemas/check_request.schema.json +++ b/schemas/check_request.schema.json @@ -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} }