feat(cf-005): connect-side serving identity — ServingConfig.identity + transport-identity propagation

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.
This commit is contained in:
glm-5.3-flash committed 2026-09-07 11:00:47 +00:00
1 parent 0d287a97b0
commit db5530ed34
8 files changed
+540 -17

No files matched your search

@@ -402,12 +402,32 @@ surfaces discovery failure as `AdapterError::DiscoveryFailed`).
`ChannelClient::from_connection_with_serving(connection,
Option<ServingConfig>)` — with `None` (the pure-consumer default) the
read pump resolves outbound pendings only (previous behavior). With
`Some(ServingConfig { registry, identity_provider })` the read pump
becomes the full-duplex serving loop (`Dispatcher::serve_single_stream`):
inbound `call.requested` frames dispatch against the configured
registry and resolve back to the peer; outbound pendings still resolve
in the same loop. Serving is opt-in because a pure consumer has no
registry to serve; the *protocol* is symmetric, the *API* is explicit.
`Some(ServingConfig { registry, identity_provider, identity })` the
read pump becomes the full-duplex serving loop
(`Dispatcher::serve_single_stream`): inbound `call.requested` frames
dispatch against the configured registry and resolve back to the peer;
outbound pendings still resolve in the same loop. Serving is opt-in
because a pure consumer has no registry to serve; the *protocol* is
symmetric, the *API* is explicit.
**Caller identity for the serving dispatch (CF-005, 2026-09-07).**
`from_connection_with_serving` builds channel 0 internally, so before
the remediation there was no capture point for the
transport-authenticated peer: the serving dispatch resolved identity
only from the payload `auth_token`, and a scope-gated serving op could
not authenticate a key-based (mTLS/QUIC) peer by transport identity.
The remediation makes the identity resolution, in precedence order:
(1) the payload `auth_token` → `ServingConfig.identity_provider`
(hub-forwarding and browser-token path, ADR-017 §7); (2) the
`ServingConfig.identity` override (explicit, e.g. an
assembly-layer-resolved principal); (3) the transport connection's
identity, propagated automatically — `from_connection_with_serving`
copies `connection.identity()` onto the channel-0 connection via
`set_identity` before the serving loop starts, mirroring the accept
side, where the adapter hands the install hook the transport
`AuthContext` and the hook sets the channel-0 identity. With no
identity from any path the dispatch runs identity-less and
`AccessControl::check` fails closed (`FORBIDDEN`).
Direction disambiguation in the loop is by table membership, not
framing: an id that is one of *our* outbound pendings resolves there;