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
|
||||
//! 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];
|
||||
|
||||
Reference in New Issue
Block a user