diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b660fc..0894bbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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` (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 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-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.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 diff --git a/Cargo.lock b/Cargo.lock index 6d8d58c..337e1e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,7 +27,7 @@ dependencies = [ [[package]] name = "alkcall" -version = "0.6.0" +version = "0.7.0" dependencies = [ "async-trait", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 42aeacd..b61ca01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "alkcall" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version = "1.85" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index d4dfca7..d92cf9e 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ let client = ChannelClient::from_connection_with_serving( Some(ServingConfig { registry: Arc::clone(®istry), identity_provider: provider, + identity: None, // peer identity: transport `Connection::set_identity` propagates }), ).await?; // peer-callable ops resolve against `registry` on channel 0; diff --git a/docs/architecture/decisions/022-call-protocol-client-and-adapter-contract.md b/docs/architecture/decisions/022-call-protocol-client-and-adapter-contract.md index 3c8d8c6..915a1b7 100644 --- a/docs/architecture/decisions/022-call-protocol-client-and-adapter-contract.md +++ b/docs/architecture/decisions/022-call-protocol-client-and-adapter-contract.md @@ -402,12 +402,32 @@ surfaces discovery failure as `AdapterError::DiscoveryFailed`). `ChannelClient::from_connection_with_serving(connection, Option)` — 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; diff --git a/docs/reviews/consumer-findings-ledger.md b/docs/reviews/consumer-findings-ledger.md index fa9578d..44ebb86 100644 --- a/docs/reviews/consumer-findings-ledger.md +++ b/docs/reviews/consumer-findings-ledger.md @@ -11,7 +11,13 @@ Format: date | found-in (alkhttp context) | severity | status. ## 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 (`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 direct connect-side serving where the transport already authenticated the peer. -- **Candidate remediations (upstream's call):** (a) `ServingConfig` - gains an optional identity (or the channel-0 connection is exposed - for `set_identity` before the serving loop starts); (b) documented - token-only posture for connect-side serving. Either is additive. -- **Status:** open — filed 2026-09-07 (alktunnels reverse-flow POC W1). +- **Verification:** confirmed statically (the chain + `Connection::from_source` → empty `OnceLock` identity → + `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 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` — 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 - **Found in:** alkhttp Review 002, finding PRJ-16 diff --git a/src/channels/client.rs b/src/channels/client.rs index 9547141..03493b8 100644 --- a/src/channels/client.rs +++ b/src/channels/client.rs @@ -111,9 +111,35 @@ pub struct ChannelClient { /// loop (`Dispatcher::serve_single_stream`), so inbound /// `call.requested` frames from the peer dispatch and resolve instead /// 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 registry: Arc, pub identity_provider: Arc, + /// 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, +} + +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 { @@ -141,6 +167,16 @@ impl ChannelClient { /// and outbound pendings still resolve. This is the opt-in that /// makes the connect side a serving half on channel 0 (ADR-022 §2 /// — 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( connection: Connection, serving: Option, @@ -178,6 +214,22 @@ impl ChannelClient { let channel0_source = super::source::channel_source(channel0_recv, channel0_send, remote_addr); 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 (single_stream_writer, single_stream_reader) = crate::protocol::connection::split_single_stream(channel0_bidi); @@ -1675,6 +1727,7 @@ mod tests { Some(ServingConfig { registry: Arc::clone(&consumer_registry), identity_provider: Arc::new(NoopIdProvider), + identity: None, }), ) .await @@ -1809,6 +1862,7 @@ mod tests { Some(ServingConfig { registry: Arc::clone(&consumer_registry), identity_provider: Arc::new(NoopIdProvider), + identity: None, }), ) .await @@ -2023,6 +2077,7 @@ mod tests { Some(ServingConfig { registry: Arc::clone(&consumer_registry), identity_provider: Arc::new(NoopIdProvider), + identity: None, }), ) .await @@ -2240,6 +2295,7 @@ mod tests { Some(ServingConfig { registry: Arc::clone(&consumer_registry), identity_provider: Arc::new(NoopIdProvider), + identity: None, }), ) .await @@ -2495,4 +2551,360 @@ mod tests { 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>, + ) -> 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::>(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::>(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::>(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 { + None + } + fn resolve_from_token( + &self, + token: &crate::core::auth::AuthToken, + ) -> Option { + 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::>(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" + ); + } } diff --git a/src/core/auth.rs b/src/core/auth.rs index 5092d04..5441c97 100644 --- a/src/core/auth.rs +++ b/src/core/auth.rs @@ -50,6 +50,23 @@ pub trait IdentityProvider: Send + Sync + 'static { fn resolve_from_token(&self, token: &AuthToken) -> Option; } +/// 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 { + None + } + fn resolve_from_token(&self, _: &AuthToken) -> Option { + None + } +} + #[cfg(test)] mod tests { use super::*;