fix(adapters): refuse unauthenticated send when capability is absent (FWD-16)

The FWD-08 remediation made every malformed credential fail loudly, but
an authed operation whose registry capability was entirely absent fell
through the build_request match: the request was sent with no credential
and no diagnostic, producing corrupted upstream 401s at call time.

- build_request now returns an INTERNAL error naming the missing
  capability keys (api_key:{ns} / http_token:{ns}) when an auth scheme
  is declared and Capabilities::get is empty; the request is not sent
- auth_scheme: None behavior unchanged (unauthenticated ops stay
  unauthenticated); error message carries key names only, no secret
- module doc: loud-missing matrix now covers malformed name/value AND
  absent capability
- tests: unit loud-error across all three schemes, unchanged-arm pin,
  wire test asserting the upstream receives zero requests (mirrors the
  FWD-08 test family)
- from_openapi/from_jsonschema no_env_vars tests updated: they pinned
  the old silent fall-through; still assert no env material echoes

Verification: cargo test (380 passed), cargo test --all-features
(496 passed), clippy --all-targets -D warnings (default + all-features),
cargo fmt --check — all via scripts/verify.sh
This commit is contained in:
2026-08-30 23:55:42 +00:00
parent 9819c24b4f
commit a427dc194d
3 changed files with 187 additions and 60 deletions
+136 -23
View File
@@ -68,6 +68,15 @@
//! `std::env::var`. Imported error codes are `HTTP_<status>` 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,7 +295,11 @@ pub(crate) fn build_request(
}
if let Some(scheme) = auth_scheme {
if let Some(secret) = context.capabilities.get(namespace) {
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 => {
@@ -323,7 +336,6 @@ pub(crate) fn build_request(
}
}
}
}
let http_method = Method::from_bytes(method.as_bytes())
.map_err(|_| CallError::internal(format!("invalid HTTP method `{method}`")))?;
@@ -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];
+11 -4
View File
@@ -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");
}
+11 -4
View File
@@ -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");
}