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.
105 lines
5.1 KiB
Markdown
105 lines
5.1 KiB
Markdown
# 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 `/schema` denials).
|
|
|
|
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 `/schema`
|
|
route 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 `/schema` route, the MCP `schema` tool, and
|
|
the spine's `services/schema` op-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`
|
|
(the `invoke`/`invoke_streaming`/`invoke_sink` spine with the
|
|
root-`OperationContext` invariants and the inner-`name` guard) is
|
|
`alkcall::gateway::GatewayDispatch` (feature `gateway`, 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 in `gateway::error`, NDJSON/SSE framing,
|
|
body caps, batch envelopes, and this ADR's original
|
|
FORBIDDEN-vs-spec-404 split on the GET route).
|
|
- **`/publish` chunk validation is registry-backed.** The local
|
|
compile-once `PublishSchemaCache` was deleted: alkcall CF-003
|
|
compiles `publish_schema` at registration time and exposes the
|
|
cached validator as `OperationRegistry::publish_validator`. The
|
|
`/publish` chunk 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`
|
|
(retryable `CONNECTION_CLOSED`) covers the write-failure race; the
|
|
from_wss drop-monitor race tests assert retryable-only (the
|
|
previously-tolerated non-retryable `INTERNAL: failed to write
|
|
request frame` outcome 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/schema` calls (allowed inner
|
|
names) are unaffected.
|
|
- The guard matches the outer registration's `spec.name` against the
|
|
`services/schema` constant rather than the raw request string, so
|
|
leading-slash variants (`/services/schema`) hit the same check. |