Bump the alkcall dependency to 0.2 (with the gateway feature) and converge on the promoted shared pieces: - The local dispatch spine (gateway/dispatch.rs, 721 lines) is deleted; GatewayDispatch, schema_disclosure_denial, and DEFAULT_DEADLINE are re-exported from alkcall::gateway (alkcall ADR-048). The 30 s default deadline preserves the previous behavior exactly. - gateway/schema_cache.rs (PublishSchemaCache) is deleted: alkcall CF-003 compiles publish_schema at registration time and exposes OperationRegistry::publish_validator; the /publish chunk stream resolves against it. Un-compilable schemas are now rejected at registration, so the two end-to-end fail-closed tests were reworked into a registration-rejection test (a stronger guarantee). - schema_disclosure_denial consumers (to_mcp, routes) use alkcall's promoted implementation; the alkhttp-local copy is gone (ADR-071 updated: the guard stays as defense-in-depth, the implementation no longer forks). - CF-001: from_wss drop monitor and the WS overlay tests use CallError::connection_closed; the review-001-ws-eof-signal race tests now assert retryable CONNECTION_CLOSED on both resolution paths (the tolerated non-retryable INTERNAL write-failure outcome is gone). - Added CHANGELOG.md (Keep a Changelog), Unreleased section records the bump and convergence. Verification: cargo test default 453 ok, wss 470 ok, mcp 526 ok, all-features 575 ok; clippy -D warnings clean (default + all-features, all-targets); fmt clean; cargo doc warning-free. Net: -1093 lines.
5.1 KiB
ADR-071: The dispatch-spine services/schema op-path guard
Status
Accepted
Context
Review-002 PRJ-16 ([major, security]) found that the GET /schema
disclosure fixes (GW-02, SRV-02) had a sibling hole through the op
path: services/schema is an External operation with default ACL, so
the gateway's outer-name pre-checks admit it — and alkcall's
services_schema_handler performs a bare
registry.registration(name) → spec projection with no visibility
and no AccessControl check of its own. Any HTTP caller could fetch
the full spec (input/output/error schemas, required_scopes) of an
Internal or ACL-restricted operation with:
POST /call {"operation":"services/schema","input":{"name":"secret/op"}}
— unauthenticated, and identically through the MCP call and batch
tools. The same GET /schema route that returns 404 for that op was
thus circumventable in one request. (Disclosure only; the target op's
handler never executes.)
The complete fix is alkcall-side: the handler itself must check —
filed as CF-004 in alkcall/docs/reviews/consumer-findings-ledger.md.
But the alkcall lead time was unknown, and the hole is in this crate's
wire surface today.
Decision
GatewayDispatch blocks services/schema invocations whose inner
name input would be denied by the GET /schema route for the same
identity. Before dispatch, when the resolved operation is the
services/schema meta-op, the spine applies the same two checks the
route applies to ?name=:
- Inner op is
Visibility::Internal→NOT_FOUND(404 on HTTP). - Inner op denies the caller's
AccessControl→FORBIDDEN(401 unauthenticated / 403 authenticated under the gateway error mapping — identical to the GET/schemadenials).
The guard lives in the dispatch spine (invoke + invoke_streaming),
not in the route handlers, because every HTTP transport and the MCP
call/batch tools flow through it — one interception point covers
/call, /batch, /subscribe (defense-in-depth: the registry rejects
the Query-typed meta-op on the streaming path anyway), and the MCP
tools. Sink dispatch (/publish) cannot reach services/schema: the
registry rejects non-Pub operations before the handler runs and the
sink ignores the input, so nothing is projected there.
The visibility + ACL check itself is one shared function
(schema_disclosure_denial) used by the HTTP GET /schema route, the
dispatch-spine guard, and the MCP schema tool, so the transports
cannot drift.
Consequences
- Transport-level invariant (PRJ-16's wording): no alkhttp
transport can fetch, through any path, a spec the GET
/schemaroute would deny for the same identity. - When CF-004 landed, the guard stayed — and was promoted. The
alkcall-side handler check is the complete fix for every transport
(wire, overlays, peer composition; alkcall commit
8cb2a6e). The alkhttp spine, the GET/schemaroute, the MCPschematool, and the spine'sservices/schemaop-path guard all now converge on the shared promoted implementation:alkcall::gateway:: schema_disclosure_denial(alkcall ADR-048). The local copy was deleted on the alkcall-0.2 bump — one implementation, so the transports still cannot drift, and no alkhttp-local fork remains. Do not re-introduce a local copy. - The dispatch spine itself is promoted too.
GatewayDispatch(theinvoke/invoke_streaming/invoke_sinkspine with the root-OperationContextinvariants and the inner-nameguard) isalkcall::gateway::GatewayDispatch(featuregateway, enabled unconditionally by the alkcall dependency — the gateway endpoints are this crate's sole invoke path, ADR-047). The alkhttp-local copy was deleted; only the HTTP-specific layers remain here (the CallError → HTTP mapping ingateway::error, NDJSON/SSE framing, body caps, batch envelopes, and this ADR's original FORBIDDEN-vs-spec-404 split on the GET route). /publishchunk validation is registry-backed. The local compile-oncePublishSchemaCachewas deleted: alkcall CF-003 compilespublish_schemaat registration time and exposes the cached validator asOperationRegistry::publish_validator. The/publishchunk stream resolves against that cache; the fail-closed guarantee moved upstream with it (an un-compilable schema is a registration error, so an unvalidated-ingest path cannot be constructed).- CF-001 resolution: alkcall's
CallError::connection_closed(retryableCONNECTION_CLOSED) covers the write-failure race; the from_wss drop-monitor race tests assert retryable-only (the previously-tolerated non-retryableINTERNAL: failed to write request frameoutcome is gone from the wire vocabulary on the undelivered-request path). - Wire-observable behavior is unchanged from the pre-promotion guard:
previously-leaking requests still get the same 404/403 the GET
route returns; legitimate
services/schemacalls (allowed inner names) are unaffected. - The guard matches the outer registration's
spec.nameagainst theservices/schemaconstant rather than the raw request string, so leading-slash variants (/services/schema) hit the same check.