Verifies and fixes CF-005 (alktunnels reverse-flow POC W1): the connect-side serving path built channel 0 internally and never set an identity, so a scope-gated serving op could only be satisfied via the payload auth_token. Token is now the fallback (hub-forwarding / browser path); transport/key-based identity is the primary path. - ServingConfig gains identity: Option<Identity> — the explicit override (remediation a). Semver-relevant struct-literal change → 0.7.0 (minor bump at 0.x, wire surface unchanged). - from_connection_with_serving propagates the transport Connection::identity() to the channel-0 connection via set_identity before the serving loop starts (remediation b) — mirrors the accept side's install-hook set_identity; process-local, nothing new on the wire. - Dispatch identity precedence on the serving loop: payload auth_token → identity_provider, then ServingConfig.identity, then transport identity; identity-less dispatch still fails closed (FORBIDDEN). - Public core::auth::NoopIdentityProvider (resolves nothing; the ServingConfig::default() provider — three private test copies existed). - Regression gates: four cf005_* e2e tests (transport propagation, override precedence, identity-less denial, token fallback + precedence). - Ledger CF-005 → resolved; ADR-022 §connect-side-serving amended; README example updated; changelog 0.7.0. Verification: cargo test (629) + --all-features (646), clippy (all-targets, all-features, -D warnings), fmt --check, doc --no-deps, wasm32 check, semver-checks (no update required at 0.7.0), publish --dry-run.
9.7 KiB
9.7 KiB
Consumer findings ledger — alkhttp consuming alkcall
Findings from writing the first real consumer (alkhttp) against alkcall. alkcall is deliberately minimal; expect missing features and edge-case bugs to surface here first. Extract items into alkcall tasks/reviews as the project sees fit.
Format: date | found-in (alkhttp context) | severity | status.
Open
(none)
Resolved
CF-005 — connect-side serving path (from_connection_with_serving) has no caller-identity capture; scope-gated ops are satisfiable only via payload auth_token (2026-09-07) — RESOLVED 2026-09-07
- Found in: alktunnels reverse-flow POC
(
alktunnels-reverse-poc, summary at/workspace/@alkdev/alktunnels/docs/research/reverse-poc-summary.md§W1; verified against alkcall 0.6.0). The worker is the connect side AND the serving side (ADR-022 §2 both-sides): it serves its tunnel open op to the hub over the connection it dialed. The open op is scope-gated (AccessControl.required_scopes), but the identity the ACL sees comes fromDispatcher::dispatch_start→resolve_identity(connection_identity, payload), and:connection.identity()isNone—from_connection_with_servingbuilds channel 0 internally viaConnection::from_source(client.rschannel-0 block) and never sets an identity; there is no capture point for the transport-authenticated peer (mTLS/QUIC identity) on this path.- The only caller-identity path is the payload
auth_token→ServingConfig.identity_provider(resolve_from_token). - Corollary: the
AuthContextclosed over byregister_openable*'s wrapper (what the establisher and pump handler receive) is the connection-establishment-time context, not the per-call opener's identity — on the accept side the install hook receives the transportAuthContextand canchannel0_conn.set_identity, butfrom_connection_with_servinghas no equivalent seam.
- Impact: a connect-side serving op with a scope gate cannot
authenticate the legitimate peer by transport identity — the POC
had to attach
auth_tokenpayloads from the hub side (CallConnection::call_with_payload). Acceptable for hub-forwarding topologies (from_call's ADR-017 §7 token path), but a gap for direct connect-side serving where the transport already authenticated the peer. - Verification: confirmed statically (the chain
Connection::from_source→ emptyOnceLockidentity →dispatch_start→resolve_identity(None, payload)→AccessControl::check(None)fails closed) and empirically (e2e duplex tests reproducing the POC shape; the tokenless scope-gated call was deniedFORBIDDEN: authentication requiredwhile the token path succeeded). - Fix (both remediations (a) + (b), 2026-09-07): the caller
identity for the connect-side serving dispatch resolves in
precedence order — (1) payload
auth_token→ServingConfig.identity_provider(unchanged; the hub-forwarding / browser-token fallback, ADR-017 §7); (2) newServingConfig.identity: Option<Identity>— the explicit override (remediation (a)); (3) the transport connection's identity, propagated automatically:from_connection_with_servingcopiesconnection.identity()to the channel-0 connection viaset_identitybefore the serving loop starts (remediation (b) — the native-client mTLS/QUIC key-based path; mirrors the accept side's install-hookset_identity). Also added the publiccore::auth::NoopIdentityProvider(resolves nothing) — the identity-less posture and theServingConfig::default()identity provider; three private test copies of it existed.- Behavior change (semver-relevant):
ServingConfiggained anidentityfield — struct literals must addidentity: None(0.x; changelog note). The call half is unaffected (spawn_dispatchtakes the consumer-ownedConnection, so the consumer canset_identitybefore wrapping). - The corollary (
register_openable's closed-over establishment-timeAuthContextvs the per-call opener identity) remains open as a design follow-up — it affects the establisher / pump handler seam, not the ACL gate; tracked separately.
- Behavior change (semver-relevant):
- Status: resolved — 2026-09-07 (regression gates:
cf005_*tests insrc/channels/client.rs).
CF-004 — services_schema_handler discloses Internal/ACL-restricted op specs — no visibility or AccessControl check (2026-08-30) — RESOLVED 2026-08-31
- Found in: alkhttp Review 002, finding PRJ-16
(
alkhttp/docs/reviews/002-post-remediation-review.md, Part F', verified against tree91483a7+ alkcall source). Filed to this ledger 2026-08-30.src/registry/discovery.rs:327-343(services_schema_handler) — the handler did a bareregistry.registration(&name)and returnedspec_to_json(®.spec)verbatim, with noVisibilitycheck and noAccessControl::check(peer_identity). - Fix: the handler now applies the same two gates as
OperationRegistry::invoke— the Internal-visibility rejection (!ctx.internal→ spec-404) andAccessControl::checkwith the same identity resolution invoke() uses (handler_identitywhen internal,identityotherwise). Restricted ops returnNOT_FOUND(spec-404) rather thanFORBIDDEN— no information leaks about the restricted surface's existence or shape. The gate is in the handler itself, so every transport (wire/call, HTTP routes, MCP tools) is covered. - Status: resolved — 2026-08-30.
CF-003 — publish_schema compile failure is fail-open on the wire dispatch path (2026-08-30) — RESOLVED 2026-08-30
- Found in: alkhttp Review 001 post-remediation task
review-001-publish-schema-validation-robust(the GW-01/#1 follow-up work). While fixing alkhttp's HTTP-side instance of the pattern, the identical instance was verified on the wire dispatch path:src/protocol/dispatch.rs:352-366(Dispatcher::dispatch,OperationType::Pubarm) — when a Pub op'spublish_schemafailed to compile, the pump logged a warn and proceeded withvalidator: None(silent unvalidated ingest, plus per-request recompile cost). - Fix: fail-closed at the source —
publish_schemais now compiled at registration time in both insertion points (OperationRegistry::registerandOperationRegistryBuilder::store); an un-compilable schema is a registration error, so an unvalidated-ingest path can never be constructed. The compiled validator is cached per-op (OperationRegistry::publish_validator) and the dispatch path consumes the cache — the per-request compile is gone. Thefrom_callimport path inherits the guarantee (imports register through the same choke point); forwarded-chunk validation on the producer side is the remote peer's dispatch path and now inherits the same fail-closed property. - Behavior change (semver-relevant):
register/builder methods returnErrfor an un-compilablepublish_schema(previously: accepted). Code that registered garbage schemas will now get a registration error instead of a silently-unvalidated op. - Status: resolved — 2026-08-30.
CF-002 — demux TooLarge skip allocates the full peer-declared length up front (up to ~4 GiB from an 8-byte header) (2026-08-30) — RESOLVED 2026-08-30
- Found in: alkhttp Review 001, finding WS-12 (cross-crate;
docs/reviews/001-initial-implementation-review.md, Part B, verified against tree4a825d3). Filed to this ledger 2026-08-30.src/channels/adapter.rs:144(run_demux_loop_for_client) — theChunkError::TooLargearm allocatedlet mut discard = vec![0u8; length as usize];before reading: the buffer was sized from the peer's untrusted 8-byte header (u32length, up to ~4 GiB). - Fix: stream-skip with a fixed 64 KiB buffer +
read_exactloop consuminglengthbytes — no allocation sized from peer input. Plus a cumulative skipped-bytes budget (256 MiB) that tears down the connection when a peer loopsTooLargeheaders to burn bandwidth/CPU. The budget resets on each valid chunk, so legitimate isolated oversized chunks (the resync path, covered by the existing test) are unaffected. The existingdemux_resyncs_after_oversized_chunktest passes unchanged. - Status: resolved — 2026-08-30.
CF-001 — call_single_stream write-failure maps to non-retryable INTERNAL (2026-08-29) — RESOLVED 2026-08-30
- Found in: alkhttp
from_wssdrop-monitor race tests (WS-02/CON-02, review-001-ws-eof-signal). When the transport mux died mid-call, the write path failed fast and the write-failure mapping produced a non-retryableINTERNAL: failed to write request frame— even though the call never reached the producer and a reconnect/retry would be safe. - Fix: new retryable
CallError::connection_closedconstructor (CONNECTION_CLOSEDcode,retryable: true), applied only to write failures where the call is provably undelivered:call.requestedframe write failures on all consumer paths (call/subscribe/publish, single-stream and stream-per-request; the publish pump tags its write stages so only the request-frame stage is retryable). Mid-publish and completed-frame write failures stayINTERNAL(delivery ambiguous — retry unsafe), and the producer-sidefail_all(...)on connection close staysINTERNAL. The new code string is additive to the wire error vocabulary;CONNECTION_CLOSEDis new — consumers should treat unknown codes per their existing policy (theretryableflag is the machine-readable signal). - Status: resolved — 2026-08-30. alkhttp follow-up: the
review-001-ws-eof-signalrace test can now tighten back to retryable-only asserts.