access-engine/internal/schemaguard/schemaguard.go
tegwick 8f2ba75e4a
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 58s
FLEX-WP-0035 T01-T03: application access readback pin
Contract accepted by user-engine; FLEX-DEC-2026-019 accepted.
- serve-readback: dedicated pin, no enforcement routes, own caller binding
- CheckRequest.purpose hashed into every digest when set; /v1/check refuses it
- POST /v1/readback/application_access and GET /v1/readback/systems
- optional client_binding on protected systems; readback schemas
- schemaguard implements maxItems
- T05 added: informed-decision needs an application.access rule over
  realm-level claims (its lifecycle rules read authentication_audience)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 609675@bnt-lap001
Assistant-Session: 4987a36e-eb38-4b10-803c-15a29cd78212
2026-10-09 20:02:31 +02:00

345 lines
9.9 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, "maxItems": 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 max, has := node["maxItems"].(float64); has && len(arr) > int(max) {
probs = append(probs, fmt.Sprintf("%s: %d items, above maxItems %d", at(path), len(arr), int(max)))
}
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)
}