feat(adapters): enforce advertised input schemas at call time (OAI-18, option a)
Decision: advertise == enforce. The key allowlist (OAI-02) stays as the first gate with its established unknown-key message; a compiled leaf validator now runs second, so required/type/enum/pattern/bounds violations surface as INVALID_INPUT 422 naming the keyword — not as upstream round-trips. - new src/adapters/input_validation.rs: CompiledInputSchema compiles an op's input_schema once at import with the jsonschema crate (same 2020-12 dialect publish_schema uses) and validates peer input at call time; the compile-time copy is hardened closed-by-default (additionalProperties: false injected when absent) so the validator reproduces the allowlist's unknown-key semantics; explicit additionalProperties:true catch-all and schema values are preserved; the original spec value is never mutated - from_openapi/from_jsonschema import(): compile per registration, capture the validator in the handler closure (re-import recompiles — the closure capture is the invalidation story); a non-compilable input schema fails import loudly (AdapterError::SchemaParse naming the operation), matching the publish_schema fail-closed precedent - from_openapi generated input schemas now carry additionalProperties:false explicitly, so the /schema advert states the enforced rule and external schema-driven validators reach the same verdicts - forward/forward_stream/build_request gain an Option<&CompiledInputSchema> parameter; enforcement runs after the allowlist - round-trip test (review 002 Test-gap 10): the /schema-exported input_schema is compiled with the same validator and driven against build_request over a 10-input violation matrix — accept-sets exactly equal in both directions; the chain-test that lets advertise/enforce drift surface as a CI failure - ADR-066: new decision section (advertise==enforce) with the trust- boundary reasoning and the rejected option (b) rationale - module + enforce_input_schema docs updated to the two-gate shape cargo test --all-features 596 pass; clippy --all-features/-D warnings, fmt, doc gates clean. docs(tasks): mark review-002-fu-oai18-decision done
This commit is contained in:
@@ -149,6 +149,73 @@ error projection and the success sweep (SSE detection +
|
||||
output-schema selection), where the precedence order is: concrete 2XX
|
||||
statuses, then `2XX`, then `default`.
|
||||
|
||||
### Input-schema enforcement: advertise == enforce (review 002 OAI-18)
|
||||
|
||||
**Decided: enforce the full input schema at call time.** Until this
|
||||
decision, `build_request` enforced a **key allowlist** only (review 001
|
||||
OAI-02: unknown keys rejected), while `/schema` advertised the full
|
||||
JSON Schema — `required`, `enum`, `pattern`, value types, bounds. The
|
||||
advertised contract was broader than the defended one: a
|
||||
`required: [id]` operation accepted `{}` and let the upstream surface
|
||||
the violation as a remote 422; a `{"type": "string"}` property sent as
|
||||
an object serialized as JSON text into the query string;
|
||||
`enum`/`pattern`/`minimum` were never consulted. This is the same
|
||||
advertise/enforce drift class review 002 filed as
|
||||
[minor→major-class], and OAI-02's own rationale decides the direction:
|
||||
peer **input** is never trusted, and the advertised schema is exactly
|
||||
what a peer reads before crafting input. ADR-066's trust boundary
|
||||
(specs are trusted *configuration*) answers whether the assembly's
|
||||
schema must be defended against; it does not make peer input trusted,
|
||||
so "the spec is configuration" does not justify enforcement-by-allowlist.
|
||||
Scoping the advert instead (the rejected alternative) would have to
|
||||
strip `required`/`enum`/`pattern` from **two** projection surfaces
|
||||
(`to_openapi` and `to_mcp`) and degrades the contract for well-behaved
|
||||
consumers.
|
||||
|
||||
Concretely:
|
||||
|
||||
- Each adapter's `import()` compiles the op's `input_schema` **once**
|
||||
with the `jsonschema` crate (the same compiler and 2020-12 dialect
|
||||
`PublishSchemaCache` already uses for `publish_schema`) and captures
|
||||
the validator in the handler closure — compile once per registration,
|
||||
no runtime cache-invalidation scheme needed, mirroring the
|
||||
value-keyed cache only in spirit (the closure capture *is* the
|
||||
invalidation story: re-import recompiles).
|
||||
- The compile step **hardens** the schema copy it compiles: when
|
||||
`additionalProperties` is absent, `additionalProperties: false` is
|
||||
injected into the compiled copy, so the validator reproduces the
|
||||
OAI-02 closed-by-default semantics. The spec value itself is never
|
||||
mutated.
|
||||
- `from_openapi`'s *generated* input schemas now state the rule in the
|
||||
advert itself: every generated schema carries
|
||||
`additionalProperties: false` explicitly, so a schema-driven external
|
||||
consumer reaches the same verdicts the gateway enforces (pinned by
|
||||
the advertised-vs-enforced round-trip test).
|
||||
- The allowlist check runs **first** and is unchanged (its message
|
||||
names the undeclared key and the declared set — better diagnosis
|
||||
than the validator's generic additionalProperties error); the
|
||||
compiled validator runs second. Unknown-key rejection therefore
|
||||
keeps the established message, and leaf violations surface as
|
||||
`INVALID_INPUT` 422 naming the violated keyword and input location
|
||||
(ADR-023 shape) — the gateway rejects, never an upstream round-trip.
|
||||
- The explicit `"additionalProperties": true` opt-in catch-all
|
||||
(documented catch-all for open-shaped endpoints) is preserved
|
||||
verbatim by the compiler; declared keys' constraints still bind
|
||||
under it.
|
||||
- An input schema that fails to compile fails **import** loudly
|
||||
(`AdapterError::SchemaParse` naming the operation), matching the
|
||||
`publish_schema` fail-closed precedent: an un-validatable input
|
||||
contract must never register as an operation whose enforcement
|
||||
silently degrades.
|
||||
|
||||
Interaction with the gateway marker extensions: the
|
||||
`HEADER_PARAM_IN_MARKER`-decorated properties and the `body` property
|
||||
are peer-visible schema extensions that ride inside `properties`, so
|
||||
the compiled validator treats them as ordinary properties (validated
|
||||
against their embedded schema; the marker key itself is not a JSON
|
||||
Schema keyword and is ignored by the compiler). The catch-all
|
||||
semantics above keep open-shaped endpoints working.
|
||||
|
||||
### Forwarding contract decisions (review 002 FWD-17/18/19)
|
||||
|
||||
The shared forwarding core (`src/adapters/forward.rs`) used by both
|
||||
|
||||
Reference in New Issue
Block a user