diff --git a/src/adapters/forward.rs b/src/adapters/forward.rs index 16161a3..dee1a15 100644 --- a/src/adapters/forward.rs +++ b/src/adapters/forward.rs @@ -68,6 +68,15 @@ //! `std::env::var`. Imported error codes are `HTTP_` to avoid //! collision with the protocol-level codes (ADR-023). //! +//! The loud-missing credential matrix: when an operation declares an +//! auth scheme, a request is sent only when the capability is present +//! AND well-formed. A malformed credential name/value fails loudly +//! (FWD-08), and an absent capability — the registry holds neither +//! `api_key:{namespace}` nor `http_token:{namespace}` — fails loudly too +//! (FWD-16): the request is refused with an `INTERNAL` error naming the +//! missing key rather than sent unauthenticated to produce corrupted +//! upstream 401s with no local diagnostic. +//! //! Input-schema enforcement (review 001 OAI-02): every input key must be //! declared by the operation's `input_schema` or consumed by a path //! template placeholder; undeclared keys are rejected with `INVALID_INPUT` @@ -286,41 +295,44 @@ pub(crate) fn build_request( } if let Some(scheme) = auth_scheme { - if let Some(secret) = context.capabilities.get(namespace) { - let credential = secret.expose_secret().clone(); - match scheme { - HttpAuthScheme::Bearer => { - let value = - HeaderValue::try_from(format!("Bearer {credential}")).map_err(|_| { - CallError::internal(format!( - "credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated" - )) - })?; - headers.insert(AUTHORIZATION, value); - } - HttpAuthScheme::ApiKey { header_name } => { - let name = - HeaderName::try_from(header_name.as_str()).map_err(|_| { - CallError::internal(format!( - "API-key auth for namespace `{namespace}` declares invalid header name `{header_name}`; refusing to send the request unauthenticated" - )) - })?; - let value = HeaderValue::try_from(credential.as_str()).map_err(|_| { + let secret = context.capabilities.get(namespace).ok_or_else(|| { + CallError::internal(format!( + "capability for namespace `{namespace}` is absent (the registry holds neither `api_key:{namespace}` nor `http_token:{namespace}`); refusing to send the request unauthenticated" + )) + })?; + let credential = secret.expose_secret().clone(); + match scheme { + HttpAuthScheme::Bearer => { + let value = + HeaderValue::try_from(format!("Bearer {credential}")).map_err(|_| { CallError::internal(format!( "credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated" )) })?; - headers.insert(name, value); - } - HttpAuthScheme::Basic => { - let value = - HeaderValue::try_from(format!("Basic {credential}")).map_err(|_| { - CallError::internal(format!( - "credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated" - )) - })?; - headers.insert(AUTHORIZATION, value); - } + headers.insert(AUTHORIZATION, value); + } + HttpAuthScheme::ApiKey { header_name } => { + let name = + HeaderName::try_from(header_name.as_str()).map_err(|_| { + CallError::internal(format!( + "API-key auth for namespace `{namespace}` declares invalid header name `{header_name}`; refusing to send the request unauthenticated" + )) + })?; + let value = HeaderValue::try_from(credential.as_str()).map_err(|_| { + CallError::internal(format!( + "credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated" + )) + })?; + headers.insert(name, value); + } + HttpAuthScheme::Basic => { + let value = + HeaderValue::try_from(format!("Basic {credential}")).map_err(|_| { + CallError::internal(format!( + "credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated" + )) + })?; + headers.insert(AUTHORIZATION, value); } } } @@ -2126,12 +2138,29 @@ mod tests { } } + #[tokio::test] + async fn default_header_with_invalid_name_fails_loudly() { + let mut defaults = TestHashMap::new(); + defaults.insert("bad header".to_string(), "v".to_string()); + let err = build_request( + "https://api.example.com", + "/x", + "GET", + &None, + &defaults, + "svc", + &serde_json::json!({"type": "object"}), + &json!({}), + &noop_context(), + ) + .expect_err("invalid default-header name must fail loudly"); + assert!(err.message.contains("bad header")); + } + #[tokio::test] async fn api_key_with_invalid_header_name_fails_loudly() { let base = "https://api.example.com".to_string(); let ctx = ctx_with_capability("svc", "key-value".to_string()); - let mut ctx = ctx; - ctx.capabilities = Capabilities::new().with_http_token("svc", "key-value".to_string()); let result = build_request( &base, "/x", @@ -2154,6 +2183,109 @@ mod tests { } } + /// FWD-16, loud-missing matrix: an authed operation whose registry + /// capability is entirely absent (`api_key:` and `http_token:` both + /// missing) is refused with an `INTERNAL` error naming the missing + /// capability keys — the request is never sent unauthenticated. + #[test] + fn authed_op_with_absent_capability_fails_loudly_in_build_request() { + for scheme in [ + HttpAuthScheme::Bearer, + HttpAuthScheme::ApiKey { + header_name: "x-api-key".to_string(), + }, + HttpAuthScheme::Basic, + ] { + let err = build_request( + "https://api.example.com", + "/x", + "GET", + &Some(scheme), + &TestHashMap::new(), + "svc", + &serde_json::json!({"type": "object"}), + &json!({}), + &noop_context(), + ) + .expect_err("absent capability must fail loudly"); + assert_eq!(err.code, "INTERNAL"); + assert!( + err.message + .contains("capability for namespace `svc` is absent"), + "message was: {}", + err.message + ); + assert!( + err.message.contains("api_key:svc") && err.message.contains("http_token:svc"), + "message must name the missing capability keys: {}", + err.message + ); + assert!( + err.message + .contains("refusing to send the request unauthenticated"), + "message was: {}", + err.message + ); + } + } + + /// FWD-16, unchanged arm: `auth_scheme: None` stays unauthenticated + /// even with empty capabilities — no error, no credential headers. + #[test] + fn unauthed_op_with_empty_capabilities_is_unchanged() { + let (_, _, _, headers) = build_request( + "https://api.example.com", + "/x", + "GET", + &None, + &TestHashMap::new(), + "svc", + &serde_json::json!({"type": "object"}), + &json!({}), + &noop_context(), + ) + .expect("unauthed op builds without capabilities"); + assert!(headers.get(AUTHORIZATION).is_none()); + } + + /// FWD-16 wire test: an authed op with empty capabilities returns + /// an error envelope and the upstream receives zero requests (the + /// responder fails the test if any connection arrives). + #[tokio::test] + async fn authed_op_with_empty_capabilities_sends_zero_requests() { + let upstream_hit = TestArc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = TestArc::clone(&upstream_hit); + let base = spawn_responder(TestArc::new(move |_| { + flag.store(true, std::sync::atomic::Ordering::SeqCst); + http_response(200, "application/json", b"{}".to_vec()) + })) + .await; + let envelope = + call_forward_authed(&base, noop_context(), &Some(HttpAuthScheme::Bearer)).await; + match envelope.result { + Err(err) => { + assert_eq!(err.code, "INTERNAL", "message was: {}", err.message); + assert!( + err.message + .contains("capability for namespace `svc` is absent"), + "message was: {}", + err.message + ); + assert!( + !err.message.to_lowercase().contains("token") + || err.message.contains("http_token:svc"), + "error must carry no credential material, only key names: {}", + err.message + ); + } + other => panic!("expected loud missing-capability error, got {other:?}"), + } + assert!( + !upstream_hit.load(std::sync::atomic::Ordering::SeqCst), + "upstream must receive zero requests when the capability is absent" + ); + } + #[tokio::test] async fn default_header_with_invalid_value_fails_loudly() { let mut defaults = TestHashMap::new(); @@ -2174,25 +2306,6 @@ mod tests { assert!(err.message.contains("invalid value")); } - #[tokio::test] - async fn default_header_with_invalid_name_fails_loudly() { - let mut defaults = TestHashMap::new(); - defaults.insert("bad header".to_string(), "v".to_string()); - let err = build_request( - "https://api.example.com", - "/x", - "GET", - &None, - &defaults, - "svc", - &serde_json::json!({"type": "object"}), - &json!({}), - &noop_context(), - ) - .expect_err("invalid default-header name must fail loudly"); - assert!(err.message.contains("bad header")); - } - #[tokio::test] async fn error_body_is_echoed_bounded_in_the_error_envelope() { let body = vec![b'x'; ERROR_BODY_ECHO_CAP * 3]; diff --git a/src/adapters/from_jsonschema.rs b/src/adapters/from_jsonschema.rs index b95b747..dc8dd93 100644 --- a/src/adapters/from_jsonschema.rs +++ b/src/adapters/from_jsonschema.rs @@ -619,7 +619,7 @@ mod tests { fn no_env_vars_read_in_build_request() { std::env::set_var("OPENAI_API_KEY", "should-not-be-used"); let ctx = noop_context("req-14", Capabilities::new()); - let (_, _, _, headers) = build_request( + let err = build_request( "https://api.openai.com", "/v1/chat", "POST", @@ -630,10 +630,17 @@ mod tests { &serde_json::json!({"body":{"prompt":"hi"}}), &ctx, ) - .unwrap(); + .expect_err("absent capability is a loud error (FWD-16), never an unauthenticated send"); + assert_eq!(err.code, "INTERNAL"); assert!( - headers.get(AUTHORIZATION).is_none(), - "no auth header when capabilities absent" + err.message + .contains("capability for namespace `openai` is absent"), + "message was: {}", + err.message + ); + assert!( + !err.message.contains("should-not-be-used"), + "error must not echo env material" ); std::env::remove_var("OPENAI_API_KEY"); } diff --git a/src/adapters/from_openapi.rs b/src/adapters/from_openapi.rs index f03f9bb..994ad48 100644 --- a/src/adapters/from_openapi.rs +++ b/src/adapters/from_openapi.rs @@ -1619,7 +1619,7 @@ mod tests { fn no_env_vars_read_in_build_request() { std::env::set_var("OPENAI_API_KEY", "should-not-be-used"); let ctx = noop_context("req-13", Capabilities::new()); - let (_, _, _, headers) = build_request( + let err = build_request( "https://api.openai.com", "/v1/chat", "POST", @@ -1630,10 +1630,17 @@ mod tests { &serde_json::json!({"body":{"prompt":"hi"}}), &ctx, ) - .unwrap(); + .expect_err("absent capability is a loud error (FWD-16), never an unauthenticated send"); + assert_eq!(err.code, "INTERNAL"); assert!( - headers.get(AUTHORIZATION).is_none(), - "no auth header when capabilities absent" + err.message + .contains("capability for namespace `openai` is absent"), + "message was: {}", + err.message + ); + assert!( + !err.message.contains("should-not-be-used"), + "error must not echo env material" ); std::env::remove_var("OPENAI_API_KEY"); }