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:
1 parent
0d287a97b0
commit
db5530ed34
8 files changed
+540
-17
No files matched your search
@@ -4,6 +4,47 @@ All notable changes to this crate are documented here. The format is
|
|||||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-09-07
|
||||||
|
|
||||||
|
The connect-side caller-identity seam (CF-005): a connect-side serving
|
||||||
|
op can now authenticate the transport-authenticated peer by key-based
|
||||||
|
identity (mTLS/QUIC), not only by payload `auth_token`. The wire
|
||||||
|
surface is unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`ServingConfig.identity: Option<Identity>` (CF-005 remediation
|
||||||
|
(a))** — an explicit caller identity for the connect-side serving
|
||||||
|
dispatch (`ChannelClient::from_connection_with_serving`). Wins over
|
||||||
|
the propagated transport identity; the payload `auth_token` path
|
||||||
|
still takes precedence over both (ADR-017 §7).
|
||||||
|
**Semver-relevant at 0.x:** struct literals must add
|
||||||
|
`identity: None`.
|
||||||
|
- **`core::auth::NoopIdentityProvider`** — a public `IdentityProvider`
|
||||||
|
that resolves nothing; the identity-less posture for ACL-free
|
||||||
|
serving and the `ServingConfig::default()` provider (three private
|
||||||
|
test copies of it existed across the crate).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The transport identity propagates to channel 0 (CF-005
|
||||||
|
remediation (b)).** `from_connection_with_serving` copies the
|
||||||
|
transport `Connection::identity()` to the channel-0 connection via
|
||||||
|
`set_identity` before the serving loop starts, mirroring the accept
|
||||||
|
side (the adapter hands the install hook the transport
|
||||||
|
`AuthContext`). The connect-side serving dispatch now resolves the
|
||||||
|
caller identity in precedence order: payload `auth_token` →
|
||||||
|
`ServingConfig.identity_provider`; `ServingConfig.identity`;
|
||||||
|
transport identity. With none, the dispatch runs identity-less and
|
||||||
|
`AccessControl::check` fails closed (`FORBIDDEN`) — unchanged. The
|
||||||
|
identity resolution is process-local (`set_identity` on the
|
||||||
|
internal channel-0 `Connection`); nothing new crosses the transport.
|
||||||
|
Regression gates: the four `cf005_*` tests in
|
||||||
|
`src/channels/client.rs` (transport propagation, override
|
||||||
|
precedence, identity-less failure, token fallback + precedence).
|
||||||
|
Ledger: `docs/reviews/consumer-findings-ledger.md` CF-005 →
|
||||||
|
resolved. Spec: ADR-022 §connect-side-serving.
|
||||||
|
|
||||||
## [0.6.0] - 2026-09-07
|
## [0.6.0] - 2026-09-07
|
||||||
|
|
||||||
The establishment follow-ups sweep (review 007): the `Establishment`
|
The establishment follow-ups sweep (review 007): the `Establishment`
|
||||||
@@ -431,6 +472,7 @@ Vendored core types (`Connection`, `ProtocolHandler`, `BiStream`,
|
|||||||
(ADR-046), the channels protocol with openable-ALPNs-as-operations
|
(ADR-046), the channels protocol with openable-ALPNs-as-operations
|
||||||
(ADR-047), and the `ChannelClient` transport-agnostic client.
|
(ADR-047), and the `ChannelClient` transport-agnostic client.
|
||||||
|
|
||||||
|
[0.7.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.7.0
|
||||||
[0.6.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.6.0
|
[0.6.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.6.0
|
||||||
[0.5.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.5.0
|
[0.5.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.5.0
|
||||||
[0.4.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.4.1
|
[0.4.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.4.1
|
||||||
|
|||||||
Generated
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ let client = ChannelClient::from_connection_with_serving(
|
|||||||
Some(ServingConfig {
|
Some(ServingConfig {
|
||||||
registry: Arc::clone(®istry),
|
registry: Arc::clone(®istry),
|
||||||
identity_provider: provider,
|
identity_provider: provider,
|
||||||
|
identity: None, // peer identity: transport `Connection::set_identity` propagates
|
||||||
}),
|
}),
|
||||||
).await?;
|
).await?;
|
||||||
// peer-callable ops resolve against `registry` on channel 0;
|
// peer-callable ops resolve against `registry` on channel 0;
|
||||||
|
|||||||
@@ -402,12 +402,32 @@ surfaces discovery failure as `AdapterError::DiscoveryFailed`).
|
|||||||
`ChannelClient::from_connection_with_serving(connection,
|
`ChannelClient::from_connection_with_serving(connection,
|
||||||
Option<ServingConfig>)` — with `None` (the pure-consumer default) the
|
Option<ServingConfig>)` — with `None` (the pure-consumer default) the
|
||||||
read pump resolves outbound pendings only (previous behavior). With
|
read pump resolves outbound pendings only (previous behavior). With
|
||||||
`Some(ServingConfig { registry, identity_provider })` the read pump
|
`Some(ServingConfig { registry, identity_provider, identity })` the
|
||||||
becomes the full-duplex serving loop (`Dispatcher::serve_single_stream`):
|
read pump becomes the full-duplex serving loop
|
||||||
inbound `call.requested` frames dispatch against the configured
|
(`Dispatcher::serve_single_stream`): inbound `call.requested` frames
|
||||||
registry and resolve back to the peer; outbound pendings still resolve
|
dispatch against the configured registry and resolve back to the peer;
|
||||||
in the same loop. Serving is opt-in because a pure consumer has no
|
outbound pendings still resolve in the same loop. Serving is opt-in
|
||||||
registry to serve; the *protocol* is symmetric, the *API* is explicit.
|
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
|
Direction disambiguation in the loop is by table membership, not
|
||||||
framing: an id that is one of *our* outbound pendings resolves there;
|
framing: an id that is one of *our* outbound pendings resolves there;
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ Format: date | found-in (alkhttp context) | severity | status.
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
### 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)
|
(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
|
- **Found in:** alktunnels reverse-flow POC
|
||||||
(`alktunnels-reverse-poc`, summary at
|
(`alktunnels-reverse-poc`, summary at
|
||||||
@@ -43,16 +49,41 @@ Format: date | found-in (alkhttp context) | severity | status.
|
|||||||
topologies (from_call's ADR-017 §7 token path), but a gap for
|
topologies (from_call's ADR-017 §7 token path), but a gap for
|
||||||
direct connect-side serving where the transport already
|
direct connect-side serving where the transport already
|
||||||
authenticated the peer.
|
authenticated the peer.
|
||||||
- **Candidate remediations (upstream's call):** (a) `ServingConfig`
|
- **Verification:** confirmed statically (the chain
|
||||||
gains an optional identity (or the channel-0 connection is exposed
|
`Connection::from_source` → empty `OnceLock` identity →
|
||||||
for `set_identity` before the serving loop starts); (b) documented
|
`dispatch_start` → `resolve_identity(None, payload)` →
|
||||||
token-only posture for connect-side serving. Either is additive.
|
`AccessControl::check(None)` fails closed) and empirically (e2e
|
||||||
- **Status:** open — filed 2026-09-07 (alktunnels reverse-flow POC W1).
|
duplex tests reproducing the POC shape; the tokenless scope-gated
|
||||||
|
call was denied `FORBIDDEN: authentication required` while 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) new
|
||||||
|
`ServingConfig.identity: Option<Identity>` — the explicit override
|
||||||
|
(remediation (a)); (3) the transport connection's identity,
|
||||||
|
propagated automatically: `from_connection_with_serving` copies
|
||||||
|
`connection.identity()` to the channel-0 connection via
|
||||||
|
`set_identity` before the serving loop starts (remediation (b) —
|
||||||
|
the native-client mTLS/QUIC key-based path; mirrors the accept
|
||||||
|
side's install-hook `set_identity`). Also added the public
|
||||||
|
`core::auth::NoopIdentityProvider` (resolves nothing) — the
|
||||||
|
identity-less posture and the `ServingConfig::default()`
|
||||||
|
identity provider; three private test copies of it existed.
|
||||||
|
- **Behavior change (semver-relevant):** `ServingConfig` gained an
|
||||||
|
`identity` field — struct literals must add `identity: None`
|
||||||
|
(0.x; changelog note). The call half is unaffected
|
||||||
|
(`spawn_dispatch` takes the consumer-owned `Connection`, so the
|
||||||
|
consumer can `set_identity` before wrapping).
|
||||||
|
- The corollary (`register_openable`'s closed-over
|
||||||
|
establishment-time `AuthContext` vs 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.
|
||||||
|
- **Status:** resolved — 2026-09-07 (regression gates: `cf005_*`
|
||||||
|
tests in `src/channels/client.rs`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Resolved
|
|
||||||
|
|
||||||
### CF-004 — `services_schema_handler` discloses Internal/ACL-restricted op specs — no visibility or AccessControl check (2026-08-30) — RESOLVED 2026-08-31
|
### 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
|
- **Found in:** alkhttp Review 002, finding PRJ-16
|
||||||
|
|||||||
@@ -111,9 +111,35 @@ pub struct ChannelClient {
|
|||||||
/// loop (`Dispatcher::serve_single_stream`), so inbound
|
/// loop (`Dispatcher::serve_single_stream`), so inbound
|
||||||
/// `call.requested` frames from the peer dispatch and resolve instead
|
/// `call.requested` frames from the peer dispatch and resolve instead
|
||||||
/// of being dropped.
|
/// of being dropped.
|
||||||
|
///
|
||||||
|
/// The caller identity for dispatch (CF-005): when the serving loop
|
||||||
|
/// dispatches an inbound `call.requested`, `Dispatcher::resolve_identity`
|
||||||
|
/// consults, in precedence order — (1) the payload `auth_token`
|
||||||
|
/// resolved through `identity_provider` (the hub-forwarding /
|
||||||
|
/// browser-token path), (2) `identity` if set (an explicit override),
|
||||||
|
/// (3) the transport connection's identity, if the consumer called
|
||||||
|
/// `Connection::set_identity` after dialing (the mTLS/QUIC key-based
|
||||||
|
/// path). When none is available the dispatch runs identity-less and
|
||||||
|
/// `AccessControl::check` fails closed (`FORBIDDEN`).
|
||||||
pub struct ServingConfig {
|
pub struct ServingConfig {
|
||||||
pub registry: Arc<OperationRegistry>,
|
pub registry: Arc<OperationRegistry>,
|
||||||
pub identity_provider: Arc<dyn crate::core::auth::IdentityProvider>,
|
pub identity_provider: Arc<dyn crate::core::auth::IdentityProvider>,
|
||||||
|
/// An explicit caller identity for the serving dispatch (the
|
||||||
|
/// connect-side seam, CF-005 remediation (a)). `None` (the
|
||||||
|
/// default) falls through to the transport connection's identity
|
||||||
|
/// (CF-005 remediation (b) — propagated automatically when the
|
||||||
|
/// consumer set one before calling `from_connection_with_serving`).
|
||||||
|
pub identity: Option<crate::core::auth::Identity>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ServingConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
registry: Arc::new(OperationRegistry::new()),
|
||||||
|
identity_provider: Arc::new(crate::core::auth::NoopIdentityProvider),
|
||||||
|
identity: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChannelClient {
|
impl ChannelClient {
|
||||||
@@ -141,6 +167,16 @@ impl ChannelClient {
|
|||||||
/// and outbound pendings still resolve. This is the opt-in that
|
/// and outbound pendings still resolve. This is the opt-in that
|
||||||
/// makes the connect side a serving half on channel 0 (ADR-022 §2
|
/// makes the connect side a serving half on channel 0 (ADR-022 §2
|
||||||
/// — both sides can be both).
|
/// — both sides can be both).
|
||||||
|
///
|
||||||
|
/// Caller identity for the serving dispatch (CF-005): the
|
||||||
|
/// `ServingConfig.identity` override wins; otherwise the transport
|
||||||
|
/// connection's identity (`Connection::identity`, set by the
|
||||||
|
/// consumer after dialing from the transport-authenticated peer)
|
||||||
|
/// propagates to channel 0. The payload `auth_token` →
|
||||||
|
/// `ServingConfig.identity_provider` path still takes precedence
|
||||||
|
/// over both (ADR-017 §7). With no identity from any path the
|
||||||
|
/// dispatch runs identity-less and scope-gated ops fail closed
|
||||||
|
/// (`FORBIDDEN`).
|
||||||
pub async fn from_connection_with_serving(
|
pub async fn from_connection_with_serving(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
serving: Option<ServingConfig>,
|
serving: Option<ServingConfig>,
|
||||||
@@ -178,6 +214,22 @@ impl ChannelClient {
|
|||||||
let channel0_source =
|
let channel0_source =
|
||||||
super::source::channel_source(channel0_recv, channel0_send, remote_addr);
|
super::source::channel_source(channel0_recv, channel0_send, remote_addr);
|
||||||
let channel0_conn = Connection::from_source(channel0_source, b"alk/call".to_vec());
|
let channel0_conn = Connection::from_source(channel0_source, b"alk/call".to_vec());
|
||||||
|
// The caller-identity seam (CF-005): the serving loop's dispatch
|
||||||
|
// resolves the peer identity from this connection
|
||||||
|
// (`dispatch_start` → `connection.identity()`), so it must carry
|
||||||
|
// the identity the serving ops should authenticate by. The
|
||||||
|
// explicit `ServingConfig.identity` wins; otherwise the
|
||||||
|
// transport connection's identity propagates (the
|
||||||
|
// mTLS/QUIC key-based path — the peer the transport already
|
||||||
|
// authenticated). `set_identity` is once-only; silently
|
||||||
|
// skip if somehow already set.
|
||||||
|
let identity_for_serving = serving
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|config| config.identity.clone())
|
||||||
|
.or_else(|| connection.identity().cloned());
|
||||||
|
if let Some(identity) = identity_for_serving {
|
||||||
|
let _ = channel0_conn.set_identity(identity);
|
||||||
|
}
|
||||||
let channel0_bidi = channel0_conn.accept_bi().await?;
|
let channel0_bidi = channel0_conn.accept_bi().await?;
|
||||||
let (single_stream_writer, single_stream_reader) =
|
let (single_stream_writer, single_stream_reader) =
|
||||||
crate::protocol::connection::split_single_stream(channel0_bidi);
|
crate::protocol::connection::split_single_stream(channel0_bidi);
|
||||||
@@ -1675,6 +1727,7 @@ mod tests {
|
|||||||
Some(ServingConfig {
|
Some(ServingConfig {
|
||||||
registry: Arc::clone(&consumer_registry),
|
registry: Arc::clone(&consumer_registry),
|
||||||
identity_provider: Arc::new(NoopIdProvider),
|
identity_provider: Arc::new(NoopIdProvider),
|
||||||
|
identity: None,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -1809,6 +1862,7 @@ mod tests {
|
|||||||
Some(ServingConfig {
|
Some(ServingConfig {
|
||||||
registry: Arc::clone(&consumer_registry),
|
registry: Arc::clone(&consumer_registry),
|
||||||
identity_provider: Arc::new(NoopIdProvider),
|
identity_provider: Arc::new(NoopIdProvider),
|
||||||
|
identity: None,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -2023,6 +2077,7 @@ mod tests {
|
|||||||
Some(ServingConfig {
|
Some(ServingConfig {
|
||||||
registry: Arc::clone(&consumer_registry),
|
registry: Arc::clone(&consumer_registry),
|
||||||
identity_provider: Arc::new(NoopIdProvider),
|
identity_provider: Arc::new(NoopIdProvider),
|
||||||
|
identity: None,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -2240,6 +2295,7 @@ mod tests {
|
|||||||
Some(ServingConfig {
|
Some(ServingConfig {
|
||||||
registry: Arc::clone(&consumer_registry),
|
registry: Arc::clone(&consumer_registry),
|
||||||
identity_provider: Arc::new(NoopIdProvider),
|
identity_provider: Arc::new(NoopIdProvider),
|
||||||
|
identity: None,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -2495,4 +2551,360 @@ mod tests {
|
|||||||
schema.result
|
schema.result
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CF-005 regression gates -----------------------------------------
|
||||||
|
|
||||||
|
/// The scope-gated open op the connect side serves — the
|
||||||
|
/// alktunnels reverse-flow POC shape (`worker/tunnel/open` with
|
||||||
|
/// `required_scopes`). The handler reports the identity the
|
||||||
|
/// dispatch resolved, so the tests can assert which path won.
|
||||||
|
fn scope_gated_open_registry() -> crate::registry::registration::OperationRegistry {
|
||||||
|
let registry = crate::registry::registration::OperationRegistry::new();
|
||||||
|
let acl = AccessControl {
|
||||||
|
required_scopes: vec!["tunnel:open".to_string()],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
registry
|
||||||
|
.register(HandlerRegistration::new(
|
||||||
|
OperationSpec::new(
|
||||||
|
"worker/tunnel/open",
|
||||||
|
OperationType::Mutation,
|
||||||
|
Visibility::External,
|
||||||
|
serde_json::json!({}),
|
||||||
|
serde_json::json!({}),
|
||||||
|
vec![],
|
||||||
|
acl,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
HandlerKind::Once(make_handler(|_input, ctx| async move {
|
||||||
|
ResponseEnvelope::ok(
|
||||||
|
ctx.request_id,
|
||||||
|
serde_json::json!({
|
||||||
|
"opened": true,
|
||||||
|
"caller": ctx.identity.map(|i| i.id),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})),
|
||||||
|
OperationProvenance::Local,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
crate::core::types::Capabilities::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accept-side (hub) install hook: serves nothing but echoes
|
||||||
|
/// nothing — it hands the channel-0 `CallConnection` back to the
|
||||||
|
/// test and runs the resolution-only serving loop, so the test can
|
||||||
|
/// initiate hub→worker calls.
|
||||||
|
fn cf005_accept_side_hook(
|
||||||
|
accept_conn_tx: tokio::sync::mpsc::Sender<Arc<CallConnection>>,
|
||||||
|
) -> crate::channels::adapter::InstallChannelZero {
|
||||||
|
Arc::new(move |_manager, channel0_conn, _auth| {
|
||||||
|
let accept_conn_tx = accept_conn_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||||
|
let call_connection = Arc::new(CallConnection::new_single_stream(
|
||||||
|
channel0_conn,
|
||||||
|
Arc::clone(&writer),
|
||||||
|
));
|
||||||
|
let _ = accept_conn_tx.send(Arc::clone(&call_connection)).await;
|
||||||
|
let reader = reader;
|
||||||
|
crate::protocol::connection::read_single_stream_until_closed(
|
||||||
|
reader,
|
||||||
|
call_connection.pending(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hub_identity() -> crate::core::auth::Identity {
|
||||||
|
crate::core::auth::Identity {
|
||||||
|
id: "hub".to_string(),
|
||||||
|
scopes: vec!["tunnel:open".to_string()],
|
||||||
|
resources: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn effective_override_identity() -> crate::core::auth::Identity {
|
||||||
|
crate::core::auth::Identity {
|
||||||
|
id: "worker-effective".to_string(),
|
||||||
|
scopes: vec!["tunnel:open".to_string()],
|
||||||
|
resources: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CF-005 remediation (b) — the transport connection's identity
|
||||||
|
/// propagates to channel 0, so a scope-gated op served by the
|
||||||
|
/// connect side authenticates the peer by transport identity (the
|
||||||
|
/// mTLS/QUIC key-based path). The consumer dials, sets the
|
||||||
|
/// transport identity (the assembly layer resolves it from the
|
||||||
|
/// transport handshake), then `from_connection_with_serving`; the
|
||||||
|
/// hub calls the scope-gated op with no token and is authorized.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cf005_transport_identity_propagates_to_connect_side_serving() {
|
||||||
|
let worker_registry = Arc::new(scope_gated_open_registry());
|
||||||
|
let (accept_conn_tx, mut accept_conn_rx) =
|
||||||
|
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
|
||||||
|
let install_hook = cf005_accept_side_hook(accept_conn_tx);
|
||||||
|
|
||||||
|
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||||
|
let client_conn =
|
||||||
|
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||||
|
client_conn
|
||||||
|
.set_identity(hub_identity())
|
||||||
|
.expect("transport identity set once");
|
||||||
|
let server_conn =
|
||||||
|
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||||
|
|
||||||
|
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||||
|
let auth = AuthContext::anonymous(b"alk/channels");
|
||||||
|
let _server_handle = tokio::spawn(async move {
|
||||||
|
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let _client = ChannelClient::from_connection_with_serving(
|
||||||
|
client_conn,
|
||||||
|
Some(ServingConfig {
|
||||||
|
registry: Arc::clone(&worker_registry),
|
||||||
|
identity_provider: Arc::new(NoopIdProvider),
|
||||||
|
identity: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("channel client init");
|
||||||
|
|
||||||
|
let accept_conn = accept_conn_rx
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.expect("accept side channel-0 connection handle");
|
||||||
|
|
||||||
|
let response = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
accept_conn.call("worker/tunnel/open", serde_json::json!({ "c": 1 })),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("hub→worker scope-gated call timed out");
|
||||||
|
|
||||||
|
let out = response
|
||||||
|
.result
|
||||||
|
.expect("scope-gated op authorized by the propagated transport identity");
|
||||||
|
assert_eq!(out["opened"], serde_json::json!(true));
|
||||||
|
assert_eq!(
|
||||||
|
out["caller"], "hub",
|
||||||
|
"the handler saw the transport-derived identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CF-005 remediation (a) — `ServingConfig.identity` explicitly
|
||||||
|
/// overrides, winning over the transport connection's identity.
|
||||||
|
/// Both are set here; the handler must see the override.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cf005_serving_config_identity_overrides_transport_identity() {
|
||||||
|
let worker_registry = Arc::new(scope_gated_open_registry());
|
||||||
|
let (accept_conn_tx, mut accept_conn_rx) =
|
||||||
|
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
|
||||||
|
let install_hook = cf005_accept_side_hook(accept_conn_tx);
|
||||||
|
|
||||||
|
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||||
|
let client_conn =
|
||||||
|
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||||
|
client_conn
|
||||||
|
.set_identity(hub_identity())
|
||||||
|
.expect("transport identity set once");
|
||||||
|
let server_conn =
|
||||||
|
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||||
|
|
||||||
|
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||||
|
let auth = AuthContext::anonymous(b"alk/channels");
|
||||||
|
let _server_handle = tokio::spawn(async move {
|
||||||
|
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let _client = ChannelClient::from_connection_with_serving(
|
||||||
|
client_conn,
|
||||||
|
Some(ServingConfig {
|
||||||
|
registry: Arc::clone(&worker_registry),
|
||||||
|
identity_provider: Arc::new(NoopIdProvider),
|
||||||
|
identity: Some(effective_override_identity()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("channel client init");
|
||||||
|
|
||||||
|
let accept_conn = accept_conn_rx
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.expect("accept side channel-0 connection handle");
|
||||||
|
|
||||||
|
let response = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
accept_conn.call("worker/tunnel/open", serde_json::json!({ "c": 1 })),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("hub→worker scope-gated call timed out");
|
||||||
|
|
||||||
|
let out = response
|
||||||
|
.result
|
||||||
|
.expect("the override identity satisfies the scope gate");
|
||||||
|
assert_eq!(out["caller"], "worker-effective", "the override won");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CF-005 negative case — no identity anywhere (no
|
||||||
|
/// `ServingConfig.identity`, no transport identity, no token): the
|
||||||
|
/// scope-gated op is denied `FORBIDDEN` ("authentication
|
||||||
|
/// required"). Fails closed, unchanged.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cf005_scope_gated_op_denied_without_any_identity() {
|
||||||
|
let worker_registry = Arc::new(scope_gated_open_registry());
|
||||||
|
let (accept_conn_tx, mut accept_conn_rx) =
|
||||||
|
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
|
||||||
|
let install_hook = cf005_accept_side_hook(accept_conn_tx);
|
||||||
|
|
||||||
|
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||||
|
let client_conn =
|
||||||
|
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||||
|
let server_conn =
|
||||||
|
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||||
|
|
||||||
|
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||||
|
let auth = AuthContext::anonymous(b"alk/channels");
|
||||||
|
let _server_handle = tokio::spawn(async move {
|
||||||
|
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let _client = ChannelClient::from_connection_with_serving(
|
||||||
|
client_conn,
|
||||||
|
Some(ServingConfig {
|
||||||
|
registry: Arc::clone(&worker_registry),
|
||||||
|
identity_provider: Arc::new(NoopIdProvider),
|
||||||
|
identity: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("channel client init");
|
||||||
|
|
||||||
|
let accept_conn = accept_conn_rx
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.expect("accept side channel-0 connection handle");
|
||||||
|
|
||||||
|
let response = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
accept_conn.call("worker/tunnel/open", serde_json::json!({ "c": 1 })),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("hub→worker scope-gated call timed out");
|
||||||
|
|
||||||
|
let err = response
|
||||||
|
.result
|
||||||
|
.expect_err("identity-less call must be denied");
|
||||||
|
assert_eq!(err.code, "FORBIDDEN", "no identity on the connect side");
|
||||||
|
assert!(
|
||||||
|
err.message.contains("authentication required"),
|
||||||
|
"AccessControl::check(None) message, got: {}",
|
||||||
|
err.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CF-005 token fallback — the payload `auth_token` →
|
||||||
|
/// `ServingConfig.identity_provider` path still works (the
|
||||||
|
/// hub-forwarding / browser-token path, ADR-017 §7), with
|
||||||
|
/// precedence over the propagated transport identity.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cf005_auth_token_fallback_and_precedence() {
|
||||||
|
struct TokenIdProvider;
|
||||||
|
impl IdentityProvider for TokenIdProvider {
|
||||||
|
fn resolve_from_fingerprint(&self, _: &str) -> Option<crate::core::auth::Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn resolve_from_token(
|
||||||
|
&self,
|
||||||
|
token: &crate::core::auth::AuthToken,
|
||||||
|
) -> Option<crate::core::auth::Identity> {
|
||||||
|
if token.raw == b"alk_worker_token" {
|
||||||
|
Some(crate::core::auth::Identity {
|
||||||
|
id: "token-resolved".to_string(),
|
||||||
|
scopes: vec!["tunnel:open".to_string()],
|
||||||
|
resources: Default::default(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let worker_registry = Arc::new(scope_gated_open_registry());
|
||||||
|
let (accept_conn_tx, mut accept_conn_rx) =
|
||||||
|
tokio::sync::mpsc::channel::<Arc<CallConnection>>(1);
|
||||||
|
let install_hook = cf005_accept_side_hook(accept_conn_tx);
|
||||||
|
|
||||||
|
let (client_end, server_end) = tokio::io::duplex(64 * 1024);
|
||||||
|
let client_conn =
|
||||||
|
Connection::from_bidi(client_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||||
|
client_conn
|
||||||
|
.set_identity(hub_identity())
|
||||||
|
.expect("transport identity set once");
|
||||||
|
let server_conn =
|
||||||
|
Connection::from_bidi(server_end, b"alk/channels".to_vec(), Some(TEST_ADDR));
|
||||||
|
|
||||||
|
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||||
|
let auth = AuthContext::anonymous(b"alk/channels");
|
||||||
|
let _server_handle = tokio::spawn(async move {
|
||||||
|
let _ = crate::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let _client = ChannelClient::from_connection_with_serving(
|
||||||
|
client_conn,
|
||||||
|
Some(ServingConfig {
|
||||||
|
registry: Arc::clone(&worker_registry),
|
||||||
|
identity_provider: Arc::new(TokenIdProvider),
|
||||||
|
identity: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("channel client init");
|
||||||
|
|
||||||
|
let accept_conn = accept_conn_rx
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.expect("accept side channel-0 connection handle");
|
||||||
|
|
||||||
|
// Tokenless: the propagated transport identity authorizes.
|
||||||
|
let transport = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
accept_conn.call("worker/tunnel/open", serde_json::json!({ "c": 1 })),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("transport-identity call timed out");
|
||||||
|
let out = transport
|
||||||
|
.result
|
||||||
|
.expect("transport identity authorizes the scope gate");
|
||||||
|
assert_eq!(out["caller"], "hub");
|
||||||
|
|
||||||
|
// With a token: the token resolution wins.
|
||||||
|
let tokened = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
accept_conn.call_with_payload(serde_json::json!({
|
||||||
|
"operationId": "worker/tunnel/open",
|
||||||
|
"input": { "c": 2 },
|
||||||
|
"auth_token": "alk_worker_token",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("token call timed out");
|
||||||
|
let out = tokened
|
||||||
|
.result
|
||||||
|
.expect("the token resolves an identity satisfying the scope gate");
|
||||||
|
assert_eq!(
|
||||||
|
out["caller"], "token-resolved",
|
||||||
|
"the token resolution won over the transport identity"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,23 @@ pub trait IdentityProvider: Send + Sync + 'static {
|
|||||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
|
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An [`IdentityProvider`] that resolves nothing — every lookup
|
||||||
|
/// returns `None`. The identity-less posture for consumers that serve
|
||||||
|
/// ops with no ACL restrictions (or gate by other means), and the
|
||||||
|
/// default for [`crate::channels::client::ServingConfig`]. Downstream
|
||||||
|
/// crates supply a real impl (config-backed, vault-backed) to resolve
|
||||||
|
/// tokens or fingerprints.
|
||||||
|
pub struct NoopIdentityProvider;
|
||||||
|
|
||||||
|
impl IdentityProvider for NoopIdentityProvider {
|
||||||
|
fn resolve_from_fingerprint(&self, _: &str) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
fn resolve_from_token(&self, _: &AuthToken) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Reference in new issue
Block a user