From 5cf3cfc24a4603891a5bed5a3f2a5379dd88e3be Mon Sep 17 00:00:00 2001 From: Spikel Date: Fri, 12 Jun 2026 21:27:53 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20BYOK=20auth-passthrough=20=E2=80=94=20f?= =?UTF-8?q?orward=20per-caller=20credentials=20to=20upstreams?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in per-upstream `auth_passthrough` mode: the gateway forwards each caller's own `Authorization` to the upstream instead of a static `bearer_token`. Enables multi-tenant BYOK (e.g. per-user NEAR AI keys) while attestation, verification, channel binding, and receipts are unchanged. Closes #11. - `UpstreamConfig.auth_passthrough` (default false); rejected at config validation if combined with a static `bearer_token`, and rejected for providers whose backend ignores it (Chutes). - `OpenAICompatibleBackend` forwards only the caller credential in passthrough mode and never the static token (absent credential => no auth header, a visible upstream rejection). - Caller credential threaded handler -> service -> backend via `UpstreamRequest.client_authorization`, a `ClientAuthorization` newtype whose Debug redacts so a raw key cannot reach logs or receipts. - Middleware mode is unsupported (the request store keeps only a hashed requester) and fails closed at startup. Tests: config parse/validation (defaults off, rejects static token, rejects unsupported provider) + an e2e forwarding test asserting the caller credential reaches the upstream verbatim and an absent credential yields no Authorization header. check/clippy/fmt clean; lib + provider_e2e + surface suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/live_chutes_embedding.rs | 1 + src/aci/upstream/mod.rs | 18 ++++ src/aci/upstream/openai.rs | 23 ++++- src/aggregator/service/forward.rs | 7 +- src/aggregator/service/middleware.rs | 1 + src/aggregator/service/wire.rs | 6 ++ src/aggregator/upstream_config/builders.rs | 20 +++- src/aggregator/upstream_config/mod.rs | 7 ++ src/aggregator/upstream_config/tests.rs | 78 ++++++++++++++ src/aggregator/upstream_config/validation.rs | 16 +++ src/http/app/backend.rs | 13 ++- src/http/app/handlers.rs | 4 + src/main.rs | 16 +++ tests/aci_service_surface.rs | 1 + tests/dstack_live.rs | 1 + tests/provider_e2e.rs | 102 +++++++++++++++++++ 16 files changed, 310 insertions(+), 4 deletions(-) diff --git a/examples/live_chutes_embedding.rs b/examples/live_chutes_embedding.rs index e0fb79c..b927ddd 100644 --- a/examples/live_chutes_embedding.rs +++ b/examples/live_chutes_embedding.rs @@ -132,6 +132,7 @@ async fn run() -> Result<(), String> { headers: HashMap::new(), path: Some("/v1/embeddings".to_string()), target_route_id: None, + client_authorization: Default::default(), }; let prepared = backend .prepare(request) diff --git a/src/aci/upstream/mod.rs b/src/aci/upstream/mod.rs index 29d770e..b7d5e8f 100644 --- a/src/aci/upstream/mod.rs +++ b/src/aci/upstream/mod.rs @@ -43,12 +43,30 @@ use openai::request_model_id; pub const DEFAULT_UPSTREAM_CONNECT_TIMEOUT_SECONDS: u64 = 10; pub const DEFAULT_UPSTREAM_READ_TIMEOUT_SECONDS: u64 = 600; +/// Per-request upstream credential carried through the forwarding path +/// for BYOK auth-passthrough. Holds the caller's token in memory only and +/// redacts on `Debug` so a raw key can never land in logs or receipts. +#[derive(Clone, Default)] +pub struct ClientAuthorization(pub Option); + +impl std::fmt::Debug for ClientAuthorization { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + Some(_) => f.write_str("ClientAuthorization()"), + None => f.write_str("ClientAuthorization(None)"), + } + } +} + #[derive(Debug, Clone, Default)] pub struct UpstreamRequest { pub body: Vec, pub headers: HashMap, pub path: Option, pub target_route_id: Option, + /// Caller credential for BYOK auth-passthrough upstreams. Ignored by + /// backends that are not configured for passthrough. + pub client_authorization: ClientAuthorization, } #[derive(Debug, Clone)] diff --git a/src/aci/upstream/openai.rs b/src/aci/upstream/openai.rs index 468a1f0..8687d7b 100644 --- a/src/aci/upstream/openai.rs +++ b/src/aci/upstream/openai.rs @@ -28,6 +28,11 @@ pub struct OpenAICompatibleBackend { base_url: String, path: String, bearer_token: Option, + /// When true, the caller's per-request credential + /// ([`UpstreamRequest::client_authorization`]) is forwarded as the + /// upstream `Authorization` and the static `bearer_token` is never used + /// (BYOK). Set from `UpstreamConfig::auth_passthrough`. + auth_passthrough: bool, client: reqwest::Client, connect_timeout_seconds: u64, read_timeout_seconds: u64, @@ -61,6 +66,7 @@ impl OpenAICompatibleBackend { base_url: base, path: "/v1/chat/completions".to_string(), bearer_token: None, + auth_passthrough: false, client, connect_timeout_seconds, read_timeout_seconds, @@ -85,6 +91,13 @@ impl OpenAICompatibleBackend { self.bearer_token = Some(token.into()); self } + + /// Enable BYOK auth-passthrough: forward the caller's per-request + /// credential to the upstream instead of a statically-configured token. + pub fn with_auth_passthrough(mut self, enabled: bool) -> Self { + self.auth_passthrough = enabled; + self + } } pub(super) fn request_model_id(body: &[u8]) -> Option { @@ -325,7 +338,15 @@ impl OpenAICompatibleBackend { for (k, v) in req.headers.iter() { builder = builder.header(k, v); } - if let Some(t) = &self.bearer_token { + if self.auth_passthrough { + // BYOK: forward only the caller's credential; never fall back to + // a static token for a passthrough upstream. Absent credential => + // no auth header (the upstream rejects, which is the correct, + // visible failure). + if let Some(t) = &req.client_authorization.0 { + builder = builder.header("authorization", format!("Bearer {t}")); + } + } else if let Some(t) = &self.bearer_token { builder = builder.header("authorization", format!("Bearer {t}")); } builder diff --git a/src/aggregator/service/forward.rs b/src/aggregator/service/forward.rs index f0f3bb4..c670283 100644 --- a/src/aggregator/service/forward.rs +++ b/src/aggregator/service/forward.rs @@ -4,7 +4,9 @@ use crate::aci::receipt::{ ReceiptBuilder, ReceiptError, TransparencyEventKind, UpstreamVerifiedEvent, VerificationResult, }; use crate::aci::types::Receipt; -use crate::aci::upstream::{PreparedUpstreamRequest, UpstreamError, UpstreamRequest}; +use crate::aci::upstream::{ + ClientAuthorization, PreparedUpstreamRequest, UpstreamError, UpstreamRequest, +}; use crate::aggregator::metrics::{RequestMode, StreamErrorKind}; use crate::aggregator::session::{ AttestedSession, EvidenceRef, SessionClaims, WorkloadIdentityRef, @@ -40,6 +42,7 @@ impl AciService { upstream_verification_event, requester: None, e2ee: None, + client_authorization: ClientAuthorization(None), }) .await } @@ -66,6 +69,7 @@ impl AciService { body: backend_input_body, path: Some(endpoint_path.to_string()), target_route_id: target_route_id.clone(), + client_authorization: req.client_authorization.clone(), ..Default::default() })?; let forwarded_body = prepared.request.body.clone(); @@ -209,6 +213,7 @@ impl AciService { body: backend_input_body, path: Some(endpoint_path.to_string()), target_route_id: target_route_id.clone(), + client_authorization: req.client_authorization.clone(), ..Default::default() })?; let forwarded_body = prepared.request.body.clone(); diff --git a/src/aggregator/service/middleware.rs b/src/aggregator/service/middleware.rs index e9c0170..906da98 100644 --- a/src/aggregator/service/middleware.rs +++ b/src/aggregator/service/middleware.rs @@ -124,6 +124,7 @@ impl AciService { body: candidate.body.clone(), path: Some(endpoint_path.to_string()), target_route_id: Some(route_id.clone()), + client_authorization: req.client_authorization.clone(), ..Default::default() }) { Ok(prepared) => prepared, diff --git a/src/aggregator/service/wire.rs b/src/aggregator/service/wire.rs index 76bc685..92e40d8 100644 --- a/src/aggregator/service/wire.rs +++ b/src/aggregator/service/wire.rs @@ -8,6 +8,7 @@ use futures_util::Stream; use super::{ReceiptOwner, ServiceError}; use crate::aci::receipt::{ReceiptBuilder, UpstreamVerifiedEvent}; use crate::aci::types::Receipt; +use crate::aci::upstream::ClientAuthorization; use crate::aggregator::metrics::RequestMode; pub struct E2eeRequestParts<'a> { @@ -228,6 +229,11 @@ pub struct ChatCompletionRequest<'a> { /// receipt that any caller can retrieve. pub requester: Option, pub e2ee: Option, + /// Caller credential to forward to a BYOK auth-passthrough upstream. + /// Empty for non-passthrough upstreams (the static token is used). + /// Typed as [`ClientAuthorization`] so the raw secret redacts on `Debug` + /// end-to-end, not just at the final hop. + pub client_authorization: ClientAuthorization, } #[derive(Debug, Clone, Default)] diff --git a/src/aggregator/upstream_config/builders.rs b/src/aggregator/upstream_config/builders.rs index 3215b67..d499012 100644 --- a/src/aggregator/upstream_config/builders.rs +++ b/src/aggregator/upstream_config/builders.rs @@ -82,6 +82,23 @@ fn provider_is_tee(provider: UpstreamProvider) -> bool { } } +/// Whether a provider's backend honours BYOK `auth_passthrough`. Only the +/// providers served by `OpenAICompatibleBackend` (wired with +/// `with_auth_passthrough` below) forward the caller credential. Chutes uses a +/// dedicated E2EE transport that ignores it, so enabling passthrough there +/// would be a silent no-op — rejected at validation instead. Keep this in sync +/// with the backend match arms in `build_provider_backend`. +pub(super) fn provider_supports_auth_passthrough(provider: UpstreamProvider) -> bool { + match provider { + UpstreamProvider::Chutes => false, + UpstreamProvider::OpenAiCompatible + | UpstreamProvider::AciDcap + | UpstreamProvider::Tinfoil + | UpstreamProvider::NearAi + | UpstreamProvider::PhalaDirect => true, + } +} + fn build_provider_backend( cfg: &UpstreamConfig, options: &UpstreamRuntimeOptions, @@ -118,7 +135,8 @@ fn build_provider_backend( read_timeout_seconds, ) .map_err(|e| UpstreamConfigError::InvalidConfig(e.to_string()))? - .with_name(cfg.name.clone()); + .with_name(cfg.name.clone()) + .with_auth_passthrough(cfg.auth_passthrough); if let Some(token) = &cfg.bearer_token { backend = backend.with_bearer_token(token.clone()); } diff --git a/src/aggregator/upstream_config/mod.rs b/src/aggregator/upstream_config/mod.rs index 860e434..dc972fd 100644 --- a/src/aggregator/upstream_config/mod.rs +++ b/src/aggregator/upstream_config/mod.rs @@ -48,6 +48,11 @@ pub struct UpstreamConfig { pub models: BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub bearer_token: Option, + /// BYOK: forward each caller's own `Authorization` to the upstream + /// instead of a static `bearer_token`. Mutually exclusive with + /// `bearer_token` (rejected at config validation). + #[serde(default)] + pub auth_passthrough: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub accepted_workload_ids: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -87,6 +92,7 @@ pub struct PublicUpstreamConfig { pub path: Option, pub models: BTreeMap, pub bearer_token_configured: bool, + pub auth_passthrough: bool, #[serde(skip_serializing_if = "Option::is_none")] pub accepted_workload_ids: Option>, #[serde(skip_serializing_if = "Option::is_none")] @@ -126,6 +132,7 @@ impl UpstreamConfig { path: self.path.clone(), models: self.models.clone(), bearer_token_configured: self.bearer_token.is_some(), + auth_passthrough: self.auth_passthrough, accepted_workload_ids: self.accepted_workload_ids.clone(), accepted_image_digests: self.accepted_image_digests.clone(), accepted_dstack_kms_root_public_keys: self.accepted_dstack_kms_root_public_keys.clone(), diff --git a/src/aggregator/upstream_config/tests.rs b/src/aggregator/upstream_config/tests.rs index 3423ddd..b325018 100644 --- a/src/aggregator/upstream_config/tests.rs +++ b/src/aggregator/upstream_config/tests.rs @@ -23,6 +23,7 @@ fn test_upstream_config( path: None, models: BTreeMap::from([(public_model.to_string(), upstream_model.to_string())]), bearer_token: None, + auth_passthrough: false, accepted_workload_ids: None, accepted_image_digests: None, accepted_dstack_kms_root_public_keys: None, @@ -89,6 +90,82 @@ fn parse_config_allows_same_public_model_on_distinct_route_ids() { assert_eq!(config.len(), 2); } +#[test] +fn byok_auth_passthrough_parses_and_defaults_off() { + let config = parse_config_text( + r#" + [ + { + "name": "near-ai", + "provider": "near-ai", + "base_url": "https://cloud-api.near.ai", + "models": {"openai/gpt-oss-120b": "near-model"}, + "auth_passthrough": true + }, + { + "name": "plain", + "provider": "openai-compatible", + "base_url": "https://plain.example", + "models": {"m": "u"} + } + ] + "#, + ) + .expect("auth_passthrough upstream is valid without a static token"); + + assert!(config[0].auth_passthrough); + assert!(!config[1].auth_passthrough, "defaults off when omitted"); +} + +#[test] +fn byok_auth_passthrough_rejects_static_bearer_token() { + let err = parse_config_text( + r#" + [ + { + "name": "near-ai", + "provider": "near-ai", + "base_url": "https://cloud-api.near.ai", + "models": {"openai/gpt-oss-120b": "near-model"}, + "auth_passthrough": true, + "bearer_token": "should-not-be-here" + } + ] + "#, + ) + .expect_err("auth_passthrough + bearer_token must be rejected"); + + assert!( + matches!(err, UpstreamConfigError::InvalidConfig(msg) if msg.contains("auth_passthrough")), + "error should name the offending field", + ); +} + +#[test] +fn byok_auth_passthrough_rejects_providers_that_do_not_support_it() { + // Chutes uses a dedicated E2EE transport that ignores auth_passthrough, + // so enabling it there is a silent no-op and must be rejected. + let err = parse_config_text( + r#" + [ + { + "name": "chutes-provider", + "provider": "chutes", + "base_url": "https://chutes.example", + "models": {"m": "u"}, + "auth_passthrough": true + } + ] + "#, + ) + .expect_err("auth_passthrough on an unsupported provider must be rejected"); + + assert!( + matches!(err, UpstreamConfigError::InvalidConfig(msg) if msg.contains("does not")), + "error should explain the provider does not support passthrough", + ); +} + #[test] fn global_aci_dcap_does_not_require_policy_for_plain_openai_compatible_upstreams() { let config = vec![ @@ -166,6 +243,7 @@ async fn prewarm_verification_deduplicates_upstream_models() { ("public-c".to_string(), "upstream-c".to_string()), ]), bearer_token: None, + auth_passthrough: false, accepted_workload_ids: None, accepted_image_digests: None, accepted_dstack_kms_root_public_keys: None, diff --git a/src/aggregator/upstream_config/validation.rs b/src/aggregator/upstream_config/validation.rs index 19290f4..2b1d6c1 100644 --- a/src/aggregator/upstream_config/validation.rs +++ b/src/aggregator/upstream_config/validation.rs @@ -156,6 +156,22 @@ pub(super) fn validate_config(config: &[UpstreamConfig]) -> Result<(), UpstreamC upstream.name ))); } + if upstream.auth_passthrough && upstream.bearer_token.is_some() { + return Err(UpstreamConfigError::InvalidConfig(format!( + "upstream {:?} sets both auth_passthrough and bearer_token; \ + a passthrough upstream must not configure a static token", + upstream.name + ))); + } + if upstream.auth_passthrough + && !super::builders::provider_supports_auth_passthrough(upstream.provider) + { + return Err(UpstreamConfigError::InvalidConfig(format!( + "upstream {:?} sets auth_passthrough but provider {:?} does not \ + support it (only OpenAI-compatible-forwarded providers do)", + upstream.name, upstream.provider + ))); + } for (field, value) in [ ("connect_timeout_seconds", upstream.connect_timeout_seconds), ("read_timeout_seconds", upstream.read_timeout_seconds), diff --git a/src/http/app/backend.rs b/src/http/app/backend.rs index 471a049..3ef29fa 100644 --- a/src/http/app/backend.rs +++ b/src/http/app/backend.rs @@ -13,7 +13,7 @@ use futures_util::StreamExt; use rand::RngCore; use serde_json::Value; -use crate::aci::upstream::UpstreamError; +use crate::aci::upstream::{ClientAuthorization, UpstreamError}; use crate::aggregator::service::{ AciService, ChatCompletionRequest, E2eeRequestContext, E2eeResponseInfo, GatewayRequestContext, MiddlewareForwardResult, ReceiptOwner, ServiceError, StreamingForwardResult, @@ -34,6 +34,10 @@ pub(super) struct BackendForwardInput { pub(super) upstream_required: bool, pub(super) requester: Option, pub(super) e2ee: Option, + /// Caller credential for BYOK auth-passthrough upstreams. Forwarded to the + /// backend in memory only; the [`ClientAuthorization`] newtype redacts on + /// `Debug` so the raw secret cannot be logged or persisted. + pub(super) client_authorization: ClientAuthorization, pub(super) stream: bool, } @@ -52,6 +56,7 @@ pub(super) async fn forward_to_backend( upstream_verification_event: None, requester: input.requester, e2ee: input.e2ee, + client_authorization: input.client_authorization, }) .await; return match result { @@ -104,6 +109,7 @@ pub(super) async fn forward_to_backend( upstream_verification_event: None, requester: input.requester, e2ee: input.e2ee, + client_authorization: input.client_authorization, }) .await; match result { @@ -181,6 +187,11 @@ pub(super) async fn internal_forward( upstream_verification_event: None, requester: stored.requester, e2ee: stored.e2ee, + // Middleware mode deliberately does not carry the raw caller + // credential (the request store keeps only a hashed requester), + // so BYOK passthrough is unsupported on this path by design + // (and rejected at startup when middleware is enabled). + client_authorization: ClientAuthorization(None), }, candidates, stream, diff --git a/src/http/app/handlers.rs b/src/http/app/handlers.rs index 4815574..74f7bb1 100644 --- a/src/http/app/handlers.rs +++ b/src/http/app/handlers.rs @@ -17,6 +17,7 @@ use crate::aci::keys::{ ethereum_address_from_uncompressed_public_key, KeyError, LEGACY_ALGO_ECDSA, LEGACY_ALGO_ED25519, }; use crate::aci::types::AttestationReport; +use crate::aci::upstream::ClientAuthorization; use crate::aggregator::service::{ E2eeRequestParts, GatewayRequestContext, MiddlewareReceiptJournal, ReceiptOwner, ServiceError, CHAT_COMPLETIONS_PATH, COMPLETIONS_PATH, EMBEDDINGS_PATH, MESSAGES_PATH, RESPONSES_PATH, @@ -481,6 +482,9 @@ pub(super) async fn openai_completion_endpoint( upstream_required, requester, e2ee, + // BYOK passthrough: the backend forwards this only for + // auth_passthrough upstreams; ignored otherwise. + client_authorization: ClientAuthorization(extract_bearer(&headers)), stream, }, ) diff --git a/src/main.rs b/src/main.rs index dc0f0a5..b459d5b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -544,6 +544,22 @@ async fn main() -> Result<(), Box> { spawn_upstream_lifecycle(upstream_config.clone()); let app = if let Some(executor_uds_path) = executor_uds_path { + // BYOK auth-passthrough cannot work in middleware mode: the request + // store keeps only a hashed requester, so the caller credential is not + // available on the middleware finalize path. Fail closed at startup + // rather than silently 401 every passthrough request at runtime. + if upstream_config + .snapshot() + .upstreams + .iter() + .any(|u| u.auth_passthrough) + { + return Err( + "auth_passthrough upstreams are not supported in middleware mode; \ + disable middleware (unset the executor UDS path) or remove auth_passthrough" + .into(), + ); + } let request_store = GatewayRequestStore::default(); let backend_app = build_internal_backend_router(service.clone(), request_store.clone()); let backend_listener = bind_unix_listener(&backend_uds_path)?; diff --git a/tests/aci_service_surface.rs b/tests/aci_service_surface.rs index 9f94ca7..bcfab50 100644 --- a/tests/aci_service_surface.rs +++ b/tests/aci_service_surface.rs @@ -674,6 +674,7 @@ async fn request_rewrite_is_recorded_by_hash_without_retaining_the_body() { }), requester: Some(ReceiptOwner::from_bearer("requester-a")), e2ee: None, + client_authorization: Default::default(), }) .await .unwrap(); diff --git a/tests/dstack_live.rs b/tests/dstack_live.rs index 3a1e450..5734e3f 100644 --- a/tests/dstack_live.rs +++ b/tests/dstack_live.rs @@ -328,6 +328,7 @@ async fn dstack_live_aci_report_and_receipt_chain_verify() { upstream_verification_event: None, requester: None, e2ee: None, + client_authorization: Default::default(), }) .await .unwrap(); diff --git a/tests/provider_e2e.rs b/tests/provider_e2e.rs index eea1e7f..2eda359 100644 --- a/tests/provider_e2e.rs +++ b/tests/provider_e2e.rs @@ -474,6 +474,36 @@ async fn call( (status, headers, body) } +/// Like [`call`] but sets (or omits) an inbound `Authorization` header, so a +/// test can exercise BYOK auth-passthrough where the caller credential is what +/// reaches the upstream. +async fn call_with_auth( + app: Router, + method: &str, + uri: &str, + body: impl Into>, + authorization: Option<&str>, +) -> (StatusCode, HeaderMap, Vec) { + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json"); + if let Some(value) = authorization { + builder = builder.header("authorization", value); + } + let response = app + .oneshot(builder.body(Body::from(body.into())).unwrap()) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(); + (status, headers, body) +} + fn service_for_manager(manager: Arc) -> Arc { Arc::new( AciService::new_with_upstream_verifier( @@ -639,6 +669,7 @@ async fn openai_compatible_provider_e2e_via_runtime_config() { path: None, models: BTreeMap::from([("public-model".to_string(), "provider-model".to_string())]), bearer_token: Some("provider-secret".to_string()), + auth_passthrough: false, accepted_workload_ids: None, accepted_image_digests: None, accepted_dstack_kms_root_public_keys: None, @@ -707,6 +738,74 @@ async fn openai_compatible_provider_e2e_via_runtime_config() { let _ = std::fs::remove_file(path); } +#[tokio::test] +async fn byok_auth_passthrough_forwards_caller_credential_not_a_static_token() { + let (base_url, provider_calls) = serve_openai_provider_fixture().await; + let path = temp_config_path(); + let manager = Arc::new( + UpstreamConfigManager::load(&path, runtime_options(UpstreamVerifierMode::Preverified)) + .unwrap(), + ); + manager + .replace(vec![UpstreamConfig { + name: "byok-provider".to_string(), + provider: UpstreamProvider::OpenAiCompatible, + base_url: base_url.clone(), + path: None, + models: BTreeMap::from([("public-model".to_string(), "provider-model".to_string())]), + bearer_token: None, + auth_passthrough: true, + accepted_workload_ids: None, + accepted_image_digests: None, + accepted_dstack_kms_root_public_keys: None, + pccs_url: None, + verifier_cache_seconds: None, + connect_timeout_seconds: None, + read_timeout_seconds: None, + verifier_request_timeout_seconds: None, + verification_refresh_seconds: None, + session_refresh_seconds: None, + chutes_e2ee_api_base: None, + chutes_chute_ids: None, + chutes_e2ee_discovery_rounds: None, + chutes_e2ee_discovery_interval_seconds: None, + }]) + .unwrap(); + let service = service_for_manager(manager); + let app = build_router(service); + + // (a) The caller's own credential — not any static token — reaches upstream. + let (status, _, body) = call_with_auth( + app.clone(), + "POST", + "/v1/chat/completions", + CHAT_REQUEST, + Some("Bearer caller-byok-key"), + ) + .await; + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + + // (b) Absent caller credential => no Authorization header forwarded (a + // visible upstream decision), never a silent fallback. + let (status, _, body) = + call_with_auth(app, "POST", "/v1/chat/completions", CHAT_REQUEST, None).await; + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + + let calls = provider_calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert_eq!( + calls[0].authorization.as_deref(), + Some("Bearer caller-byok-key"), + "passthrough must forward the caller credential verbatim", + ); + assert_eq!( + calls[1].authorization, None, + "absent caller credential must not fall back to any token", + ); + + let _ = std::fs::remove_file(path); +} + #[tokio::test] async fn openai_compatible_provider_routes_embeddings_via_runtime_config() { let (base_url, provider_calls) = serve_openai_provider_fixture().await; @@ -729,6 +828,7 @@ async fn openai_compatible_provider_routes_embeddings_via_runtime_config() { ), ]), bearer_token: Some("provider-secret".to_string()), + auth_passthrough: false, accepted_workload_ids: None, accepted_image_digests: None, accepted_dstack_kms_root_public_keys: None, @@ -813,6 +913,7 @@ async fn dynamic_runtime_config_delegates_verified_forwarding_to_selected_backen path: None, models: BTreeMap::from([("public-model".to_string(), "provider-model".to_string())]), bearer_token: None, + auth_passthrough: false, accepted_workload_ids: None, accepted_image_digests: None, accepted_dstack_kms_root_public_keys: None, @@ -836,6 +937,7 @@ async fn dynamic_runtime_config_delegates_verified_forwarding_to_selected_backen headers: Default::default(), path: None, target_route_id: None, + client_authorization: Default::default(), }) .unwrap(); let event = UpstreamVerifiedEvent {