flex-auth/internal/schemaguard/schemaguard.go
tegwick 9f3e7e363a
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
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
2026-09-06 14:21:28 +02:00

342 lines
9.7 KiB
Go

// 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)
}