diff --git a/src/v1/stage0/src/v1_interpreter.rs b/src/v1/stage0/src/v1_interpreter.rs index 2625ad164f5..0a844870bf5 100644 --- a/src/v1/stage0/src/v1_interpreter.rs +++ b/src/v1/stage0/src/v1_interpreter.rs @@ -579,6 +579,7 @@ pub enum InterpError { DivisionByZero, Unimplemented { what: String }, EarlyReturn { value: Value }, + AuthDeclaredButUnwired { service: String, reason: String }, } impl fmt::Display for InterpError { @@ -600,10 +601,28 @@ impl fmt::Display for InterpError { InterpError::DivisionByZero => write!(f, "division by zero"), InterpError::Unimplemented { what } => write!(f, "not yet implemented: {}", what), InterpError::EarlyReturn { .. } => write!(f, "internal: uncaught early return"), + InterpError::AuthDeclaredButUnwired { service, reason } => write!( + f, + "auth declared but unwired for '{}': {} — refusing to send unauthenticated request", + service, reason + ), } } } +/// Three-way auth resolution: splits the conflated `Option` into named states so the +/// dispatch site cannot reach the send path with auth declared but no token (§5 construction). +#[derive(Debug, Clone)] +pub enum AuthResolution { + /// The service declares no auth; unauthenticated send is correct. + NoAuthDeclared, + /// Auth is declared and a non-empty token was resolved; attach the header. + Resolved { header: String, token: String }, + /// Auth is declared (svc_auth / svc_auth_input / svc_auth_source present) but no token + /// resolved — the caller must raise a typed error, never send unauthenticated. + DeclaredButUnwired { reason: String }, +} + pub type InterpResult = Result; type ServiceOp = (Rc, Rc); @@ -3849,7 +3868,14 @@ fn dispatch_rest( None => "GET".to_string(), }; - let (auth_header_name, auth_token) = resolve_auth(service_node, transport, param_env, &si, ctx); + let auth = resolve_auth(service_node, transport, param_env, &si, ctx); + if let AuthResolution::DeclaredButUnwired { ref reason } = auth { + return Err(InterpError::AuthDeclaredButUnwired { + service: find_service_config_string(service_node, "svc_endpoint", &si) + .unwrap_or_else(|| "".to_string()), + reason: reason.clone(), + }); + } let reserved_props = [ "base_url", @@ -3921,14 +3947,18 @@ fn dispatch_rest( } }; - if let Some(token) = &auth_token { + if let AuthResolution::Resolved { + ref header, + ref token, + } = auth + { if !token.is_empty() { - let header_val = if auth_header_name == "Authorization" { + let header_val = if header == "Authorization" { format!("Bearer {}", token) } else { token.clone() }; - request = request.set(&auth_header_name, &header_val); + request = request.set(header, &header_val); } } @@ -3978,19 +4008,20 @@ fn dispatch_rest( } } -fn resolve_auth( +pub fn resolve_auth( service_node: &Rc, _transport: &Rc, param_env: &Rc, si: &Rc>>, ctx: &InterpContext, -) -> (String, Option) { +) -> AuthResolution { let mut header_name = "Authorization".to_string(); let mut env_var_name: Option = None; // `auth_input: ` (§3): the token is an operation INPUT the caller supplies, // not ambient env. Resolve it from the per-call param env. Takes precedence over // `auth_source` (env var) when both are present. let mut input_field_name: Option = None; + let mut auth_declared = false; for prop in service_node.properties.iter() { let name = field_init_node_name_at(prop.clone(), si.clone()); @@ -3998,6 +4029,7 @@ fn resolve_auth( match name.as_str() { "svc_auth" => { + auth_declared = true; let scheme = authored_name_at(si.clone(), val_node.clone()); if scheme == "Bearer" { header_name = "Authorization".to_string(); @@ -4016,6 +4048,7 @@ fn resolve_auth( } } "svc_auth_input" => { + auth_declared = true; // `auth_input: access_token` — the value node is the input field name (an identifier). let field = authored_name_at(si.clone(), val_node.clone()); if !field.is_empty() { @@ -4025,6 +4058,7 @@ fn resolve_auth( } } "svc_auth_source" => { + auth_declared = true; for child in val_node.children.iter() { let field_name = field_init_node_name_at(child.clone(), si.clone()); if field_name == "name" { @@ -4040,19 +4074,37 @@ fn resolve_auth( } } - // §3: a caller-supplied input token wins over an ambient env var. Extract the String - // payload explicitly (the token is a Secret = Value::Str) rather than relying on Display — - // a non-string Value must NOT produce a stringified-debug Bearer header; fall through instead. - if let Some(field) = input_field_name { - if let Some(Value::Str(tok)) = param_env.lookup(ctx.sym(&field)) { + if !auth_declared { + return AuthResolution::NoAuthDeclared; + } + + // §3: caller-supplied input token wins over ambient env var when non-empty; if the input + // field is absent or empty, fall through to auth_source so dual-declare services + // (auth_input + auth_source) get the env-var fallback. Extract the String payload + // explicitly — a non-Str Value must NOT produce a stringified-debug Bearer header. + if let Some(ref field) = input_field_name { + if let Some(Value::Str(tok)) = param_env.lookup(ctx.sym(field)) { if !tok.is_empty() { - return (header_name, Some(tok.clone())); + return AuthResolution::Resolved { + header: header_name, + token: tok.clone(), + }; } } + // input field unresolved or empty — fall through to auth_source attempt below. } - let token = env_var_name.and_then(|var| resolve_env_var_token(ctx, &var)); - (header_name, token) + match env_var_name.and_then(|var| resolve_env_var_token(ctx, &var)) { + Some(tok) if !tok.is_empty() => AuthResolution::Resolved { + header: header_name, + token: tok, + }, + _ => AuthResolution::DeclaredButUnwired { + reason: "auth declared but no token resolved (auth_input unresolved/empty, \ + auth_source env var absent or empty)" + .to_string(), + }, + } } fn extract_string_value(node: &Rc) -> Option { diff --git a/src/v1/tests/src/auth_declared_but_unwired_witness_test.rs b/src/v1/tests/src/auth_declared_but_unwired_witness_test.rs new file mode 100644 index 00000000000..d80126b03b8 --- /dev/null +++ b/src/v1/tests/src/auth_declared_but_unwired_witness_test.rs @@ -0,0 +1,273 @@ +use std::rc::Rc; + +use v1_compiler::v1_compiler_compile::{compile_to_resolved, ResolvedPipelineResult}; +use v1_compiler::v1_interpreter::{self, AuthResolution, ExecutionMode, InterpError}; + +use crate::helpers::resolve_imports_transitively; + +fn assert_resolved_no_hard_errors(result: &ResolvedPipelineResult) { + let msgs: Vec = result + .diagnostics + .iter() + .map(|d| v1_compiler::v1_std_core::diagnostic_to_message(d.diagnostic.clone())) + .filter(|m| !m.starts_with("complexity: ")) + .collect(); + assert!( + msgs.is_empty() && result.graph.is_some(), + "expected resolved graph, got diagnostics {:?}", + msgs, + ); +} + +fn resolve(src: &str) -> Rc { + let sources = resolve_imports_transitively("test.dag", src); + let resolved = compile_to_resolved(Rc::new(sources)); + assert_resolved_no_hard_errors(&resolved); + resolved +} + +const SERVICE_AUTH_BEARER_NO_SOURCE: &str = r#"module auth_unwired_t1 + +service test.Svc { + config { + endpoint: "https://unreachable.invalid.example" + auth: Bearer + } + operation GetData { + output { data: String } + transport rest { method: GET, path: "/data" } + response { + 200 => String + } + mock_response { + 200 => { data: "ok" } + } + } +} + +fn probe() -> String { + let r = test.Svc.GetData() + r.data +} +"#; + +const SERVICE_AUTH_INPUT_NOT_PROVIDED: &str = r#"module auth_unwired_t2 + +service test.Svc { + config { + endpoint: "https://unreachable.invalid.example" + auth: Bearer + auth_input: token + } + operation GetData { + input { token: String } + output { data: String } + transport rest { method: GET, path: "/data" } + response { + 200 => String + } + mock_response { + 200 => { data: "ok" } + } + } +} + +fn probe() -> String { + let r = test.Svc.GetData(token: "") + r.data +} +"#; + +// Dual-declare: both auth_input (caller-supplied) and auth_source (env-var fallback) declared. +// Used by the two fallback-regression witnesses below. +const SERVICE_DUAL_DECLARE: &str = r#"module auth_unwired_t4 + +service test.Svc { + config { + endpoint: "https://unreachable.invalid.example" + auth: Bearer + auth_input: api_key + auth_source: "TEST_AUTH_GUARD_DUAL_FALLBACK_VAR" + } + operation GetData { + input { api_key: String } + output { data: String } + transport rest { method: GET, path: "/data" } + response { + 200 => String + } + mock_response { + 200 => { data: "ok" } + } + } +} + +fn probe() -> String { + let r = test.Svc.GetData(api_key: "") + r.data +} +"#; + +const SERVICE_NO_AUTH: &str = r#"module auth_unwired_t3 + +service test.Svc { + config { + endpoint: "https://unreachable.invalid.example" + } + operation GetData { + output { data: String } + transport rest { method: GET, path: "/data" } + response { + 200 => String + } + mock_response { + 200 => { data: "ok" } + } + } +} + +fn probe() -> String { + let r = test.Svc.GetData() + r.data +} +"#; + +#[test] +fn auth_declared_no_source_fails_closed_pre_send() { + let resolved = resolve(SERVICE_AUTH_BEARER_NO_SOURCE); + let graph = resolved.graph.as_ref().expect("graph"); + let ctx = v1_interpreter::InterpContext::new( + graph, + resolved.source_indices.clone(), + ExecutionMode::Wet, + ); + match v1_interpreter::run_in_context(&ctx, "probe", false) { + Err(InterpError::AuthDeclaredButUnwired { service, reason }) => { + assert!( + service.contains("unreachable.invalid.example"), + "expected service endpoint in error, got service='{service}' reason='{reason}'" + ); + } + other => panic!( + "expected AuthDeclaredButUnwired pre-send, got {other:?} — \ + guard did not fire before dispatch" + ), + } +} + +#[test] +fn auth_input_empty_fails_closed_pre_send() { + let resolved = resolve(SERVICE_AUTH_INPUT_NOT_PROVIDED); + let graph = resolved.graph.as_ref().expect("graph"); + let ctx = v1_interpreter::InterpContext::new( + graph, + resolved.source_indices.clone(), + ExecutionMode::Wet, + ); + match v1_interpreter::run_in_context(&ctx, "probe", false) { + Err(InterpError::AuthDeclaredButUnwired { .. }) => {} + other => panic!("expected AuthDeclaredButUnwired for empty auth_input, got {other:?}"), + } +} + +#[test] +fn no_auth_declared_does_not_fire_guard() { + let resolved = resolve(SERVICE_NO_AUTH); + let graph = resolved.graph.as_ref().expect("graph"); + let ctx = v1_interpreter::InterpContext::new( + graph, + resolved.source_indices.clone(), + ExecutionMode::Wet, + ); + if let Err(InterpError::AuthDeclaredButUnwired { service, reason }) = + v1_interpreter::run_in_context(&ctx, "probe", false) + { + panic!( + "guard must NOT fire for a service with no auth declaration; \ + got AuthDeclaredButUnwired service='{service}' reason='{reason}'" + ); + } +} + +#[test] +fn resolve_auth_three_way_split_matches_dispatch_behavior() { + let cases: &[(&str, &str, bool)] = &[ + ("Bearer + no source", SERVICE_AUTH_BEARER_NO_SOURCE, true), + ("auth_input empty", SERVICE_AUTH_INPUT_NOT_PROVIDED, true), + ("no auth declared", SERVICE_NO_AUTH, false), + ]; + for (label, src, expect_unwired) in cases { + let resolved = resolve(src); + let graph = resolved.graph.as_ref().expect("graph"); + let ctx = v1_interpreter::InterpContext::new( + graph, + resolved.source_indices.clone(), + ExecutionMode::Wet, + ); + let result = v1_interpreter::run_in_context(&ctx, "probe", false); + let is_unwired = matches!(&result, Err(InterpError::AuthDeclaredButUnwired { .. })); + assert_eq!( + is_unwired, *expect_unwired, + "{label}: expect_unwired={expect_unwired} but got {result:?}" + ); + } +} + +// Regression guard: dual-declare (auth_input + auth_source), api_key empty but env var present → +// must fall through to auth_source and NOT raise AuthDeclaredButUnwired. +#[test] +fn dual_declare_env_var_fallback_resolves_when_input_empty() { + // Set a synthetic env var the service fixture reads. + std::env::set_var("TEST_AUTH_GUARD_DUAL_FALLBACK_VAR", "test-token-sentinel"); + let resolved = resolve(SERVICE_DUAL_DECLARE); + let graph = resolved.graph.as_ref().expect("graph"); + let ctx = v1_interpreter::InterpContext::new( + graph, + resolved.source_indices.clone(), + ExecutionMode::Wet, + ); + let result = v1_interpreter::run_in_context(&ctx, "probe", false); + std::env::remove_var("TEST_AUTH_GUARD_DUAL_FALLBACK_VAR"); + // Auth resolved via env-var fallback → guard must NOT fire; a network error is acceptable + // (the endpoint is unreachable) but AuthDeclaredButUnwired is the regression. + if let Err(InterpError::AuthDeclaredButUnwired { service, reason }) = result { + panic!( + "regression: guard fired on dual-declare with env-var present; \ + auth_input→auth_source fallback broken. service='{service}' reason='{reason}'" + ); + } +} + +// Dual-declare, api_key empty AND env var absent → guard must still fire (fail-closed). +#[test] +fn dual_declare_both_empty_fails_closed() { + std::env::remove_var("TEST_AUTH_GUARD_DUAL_FALLBACK_VAR"); + let resolved = resolve(SERVICE_DUAL_DECLARE); + let graph = resolved.graph.as_ref().expect("graph"); + let ctx = v1_interpreter::InterpContext::new( + graph, + resolved.source_indices.clone(), + ExecutionMode::Wet, + ); + match v1_interpreter::run_in_context(&ctx, "probe", false) { + Err(InterpError::AuthDeclaredButUnwired { .. }) => {} + other => panic!( + "expected AuthDeclaredButUnwired when both auth_input and auth_source fail, \ + got {other:?}" + ), + } +} + +// Pub-API smoke: confirms the 3 variants are reachable from outside v1_compiler. +// Execution discrimination lives in the wet-dispatch tests above. +#[test] +fn auth_resolution_enum_is_pub_and_discriminable() { + let _ = AuthResolution::NoAuthDeclared; + let _ = AuthResolution::Resolved { + header: "Authorization".to_string(), + token: "tok".to_string(), + }; + let _ = AuthResolution::DeclaredButUnwired { + reason: "test".to_string(), + }; +} diff --git a/src/v1/tests/src/lib.rs b/src/v1/tests/src/lib.rs index d34fe11631a..a680ff94885 100644 --- a/src/v1/tests/src/lib.rs +++ b/src/v1/tests/src/lib.rs @@ -9,6 +9,8 @@ pub mod helpers; #[cfg(test)] mod a4_opacity; #[cfg(test)] +mod auth_declared_but_unwired_witness_test; +#[cfg(test)] mod b1_hash_primitive_test; #[cfg(test)] mod body_producer_infer_perf_witness_test;