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:
+165
-52
@@ -68,6 +68,15 @@
|
|||||||
//! `std::env::var`. Imported error codes are `HTTP_<status>` to avoid
|
//! `std::env::var`. Imported error codes are `HTTP_<status>` to avoid
|
||||||
//! collision with the protocol-level codes (ADR-023).
|
//! 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
|
//! Input-schema enforcement (review 001 OAI-02): every input key must be
|
||||||
//! declared by the operation's `input_schema` or consumed by a path
|
//! declared by the operation's `input_schema` or consumed by a path
|
||||||
//! template placeholder; undeclared keys are rejected with `INVALID_INPUT`
|
//! 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(scheme) = auth_scheme {
|
||||||
if let Some(secret) = context.capabilities.get(namespace) {
|
let secret = context.capabilities.get(namespace).ok_or_else(|| {
|
||||||
let credential = secret.expose_secret().clone();
|
CallError::internal(format!(
|
||||||
match scheme {
|
"capability for namespace `{namespace}` is absent (the registry holds neither `api_key:{namespace}` nor `http_token:{namespace}`); refusing to send the request unauthenticated"
|
||||||
HttpAuthScheme::Bearer => {
|
))
|
||||||
let value =
|
})?;
|
||||||
HeaderValue::try_from(format!("Bearer {credential}")).map_err(|_| {
|
let credential = secret.expose_secret().clone();
|
||||||
CallError::internal(format!(
|
match scheme {
|
||||||
"credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated"
|
HttpAuthScheme::Bearer => {
|
||||||
))
|
let value =
|
||||||
})?;
|
HeaderValue::try_from(format!("Bearer {credential}")).map_err(|_| {
|
||||||
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!(
|
CallError::internal(format!(
|
||||||
"credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated"
|
"credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
headers.insert(name, value);
|
headers.insert(AUTHORIZATION, value);
|
||||||
}
|
}
|
||||||
HttpAuthScheme::Basic => {
|
HttpAuthScheme::ApiKey { header_name } => {
|
||||||
let value =
|
let name =
|
||||||
HeaderValue::try_from(format!("Basic {credential}")).map_err(|_| {
|
HeaderName::try_from(header_name.as_str()).map_err(|_| {
|
||||||
CallError::internal(format!(
|
CallError::internal(format!(
|
||||||
"credential for namespace `{namespace}` contains invalid HTTP header characters (control or non-ASCII bytes); refusing to send the request unauthenticated"
|
"API-key auth for namespace `{namespace}` declares invalid header name `{header_name}`; refusing to send the request unauthenticated"
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
headers.insert(AUTHORIZATION, value);
|
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]
|
#[tokio::test]
|
||||||
async fn api_key_with_invalid_header_name_fails_loudly() {
|
async fn api_key_with_invalid_header_name_fails_loudly() {
|
||||||
let base = "https://api.example.com".to_string();
|
let base = "https://api.example.com".to_string();
|
||||||
let ctx = ctx_with_capability("svc", "key-value".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(
|
let result = build_request(
|
||||||
&base,
|
&base,
|
||||||
"/x",
|
"/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]
|
#[tokio::test]
|
||||||
async fn default_header_with_invalid_value_fails_loudly() {
|
async fn default_header_with_invalid_value_fails_loudly() {
|
||||||
let mut defaults = TestHashMap::new();
|
let mut defaults = TestHashMap::new();
|
||||||
@@ -2174,25 +2306,6 @@ mod tests {
|
|||||||
assert!(err.message.contains("invalid value"));
|
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]
|
#[tokio::test]
|
||||||
async fn error_body_is_echoed_bounded_in_the_error_envelope() {
|
async fn error_body_is_echoed_bounded_in_the_error_envelope() {
|
||||||
let body = vec![b'x'; ERROR_BODY_ECHO_CAP * 3];
|
let body = vec![b'x'; ERROR_BODY_ECHO_CAP * 3];
|
||||||
|
|||||||
@@ -619,7 +619,7 @@ mod tests {
|
|||||||
fn no_env_vars_read_in_build_request() {
|
fn no_env_vars_read_in_build_request() {
|
||||||
std::env::set_var("OPENAI_API_KEY", "should-not-be-used");
|
std::env::set_var("OPENAI_API_KEY", "should-not-be-used");
|
||||||
let ctx = noop_context("req-14", Capabilities::new());
|
let ctx = noop_context("req-14", Capabilities::new());
|
||||||
let (_, _, _, headers) = build_request(
|
let err = build_request(
|
||||||
"https://api.openai.com",
|
"https://api.openai.com",
|
||||||
"/v1/chat",
|
"/v1/chat",
|
||||||
"POST",
|
"POST",
|
||||||
@@ -630,10 +630,17 @@ mod tests {
|
|||||||
&serde_json::json!({"body":{"prompt":"hi"}}),
|
&serde_json::json!({"body":{"prompt":"hi"}}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.expect_err("absent capability is a loud error (FWD-16), never an unauthenticated send");
|
||||||
|
assert_eq!(err.code, "INTERNAL");
|
||||||
assert!(
|
assert!(
|
||||||
headers.get(AUTHORIZATION).is_none(),
|
err.message
|
||||||
"no auth header when capabilities absent"
|
.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");
|
std::env::remove_var("OPENAI_API_KEY");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1619,7 +1619,7 @@ mod tests {
|
|||||||
fn no_env_vars_read_in_build_request() {
|
fn no_env_vars_read_in_build_request() {
|
||||||
std::env::set_var("OPENAI_API_KEY", "should-not-be-used");
|
std::env::set_var("OPENAI_API_KEY", "should-not-be-used");
|
||||||
let ctx = noop_context("req-13", Capabilities::new());
|
let ctx = noop_context("req-13", Capabilities::new());
|
||||||
let (_, _, _, headers) = build_request(
|
let err = build_request(
|
||||||
"https://api.openai.com",
|
"https://api.openai.com",
|
||||||
"/v1/chat",
|
"/v1/chat",
|
||||||
"POST",
|
"POST",
|
||||||
@@ -1630,10 +1630,17 @@ mod tests {
|
|||||||
&serde_json::json!({"body":{"prompt":"hi"}}),
|
&serde_json::json!({"body":{"prompt":"hi"}}),
|
||||||
&ctx,
|
&ctx,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.expect_err("absent capability is a loud error (FWD-16), never an unauthenticated send");
|
||||||
|
assert_eq!(err.code, "INTERNAL");
|
||||||
assert!(
|
assert!(
|
||||||
headers.get(AUTHORIZATION).is_none(),
|
err.message
|
||||||
"no auth header when capabilities absent"
|
.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");
|
std::env::remove_var("OPENAI_API_KEY");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user