From e15d3b230009f59ce36d5db357cd2435f6efd5dd Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 10 Nov 2026 20:15:23 -0600 Subject: [PATCH 1/3] Extract the protected CloudFront Geo baseline --- Cargo.lock | 2 + crates/trusted-server-adapter-axum/Cargo.toml | 3 + crates/trusted-server-adapter-axum/src/app.rs | 4 +- crates/trusted-server-adapter-axum/src/lib.rs | 5 +- .../trusted-server-adapter-axum/src/main.rs | 43 +++- .../src/middleware.rs | 87 ++++++-- .../src/native_geo.rs | 198 ++++++++++++++++++ .../src/platform.rs | 135 ++++++++++-- .../src/startup.rs | 10 +- .../tests/startup.rs | 165 ++++++++++++++- .../src/platform.rs | 102 ++++++--- .../src/platform.rs | 12 +- .../src/auction/openrtb.rs | 17 +- .../src/auction/openrtb/tests.rs | 64 ++++++ .../src/auction/test_support.rs | 12 +- .../src/consent/jurisdiction.rs | 118 ++++++++--- crates/trusted-server-core/src/ec/kv_types.rs | 14 +- crates/trusted-server-core/src/geo.rs | 90 ++++++-- .../src/integrations/aps.rs | 42 +++- .../src/integrations/datadome/protection.rs | 12 +- .../src/integrations/didomi.rs | 11 +- .../src/integrations/prebid.rs | 106 +++++++--- .../trusted-server-core/src/platform/mod.rs | 118 +++++++++-- .../trusted-server-core/src/platform/types.rs | 110 ++++++++-- crates/trusted-server-core/src/publisher.rs | 12 +- .../trusted-server-core/src/test_support.rs | 12 +- docs/guide/native-rc-demo.md | 49 ++++- 27 files changed, 1315 insertions(+), 238 deletions(-) create mode 100644 crates/trusted-server-adapter-axum/src/native_geo.rs diff --git a/Cargo.lock b/Cargo.lock index 662eb6eca6..cdc1a28f3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7084,11 +7084,13 @@ dependencies = [ "futures", "log", "reqwest 0.12.28", + "serde", "serde_json", "simple_logger", "temp-env", "tempfile", "tokio", + "toml", "tower 0.4.13", "trusted-server-core", ] diff --git a/crates/trusted-server-adapter-axum/Cargo.toml b/crates/trusted-server-adapter-axum/Cargo.toml index 7094358623..e204ae90b0 100644 --- a/crates/trusted-server-adapter-axum/Cargo.toml +++ b/crates/trusted-server-adapter-axum/Cargo.toml @@ -22,6 +22,7 @@ path = "src/main.rs" default = [] aws-appconfig-agent = ["edgezero-adapter-axum/aws-appconfig-agent"] aws-secrets-manager = ["edgezero-adapter-axum/aws-secrets-manager"] +geo-cloudfront = ["edgezero-adapter-axum/geo-cloudfront"] [dependencies] async-trait = { workspace = true } @@ -31,6 +32,8 @@ error-stack = { workspace = true } futures = { workspace = true } log = { workspace = true } reqwest = { workspace = true } +serde = { workspace = true } +toml = { workspace = true } simple_logger = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time"] } trusted-server-core = { workspace = true } diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index a4371692b8..3aeca800e7 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -186,8 +186,8 @@ where /// /// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction /// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the platform (a no-op on the local Axum dev server, so -/// jurisdiction stays Unknown there unless the request carries TCF consent). A +/// users. Geo comes from the immutable managed request snapshot. Off, untrusted +/// or unavailable results remain Unknown unless real consent supplies it. A /// malformed consent string is logged and falls back to the default /// (fail-closed) context rather than being silently swallowed. fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext { diff --git a/crates/trusted-server-adapter-axum/src/lib.rs b/crates/trusted-server-adapter-axum/src/lib.rs index b42b6ec9c6..e0fcf69123 100644 --- a/crates/trusted-server-adapter-axum/src/lib.rs +++ b/crates/trusted-server-adapter-axum/src/lib.rs @@ -1,12 +1,15 @@ //! Native Axum adapter with strict prepared-store startup. //! //! Runtime settings and immutable store snapshots are validated before the RC -//! listener becomes ready. Reader geography remains unavailable on native. +//! listener becomes ready. Optional reader geography comes only from the +//! managed native host's protected, immutable request outcome. /// Application routing and handler registration for the Axum dev server. pub mod app; /// Request middleware (auth, response finalisation). pub mod middleware; +/// Explicit native Geo boot profile and provider composition. +pub mod native_geo; /// Managed outbound HTTP compatibility bridge. pub mod outbound; /// Native platform services and request-scoped transport/ingress bridges. diff --git a/crates/trusted-server-adapter-axum/src/main.rs b/crates/trusted-server-adapter-axum/src/main.rs index bb705c01a3..ba25b2541a 100644 --- a/crates/trusted-server-adapter-axum/src/main.rs +++ b/crates/trusted-server-adapter-axum/src/main.rs @@ -1,6 +1,9 @@ -use edgezero_adapter_axum::dev_server::run_production_app_with_initializer; +use edgezero_adapter_axum::dev_server::{ + AxumRunOptions, run_production_app_with_options_and_initializer, +}; use log::LevelFilter; use trusted_server_adapter_axum::app::TrustedServerApp; +use trusted_server_adapter_axum::native_geo::load_selection; use trusted_server_adapter_axum::startup::initialize_native; /// Environment variable that sets the maximum log level. @@ -26,20 +29,42 @@ fn main() { eprintln!("warning: logger init failed: {e}"); } - if let Err(err) = run_production_app_with_initializer::(|app, stores| { - Box::pin(async move { - initialize_native(app, stores).await.inspect_err(|error| { - // RC classifies callback errors without their details. This safe - // application diagnostic tells operators which input to fix. - log::error!("Trusted Server initialization failed: {error}"); + let options = match native_options() { + Ok(options) => options, + Err(error) => { + log::error!("Trusted Server native hosting configuration failed: {error}"); + std::process::exit(1); + } + }; + if let Err(err) = run_production_app_with_options_and_initializer::( + options, + |app, stores| { + Box::pin(async move { + initialize_native(app, stores).await.inspect_err(|error| { + // RC classifies callback errors without their details. This safe + // application diagnostic tells operators which input to fix. + log::error!("Trusted Server initialization failed: {error}"); + }) }) - }) - }) { + }, + ) { log::error!("trusted-server-adapter-axum failed: {err}"); std::process::exit(1); } } +/// Capture hosting inputs and validate the final provider selection before startup. +fn native_options() -> Result { + let selection = load_selection().map_err(|error| error.to_string())?; + let options = AxumRunOptions::from_env() + .map_err(|_| "native hosting environment is invalid".to_owned())? + .with_geo(selection); + options + .geo_capability() + .map_err(|error| error.to_string())?; + Ok(options) +} + /// Parses an optional `RUST_LOG` value into the logger's maximum level. /// /// Absent and blank values resolve to [`DEFAULT_LOG_LEVEL`]. Any other value diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index a281777e21..73b479c57e 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -6,11 +6,16 @@ use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderValue, Response}; use edgezero_core::middleware::{Middleware, Next}; use trusted_server_core::auth::enforce_basic_auth_parts; -use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE; +use trusted_server_core::constants::{ + HEADER_X_GEO_CITY, HEADER_X_GEO_CONTINENT, HEADER_X_GEO_COORDINATES, HEADER_X_GEO_COUNTRY, + HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_GEO_METRO_CODE, HEADER_X_GEO_REGION, +}; use trusted_server_core::http_util::sanitize_trusted_client_ip_header_map; +use trusted_server_core::platform::GeoInfo; use trusted_server_core::settings::Settings; use crate::app::AppState; +use crate::platform::geo_from_outcome; fn request_settings( explicit: &Option>, @@ -73,8 +78,9 @@ impl Middleware for SanitizeRequestMiddleware { /// Response-finalization middleware: injects all standard TS response headers. /// -/// Geo lookup is unavailable in the Axum dev server — `X-Geo-Info-Available: false` -/// is always emitted. `X-TS-Version` carries the compiled-in git version. The +/// Geo headers reflect the same immutable managed outcome used by handlers, +/// without resolving the provider during finalization. Missing detail is omitted. +/// `X-TS-Version` carries the compiled-in git version. The /// Fastly-specific headers (`X-TS-Fastly-Version`, `X-TS-ENV`) are skipped /// because the corresponding env vars are not set in a local dev context. /// @@ -105,8 +111,9 @@ impl FinalizeResponseMiddleware { impl Middleware for FinalizeResponseMiddleware { async fn handle(&self, ctx: RequestContext, next: Next<'_>) -> Result { let settings = request_settings(&self.settings, &ctx)?; + let geo = geo_from_outcome(ctx.geo()); let mut response = next.run(ctx).await?; - apply_finalize_headers(&settings, &mut response); + apply_finalize_headers(&settings, geo.as_ref(), &mut response); Ok(response) } } @@ -164,16 +171,34 @@ impl Middleware for AuthMiddleware { /// Applies standard Trusted Server response headers to the given response. /// -/// Unlike the Fastly variant, geo is always unavailable so `X-Geo-Info-Available: false` -/// is unconditionally emitted, followed by the compiled-in `X-TS-Version`. -/// Fastly-specific headers are omitted. +/// Clear stale upstream geographic headers, then emit only detail present in +/// the request snapshot. Fastly-specific headers are omitted. /// Operator-configured `settings.response_headers` are applied last (with the /// shared cookie cache-privacy hardening) and can override any managed header. -pub(crate) fn apply_finalize_headers(settings: &Settings, response: &mut Response) { - response.headers_mut().insert( +pub(crate) fn apply_finalize_headers( + settings: &Settings, + geo: Option<&GeoInfo>, + response: &mut Response, +) { + for name in [ + HEADER_X_GEO_CITY, + HEADER_X_GEO_COUNTRY, + HEADER_X_GEO_CONTINENT, + HEADER_X_GEO_COORDINATES, + HEADER_X_GEO_METRO_CODE, + HEADER_X_GEO_REGION, HEADER_X_GEO_INFO_AVAILABLE, - HeaderValue::from_static("false"), - ); + ] { + response.headers_mut().remove(name); + } + if let Some(geo) = geo { + geo.set_response_headers(response); + } else { + response.headers_mut().insert( + HEADER_X_GEO_INFO_AVAILABLE, + HeaderValue::from_static("false"), + ); + } trusted_server_core::version_header::apply_git_version_header(response); @@ -252,7 +277,7 @@ mod tests { let settings = settings_with_response_headers(vec![]); let mut response = empty_response(); - apply_finalize_headers(&settings, &mut response); + apply_finalize_headers(&settings, None, &mut response); assert_eq!( response @@ -270,7 +295,7 @@ mod tests { settings_with_response_headers(vec![("X-Geo-Info-Available", "operator-override")]); let mut response = empty_response(); - apply_finalize_headers(&settings, &mut response); + apply_finalize_headers(&settings, None, &mut response); assert_eq!( response @@ -287,7 +312,7 @@ mod tests { let settings = settings_with_response_headers(vec![("X-Custom-Header", "custom-value")]); let mut response = empty_response(); - apply_finalize_headers(&settings, &mut response); + apply_finalize_headers(&settings, None, &mut response); assert_eq!( response @@ -299,6 +324,38 @@ mod tests { ); } + #[test] + fn partial_geo_emits_no_invented_or_stale_detail() { + let settings = settings_with_response_headers(vec![]); + let mut response = empty_response(); + response + .headers_mut() + .insert("x-geo-coordinates", HeaderValue::from_static("0,0")); + response + .headers_mut() + .insert("x-geo-city", HeaderValue::from_static("stale-origin-city")); + let geo = GeoInfo::country_region("US", Some("CA".to_owned())); + apply_finalize_headers(&settings, Some(&geo), &mut response); + assert_eq!(response.headers()["x-geo-info-available"], "true"); + assert_eq!(response.headers()["x-geo-country"], "US"); + assert_eq!(response.headers()["x-geo-region"], "CA"); + for name in [ + "x-geo-coordinates", + "x-geo-city", + "x-geo-continent", + "x-geo-metro-code", + ] { + assert!( + !response.headers().contains_key(name), + "should omit unavailable detail" + ); + } + apply_finalize_headers(&settings, None, &mut response); + assert_eq!(response.headers()["x-geo-info-available"], "false"); + assert!(!response.headers().contains_key("x-geo-country")); + assert!(!response.headers().contains_key("x-geo-region")); + } + #[test] fn sanitize_middleware_strips_configured_trust_headers_before_routing() { let mut settings = settings_with_response_headers(vec![]); @@ -334,7 +391,7 @@ mod tests { #[test] fn emits_git_version_header() { let mut response = empty_response(); - apply_finalize_headers(&settings_with_response_headers(vec![]), &mut response); + apply_finalize_headers(&settings_with_response_headers(vec![]), None, &mut response); assert_eq!( response .headers() diff --git a/crates/trusted-server-adapter-axum/src/native_geo.rs b/crates/trusted-server-adapter-axum/src/native_geo.rs new file mode 100644 index 0000000000..1599b959cc --- /dev/null +++ b/crates/trusted-server-adapter-axum/src/native_geo.rs @@ -0,0 +1,198 @@ +//! Explicit application-owned composition of the native Geo provider. +//! +//! EdgeZero does not load a Geo environment or store binding. This optional TS +//! boot profile selects its Rust options before store preparation and listening. +//! It contains a credential removal name, never the expected origin credential. + +use std::fmt; +use std::fs; + +use edgezero_adapter_axum::geo::{NativeGeoSelection, ProtectedCloudFrontAlb}; +use serde::Deserialize; + +/// Path to the separately delivered TS native Geo boot profile. +pub const PROFILE_FILE_ENV: &str = "TS_NATIVE_GEO_PROFILE_FILE"; + +/// Safe startup categories that never retain profile bytes or policy inputs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeGeoProfileError { + /// A selected profile file could not be read. + Unavailable, + /// The file is not the supported typed profile. + InvalidProfile, + /// `EdgeZero` rejected the protected source or credential removal policy. + InvalidPolicy, +} + +impl fmt::Display for NativeGeoProfileError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Unavailable => "native Geo profile could not be read", + Self::InvalidProfile => "native Geo profile is invalid", + Self::InvalidPolicy => "native Geo protected source policy is invalid", + }) + } +} + +impl core::error::Error for NativeGeoProfileError {} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NativeGeoProfile { + version: u32, + geo: GeoSelection, +} + +#[derive(Deserialize)] +#[serde(tag = "provider", deny_unknown_fields)] +enum GeoSelection { + #[serde(rename = "off")] + Off {}, + #[serde(rename = "cloudfront")] + CloudFront { + alb_peer_cidrs: Vec, + origin_credential_header_name: String, + }, +} + +/// Capture the application-owned selection once before starting the runner. +/// +/// An absent profile keeps existing local hosting Off. A supplied unreadable or +/// invalid file is an error, never a silent fallback. The selected built-in +/// implementation is validated by final `AxumRunOptions::geo_capability()`. +/// +/// # Errors +/// Returns a bounded category without the file path, input bytes, CIDRs or name. +pub fn load_selection() -> Result { + let Some(path) = std::env::var_os(PROFILE_FILE_ENV) else { + return Ok(NativeGeoSelection::Off); + }; + let bytes = fs::read_to_string(path).map_err(|_| NativeGeoProfileError::Unavailable)?; + parse_selection(&bytes) +} + +fn parse_selection(bytes: &str) -> Result { + let profile: NativeGeoProfile = + toml::from_str(bytes).map_err(|_| NativeGeoProfileError::InvalidProfile)?; + if profile.version != 1 { + return Err(NativeGeoProfileError::InvalidProfile); + } + match profile.geo { + GeoSelection::Off {} => Ok(NativeGeoSelection::Off), + GeoSelection::CloudFront { + alb_peer_cidrs, + origin_credential_header_name, + } => ProtectedCloudFrontAlb::new(alb_peer_cidrs, &origin_credential_header_name) + .map(|policy| NativeGeoSelection::CloudFront { policy }) + .map_err(|_| NativeGeoProfileError::InvalidPolicy), + } +} + +#[cfg(test)] +mod tests { + use edgezero_adapter_axum::dev_server::AxumRunOptions; + use edgezero_adapter_axum::geo::NativeGeoCapability; + + use super::*; + + const PROFILE: &str = r#"version = 1 +[geo] +provider = "cloudfront" +alb_peer_cidrs = ["127.0.0.1/32"] +origin_credential_header_name = "x-origin-fixture-private-name" +"#; + + #[test] + fn absent_profile_retains_off() { + temp_env::with_var_unset(PROFILE_FILE_ENV, || { + assert!(matches!( + load_selection().expect("should retain absent selection"), + NativeGeoSelection::Off + )); + }); + } + + #[test] + fn explicit_off_is_a_complete_selection() { + assert!(matches!( + parse_selection("version = 1\n[geo]\nprovider = 'off'") + .expect("should parse explicit off"), + NativeGeoSelection::Off + )); + assert!( + parse_selection("version = 1\n[geo]\nprovider = 'off'\nalb_peer_cidrs = []").is_err(), + "should reject source policy fields on an off selection" + ); + } + + #[test] + fn invalid_selected_file_does_not_fall_back() { + let directory = tempfile::tempdir().expect("should create profile directory"); + let path = directory.path().join("missing-profile.toml"); + temp_env::with_var(PROFILE_FILE_ENV, Some(path.as_os_str()), || { + assert!(matches!( + load_selection(), + Err(NativeGeoProfileError::Unavailable) + )); + fs::write(&path, "credential-sensitive malformed input") + .expect("should write invalid profile"); + assert!(matches!( + load_selection(), + Err(NativeGeoProfileError::InvalidProfile) + )); + }); + } + + #[test] + fn invalid_policy_diagnostics_do_not_disclose_inputs() { + for bytes in [ + PROFILE.replace("127.0.0.1/32", "0.0.0.0/0"), + PROFILE.replace("x-origin-fixture-private-name", "authorization"), + PROFILE.replace("version = 1", "version = 2"), + format!("{PROFILE}origin_credential_value = 'fixture-credential-never-supported'\n"), + ] { + let error = + parse_selection(&bytes).expect_err("should reject unsupported native Geo profile"); + let diagnostic = format!("{error:?}: {error}"); + for sensitive in [ + "127.0.0.1", + "0.0.0.0", + "x-origin-fixture-private-name", + "authorization", + "fixture-credential-never-supported", + ] { + assert!( + !diagnostic.contains(sensitive), + "should redact profile inputs" + ); + } + } + } + + #[test] + fn selected_implementation_is_explicitly_validated() { + let selection = parse_selection(PROFILE).expect("should compose protected source policy"); + let options = AxumRunOptions::new("127.0.0.1:8787".parse().expect("should parse address")) + .expect("should construct options") + .with_geo(selection); + #[cfg(feature = "geo-cloudfront")] + assert!(matches!( + options + .geo_capability() + .expect("should validate selected implementation"), + NativeGeoCapability::Selected { .. } + )); + #[cfg(not(feature = "geo-cloudfront"))] + { + assert!( + options.geo_capability().is_err(), + "should reject compiled-out Geo" + ); + let off = options.with_geo(NativeGeoSelection::Off); + assert_eq!( + off.geo_capability().expect("should permit off"), + NativeGeoCapability::Off + ); + } + } +} diff --git a/crates/trusted-server-adapter-axum/src/platform.rs b/crates/trusted-server-adapter-axum/src/platform.rs index fcf66facdd..1cc6e101c1 100644 --- a/crates/trusted-server-adapter-axum/src/platform.rs +++ b/crates/trusted-server-adapter-axum/src/platform.rs @@ -4,6 +4,7 @@ use std::sync::{Arc, OnceLock}; use edgezero_adapter_axum::diagnostics::NativeIngressMetadata; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; +use edgezero_core::geo::{GeoOutcome, GeoStatus}; use edgezero_core::http::{HeaderValue, Request, Uri, header}; use error_stack::{Report, ResultExt as _}; use trusted_server_core::http_util::RequestInfo; @@ -34,12 +35,43 @@ impl PlatformBackend for AxumPlatformBackend { } } -/// Reader geography is unavailable on native, regardless of deployment region. -pub struct AxumPlatformGeo; +/// An owned snapshot of the managed native request's geographic outcome. +/// +/// Provider resolution belongs to `EdgeZero` ingress. Every downstream lookup +/// returns this same request's data, never an IP lookup or shared global state. +#[derive(Clone, Default)] +pub struct AxumPlatformGeo { + info: Option, +} + +impl AxumPlatformGeo { + fn from_context(context: &RequestContext) -> Self { + Self { + info: geo_from_outcome(context.geo()), + } + } +} impl PlatformGeo for AxumPlatformGeo { fn lookup(&self, _client_ip: Option) -> Result, Report> { - Ok(None) + Ok(self.info.clone()) + } +} + +/// Project only the private managed outcome, without reading request headers. +/// Unavailable, untrusted, error and unselected outcomes supply no location. +pub(crate) fn geo_from_outcome(outcome: &GeoOutcome) -> Option { + match outcome { + GeoOutcome::Selected { + status: GeoStatus::Located { location, .. }, + .. + } => Some(GeoInfo::country_region( + location.country().as_str(), + location + .subdivision() + .map(|value| value.as_str().to_owned()), + )), + _ => None, } } @@ -92,7 +124,8 @@ pub(crate) fn apply_native_ingress(request: &mut Request) -> Result<(), EdgeErro /// /// The RC client and application clock come from the incoming request. Only /// dependency handles are shared; checked visitor metadata is read afresh. -/// Generic KV and reader geography are intentionally unavailable in this demo. +/// Generic KV remains unavailable. Geography is captured before converting the +/// managed context back into an ordinary request and never resolved again. /// /// # Errors /// Returns an error when startup has not installed the store snapshot or the @@ -118,7 +151,6 @@ pub fn build_runtime_services( static KV: OnceLock> = OnceLock::new(); static BACKEND: OnceLock> = OnceLock::new(); - static GEO: OnceLock> = OnceLock::new(); Ok(RuntimeServices::builder() .config_store(Arc::clone(&snapshot.config_store)) @@ -131,7 +163,7 @@ pub fn build_runtime_services( client, context.monotonic_clock(), ))) - .geo(Arc::clone(GEO.get_or_init(|| Arc::new(AxumPlatformGeo)))) + .geo(Arc::new(AxumPlatformGeo::from_context(context))) .client_info(ClientInfo { client_ip, ..ClientInfo::default() @@ -168,19 +200,90 @@ mod tests { ); } + fn located(country: &str, region: Option<&str>) -> GeoOutcome { + use edgezero_core::geo::{ + CountryCode, GeoLocation, GeoProviderId, GeoSource, GeoTrustBasis, SubdivisionCode, + }; + GeoOutcome::Selected { + provider: GeoProviderId::cloudfront(), + status: GeoStatus::Located { + location: GeoLocation::new( + CountryCode::new(country).expect("should parse fixture country"), + region.map(|value| { + SubdivisionCode::new(value).expect("should parse fixture subdivision") + }), + ), + source: GeoSource::CloudFront, + trust_basis: GeoTrustBasis::DeclaredProtectedAlb, + }, + } + } + #[test] - fn reader_geography_remains_unavailable() { - assert!( - AxumPlatformGeo - .lookup(None) - .expect("should look up geography") - .is_none() + fn geography_is_partial_and_request_local() { + let first = AxumPlatformGeo { + info: geo_from_outcome(&located("US", Some("CA"))), + }; + let second = AxumPlatformGeo { + info: geo_from_outcome(&located("GB", None)), + }; + let us = first + .lookup(None) + .expect("should read first snapshot") + .expect("should retain US"); + let gb = second + .lookup(None) + .expect("should read second snapshot") + .expect("should retain GB"); + assert_eq!(us.country, "US"); + assert_eq!(us.region.as_deref(), Some("CA")); + assert_eq!(gb.country, "GB"); + assert_eq!(gb.region, None); + assert_eq!(us.coordinates, None); + assert_eq!(us.city, None); + assert_eq!(us.continent, None); + assert_eq!(us.metro_code, None); + assert_eq!( + first + .lookup(Some( + "192.0.2.1".parse().expect("should parse different IP") + )) + .expect("should reuse same snapshot"), + Some(us), + "should not resolve geography from another lookup argument" ); - assert!( - AxumPlatformGeo - .lookup(Some("192.0.2.1".parse().expect("should parse client IP"))) - .expect("should look up geography") + } + + #[test] + fn unavailable_outcomes_and_forged_extensions_cannot_supply_geography() { + use edgezero_core::geo::{GeoError, GeoProviderId, GeoUnavailableReason}; + for status in [ + GeoStatus::Unavailable(GeoUnavailableReason::MissingMetadata), + GeoStatus::Unavailable(GeoUnavailableReason::UntrustedIngress), + GeoStatus::Error(GeoError::InvalidAssertion), + GeoStatus::Error(GeoError::ProviderFailure), + ] { + assert!( + geo_from_outcome(&GeoOutcome::Selected { + provider: GeoProviderId::cloudfront(), + status, + }) .is_none() + ); + } + let mut request = request_builder() + .header("cloudfront-viewer-country", "US") + .header("cloudfront-viewer-country-region", "CA") + .body(Body::empty()) + .expect("should build unmanaged request"); + request.extensions_mut().insert(located("US", Some("CA"))); + let context = RequestContext::new(request, edgezero_core::params::PathParams::default()); + assert!( + AxumPlatformGeo::from_context(&context) + .lookup(None) + .expect("should read unmanaged outcome") + .is_none(), + "should ignore raw headers and public extensions" ); } diff --git a/crates/trusted-server-adapter-axum/src/startup.rs b/crates/trusted-server-adapter-axum/src/startup.rs index d45c57ab6a..6495a24fd8 100644 --- a/crates/trusted-server-adapter-axum/src/startup.rs +++ b/crates/trusted-server-adapter-axum/src/startup.rs @@ -21,7 +21,8 @@ use trusted_server_core::platform::{ use trusted_server_core::settings::Settings; use crate::app::build_state_with_settings; -use crate::platform::{AxumPlatformBackend, AxumPlatformGeo}; +use crate::middleware::apply_finalize_headers; +use crate::platform::{AxumPlatformBackend, AxumPlatformGeo, geo_from_outcome}; /// Immutable inputs shared by per-request native runtime services. /// @@ -106,7 +107,7 @@ pub(crate) fn explicit_settings_services() -> RuntimeServices { .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(AxumPlatformBackend)) .http_client(Arc::new(UnavailableHttpClient)) - .geo(Arc::new(AxumPlatformGeo)) + .geo(Arc::new(AxumPlatformGeo::default())) .client_info(Default::default()) .build() } @@ -269,6 +270,11 @@ pub async fn initialize_native(app: &mut App, stores: &PreparedStores) -> Result }), secret_store, }); + let finalize_settings = Arc::clone(&state.settings); + app.set_admitted_error_response_finalizer(move |outcome, response| { + let geo = geo_from_outcome(outcome); + apply_finalize_headers(&finalize_settings, geo.as_ref(), response); + }); app.insert_state(snapshot); app.insert_state(state); Ok(()) diff --git a/crates/trusted-server-adapter-axum/tests/startup.rs b/crates/trusted-server-adapter-axum/tests/startup.rs index fcbab5ef57..09f4b2144a 100644 --- a/crates/trusted-server-adapter-axum/tests/startup.rs +++ b/crates/trusted-server-adapter-axum/tests/startup.rs @@ -52,6 +52,15 @@ impl ServerFixture { } fn spawn(&mut self, missing_secret: bool, short_passphrase: bool) { + self.spawn_configured(missing_secret, short_passphrase, None); + } + + fn spawn_configured( + &mut self, + missing_secret: bool, + short_passphrase: bool, + profile: Option<&str>, + ) { let mut command = Command::new(env!("CARGO_BIN_EXE_trusted-server-axum")); command .env_clear() @@ -78,6 +87,11 @@ impl ServerFixture { ) .stdout(Stdio::piped()) .stderr(Stdio::piped()); + if let Some(profile) = profile { + let path = self.root.join("native-geo.toml"); + fs::write(&path, profile).expect("should write synthetic Geo profile"); + command.env("TS_NATIVE_GEO_PROFILE_FILE", path); + } if !missing_secret { command.env( "EDGEZERO__SECRETS__EC_KEY", @@ -190,11 +204,15 @@ impl ServerFixture { } fn get(&self, path: &str) -> std::io::Result { + self.get_with_headers(path, "") + } + + fn get_with_headers(&self, path: &str, headers: &str) -> std::io::Result { let mut stream = TcpStream::connect_timeout(&self.addr, Duration::from_millis(100))?; stream.set_read_timeout(Some(Duration::from_secs(2)))?; write!( stream, - "GET {path} HTTP/1.1\r\nHost: publisher.example.com\r\nConnection: close\r\n\r\n" + "GET {path} HTTP/1.1\r\nHost: publisher.example.com\r\nConnection: close\r\n{headers}\r\n" )?; let mut response = String::new(); stream.read_to_string(&mut response)?; @@ -274,6 +292,151 @@ fn valid_prepared_inputs_serve_real_routes_and_ignore_changed_config_until_resta ); } +const GEO_PROFILE: &str = r#"version = 1 +[geo] +provider = "cloudfront" +alb_peer_cidrs = ["127.0.0.1/32"] +origin_credential_header_name = "x-origin-test-removal" +"#; + +#[test] +fn invalid_geo_profile_fails_before_store_preparation_and_listen() { + let mut server = ServerFixture::new(None); + server.spawn_configured(false, false, Some("version = 999\n[geo]\nprovider = 'off'")); + server.assert_failed_before_listen("native Geo profile is invalid"); +} + +#[test] +#[cfg(not(feature = "geo-cloudfront"))] +fn compiled_out_geo_selection_fails_before_store_preparation_and_listen() { + let mut server = ServerFixture::new(None); + server.spawn_configured(false, false, Some(GEO_PROFILE)); + server.assert_failed_before_listen("cloudfront implementation unavailable"); +} + +#[test] +#[cfg(feature = "geo-cloudfront")] +fn managed_geo_is_partial_immutable_per_request_and_finalized_on_auth_errors() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn_configured(false, false, Some(GEO_PROFILE)); + let health = server.wait_ready(); + assert!( + health + .to_ascii_lowercase() + .contains("x-geo-info-available: false") + ); + let headers = "CloudFront-Viewer-Country: us\r\nCloudFront-Viewer-Country-Region: ca\r\nx-origin-test-removal: fixture-origin-credential\r\n"; + for (path, status) in [ + ("/health", "200"), + ("/_ts/admin/ec", "401"), + ("/static/tsjs=0000000000000000", "404"), + ] { + let response = server + .get_with_headers(path, headers) + .expect("should reach managed native route"); + let lower = response.to_ascii_lowercase(); + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "should preserve route status: {response}" + ); + assert!(lower.contains("x-geo-info-available: true")); + assert!(lower.contains("x-geo-country: us")); + assert!(lower.contains("x-geo-region: ca")); + for absent in [ + "x-geo-coordinates:", + "x-geo-city:", + "x-geo-continent:", + "x-geo-metro-code:", + "x-origin-test-removal", + "fixture-origin-credential", + ] { + assert!( + !lower.contains(absent), + "should not export unknown detail or credential" + ); + } + } + let gb = server + .get_with_headers("/health", "CloudFront-Viewer-Country: GB\r\n") + .expect("should process another country"); + assert!(gb.to_ascii_lowercase().contains("x-geo-country: gb")); + assert!(!gb.to_ascii_lowercase().contains("x-geo-region:")); + for headers in [ + "", + "CloudFront-Viewer-Country: UK\r\n", + "CloudFront-Viewer-Country: US\r\nCloudFront-Viewer-Country: US\r\n", + "CloudFront-Viewer-Country-Region: CA\r\n", + ] { + let response = server + .get_with_headers("/health", headers) + .expect("should preserve health with unavailable geography"); + assert!( + response + .to_ascii_lowercase() + .contains("x-geo-info-available: false") + ); + assert!(!response.to_ascii_lowercase().contains("x-geo-country:")); + } +} + +#[test] +#[cfg(feature = "geo-cloudfront")] +fn admitted_body_error_keeps_managed_geo_without_losing_framework_status() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn_configured(false, false, Some(GEO_PROFILE)); + server.wait_ready(); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("should create local fixture client"); + let response = client + .post(format!("http://{}/auction", server.addr)) + .header("host", "publisher.example.com") + .header("content-type", "application/json") + .header("cloudfront-viewer-country", "US") + .header("cloudfront-viewer-country-region", "CA") + .header("x-origin-test-removal", "fixture-error-credential") + .body(vec![ + b' '; + trusted_server_core::auction::endpoints::MAX_AUCTION_BODY_SIZE + + 1 + ]) + .send() + .expect("should receive managed oversized-body response"); + assert_eq!( + response.status(), + reqwest::StatusCode::PAYLOAD_TOO_LARGE, + "should retain framework payload limit status" + ); + assert_eq!(response.headers()["x-geo-info-available"], "true"); + assert_eq!(response.headers()["x-geo-country"], "US"); + assert_eq!(response.headers()["x-geo-region"], "CA"); + assert!(!response.headers().contains_key("x-origin-test-removal")); + let body = response.text().expect("should read framework error"); + assert!(!body.contains("fixture-error-credential")); + assert!(!body.contains("x-origin-test-removal")); +} + +#[test] +#[cfg(feature = "geo-cloudfront")] +fn nonmatching_real_socket_peer_cannot_supply_geo() { + let mut server = ServerFixture::new(Some(ENVELOPE)); + server.spawn_configured( + false, + false, + Some(&GEO_PROFILE.replace("127.0.0.1/32", "192.0.2.1/32")), + ); + server.wait_ready(); + let response = server.get_with_headers("/health", "CloudFront-Viewer-Country: US\r\nCloudFront-Viewer-Country-Region: CA\r\nX-Forwarded-For: 192.0.2.1\r\n") + .expect("should reach health without trusting forged peer"); + assert!( + response + .to_ascii_lowercase() + .contains("x-geo-info-available: false") + ); + assert!(!response.to_ascii_lowercase().contains("x-geo-country:")); +} + #[test] fn handwritten_store_metadata_matches_the_manifest() { let manifest = include_str!("../../../edgezero.toml"); diff --git a/crates/trusted-server-adapter-cloudflare/src/platform.rs b/crates/trusted-server-adapter-cloudflare/src/platform.rs index 617b81f7a8..90b913de4e 100644 --- a/crates/trusted-server-adapter-cloudflare/src/platform.rs +++ b/crates/trusted-server-adapter-cloudflare/src/platform.rs @@ -658,14 +658,13 @@ pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> R /// /// `cf-ipcountry` is available on all plans. `cf-ipcity`, `cf-ipcontinent`, /// `cf-iplatitude`, and `cf-iplongitude` require an Enterprise plan. Absent or -/// unparseable values default to empty strings or `0.0`. Country code `XX` -/// (Cloudflare's "unknown" sentinel) is treated as absent. +/// unparseable values are absent. Coordinates are available only when both +/// values parse. Country code `XX` is treated as absent. struct CloudflareGeo { country: String, - city: String, - continent: String, - latitude: f64, - longitude: f64, + city: Option, + continent: Option, + coordinates: Option, } impl PlatformGeo for CloudflareGeo { @@ -677,9 +676,8 @@ impl PlatformGeo for CloudflareGeo { city: self.city.clone(), country: self.country.clone(), continent: self.continent.clone(), - latitude: self.latitude, - longitude: self.longitude, - metro_code: 0, + coordinates: self.coordinates, + metro_code: None, region: None, asn: None, })) @@ -697,29 +695,32 @@ fn build_geo(ctx: &edgezero_core::context::RequestContext) -> CloudflareGeo { let city = headers .get("cf-ipcity") .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); + .filter(|s| !s.is_empty()) + .map(str::to_string); let continent = headers .get("cf-ipcontinent") .and_then(|v| v.to_str().ok()) - .unwrap_or("") - .to_string(); + .filter(|s| !s.is_empty()) + .map(str::to_string); let latitude = headers .get("cf-iplatitude") .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); + .and_then(|s| s.parse::().ok()); let longitude = headers .get("cf-iplongitude") .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(0.0); + .and_then(|s| s.parse::().ok()); + let coordinates = latitude.zip(longitude).map(|(latitude, longitude)| { + trusted_server_core::platform::GeoCoordinates { + latitude, + longitude, + } + }); CloudflareGeo { country, city, continent, - latitude, - longitude, + coordinates, } } @@ -878,16 +879,63 @@ mod tests { .expect("should perform geo lookup") .expect("should return GeoInfo when country is set"); assert_eq!(info.country, "US", "should populate country"); - assert_eq!(info.city, "New York", "should populate city"); - assert_eq!(info.continent, "NA", "should populate continent"); - assert!( - (info.latitude - 40.71).abs() < 0.01, - "should populate latitude" + assert_eq!( + info.city.as_deref(), + Some("New York"), + "should populate city" ); - assert!( - (info.longitude - (-74.01)).abs() < 0.01, - "should populate longitude" + assert_eq!( + info.continent.as_deref(), + Some("NA"), + "should populate continent" ); + let coordinates = info.coordinates.expect("should populate coordinate pair"); + assert_eq!(coordinates.latitude, 40.71); + assert_eq!(coordinates.longitude, -74.01); + } + + #[test] + fn build_geo_country_only_does_not_invent_detail() { + let ctx = make_ctx_with_header("cf-ipcountry", "US"); + let info = build_geo(&ctx) + .lookup(None) + .expect("should look up geo") + .expect("should have country"); + assert_eq!(info, GeoInfo::country_region("US", None)); + } + + #[test] + fn build_geo_requires_both_parseable_coordinates_and_preserves_zero() { + for (latitude, longitude, has_coordinates) in [ + (None, None, false), + (Some("0"), None, false), + (None, Some("0"), false), + (Some("bad"), Some("0"), false), + (Some("0"), Some("bad"), false), + (Some("0"), Some("0"), true), + ] { + let mut builder = request_builder() + .uri("https://example.com/") + .header("cf-ipcountry", "US"); + if let Some(value) = latitude { + builder = builder.header("cf-iplatitude", value); + } + if let Some(value) = longitude { + builder = builder.header("cf-iplongitude", value); + } + let request = builder + .body(edgezero_core::body::Body::empty()) + .expect("should build request"); + let ctx = RequestContext::new(request, PathParams::default()); + let info = build_geo(&ctx) + .lookup(None) + .expect("should look up geo") + .expect("should have country"); + assert_eq!(info.coordinates.is_some(), has_coordinates); + if has_coordinates { + assert_eq!(info.coordinates_string().as_deref(), Some("0,0")); + } + } } // --------------------------------------------------------------------------- diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index b114630e9d..ad7a1cf221 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -662,12 +662,14 @@ impl PlatformHttpClient for FastlyPlatformHttpClient { /// that field mapping is never duplicated. fn geo_from_fastly(geo: &Geo) -> GeoInfo { GeoInfo { - city: geo.city().to_string(), + city: Some(geo.city().to_string()), country: geo.country_code().to_string(), - continent: format!("{:?}", geo.continent()), - latitude: geo.latitude(), - longitude: geo.longitude(), - metro_code: geo.metro_code(), + continent: Some(format!("{:?}", geo.continent())), + coordinates: Some(trusted_server_core::platform::GeoCoordinates { + latitude: geo.latitude(), + longitude: geo.longitude(), + }), + metro_code: Some(geo.metro_code()), region: geo.region().map(str::to_string), asn: None, } diff --git a/crates/trusted-server-core/src/auction/openrtb.rs b/crates/trusted-server-core/src/auction/openrtb.rs index 74553c9bce..d2cae15d02 100644 --- a/crates/trusted-server-core/src/auction/openrtb.rs +++ b/crates/trusted-server-core/src/auction/openrtb.rs @@ -384,10 +384,19 @@ fn build_common_request( geo: device.geo.as_ref().map(|geo| Geo { country: Some(geo.country.clone()), region: geo.region.clone(), - city: Some(geo.city.clone()), - lat: matches!(policy, ProfilePolicy::Prebid(_)).then_some(geo.latitude), - lon: matches!(policy, ProfilePolicy::Prebid(_)).then_some(geo.longitude), - metro: (geo.metro_code > 0).then(|| geo.metro_code.to_string()), + city: geo.city.clone(), + lat: geo + .coordinates + .filter(|_| matches!(policy, ProfilePolicy::Prebid(_))) + .map(|coordinates| coordinates.latitude), + lon: geo + .coordinates + .filter(|_| matches!(policy, ProfilePolicy::Prebid(_))) + .map(|coordinates| coordinates.longitude), + metro: geo + .metro_code + .filter(|code| *code > 0) + .map(|code| code.to_string()), r#type: Some(2), ..Default::default() }), diff --git a/crates/trusted-server-core/src/auction/openrtb/tests.rs b/crates/trusted-server-core/src/auction/openrtb/tests.rs index f47efa475e..a86da73799 100644 --- a/crates/trusted-server-core/src/auction/openrtb/tests.rs +++ b/crates/trusted-server-core/src/auction/openrtb/tests.rs @@ -171,6 +171,70 @@ fn deterministic_signer() -> RequestSigner { RequestSigner::from_services(&services).expect("should load deterministic signer") } +#[test] +fn geo_exports_omit_unavailable_partial_fields_across_profiles() { + for (profile, config) in [ + ("prebid-server", json!({})), + ("standard", json!({})), + ("aps", json!({"account_id": "example-account"})), + ] { + for region in [None, Some("CA".to_owned())] { + let mut request = canonical_parity_auction_request(); + request.device.as_mut().expect("should have device").geo = Some( + crate::platform::GeoInfo::country_region("US", region.clone()), + ); + let serialized = + serde_json::to_value(build_with_request(profile, config.clone(), request, None)) + .expect("should serialize partial geography"); + let mut expected = json!({"country": "US", "type": 2}); + if let Some(region) = region { + expected["region"] = json!(region); + } + assert_eq!(serialized["device"]["geo"], expected, "{profile}"); + } + } +} + +#[test] +fn geo_exports_preserve_populated_fields_and_real_zero_coordinates() { + for (profile, config) in [ + ("prebid-server", json!({})), + ("standard", json!({})), + ("aps", json!({"account_id": "example-account"})), + ] { + for coordinates in [ + crate::platform::GeoCoordinates { + latitude: 12.34, + longitude: 56.78, + }, + crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }, + ] { + let mut request = canonical_parity_auction_request(); + request + .device + .as_mut() + .expect("should have device") + .geo + .as_mut() + .expect("should have geo") + .coordinates = Some(coordinates); + let serialized = + serde_json::to_value(build_with_request(profile, config.clone(), request, None)) + .expect("should serialize populated geography"); + let mut expected = json!({"country": "US", "region": "CA", "city": "Example City", + "metro": "501", "type": 2}); + if profile == "prebid-server" { + expected["lat"] = json!(coordinates.latitude); + expected["lon"] = json!(coordinates.longitude); + } + assert_eq!(serialized["device"]["geo"], expected, "{profile}"); + } + } +} + #[test] fn consent_matrix_preserves_pbs_standard_and_aps_policies() { let cases = [ diff --git a/crates/trusted-server-core/src/auction/test_support.rs b/crates/trusted-server-core/src/auction/test_support.rs index a83f8899a8..a2ba93467f 100644 --- a/crates/trusted-server-core/src/auction/test_support.rs +++ b/crates/trusted-server-core/src/auction/test_support.rs @@ -105,12 +105,14 @@ pub(crate) fn canonical_parity_auction_request() -> AuctionRequest { user_agent: Some("Fictional Browser".to_string()), ip: Some("192.0.2.10".to_string()), geo: Some(GeoInfo { - city: "Example City".to_string(), + city: Some("Example City".to_string()), country: "US".to_string(), - continent: "NA".to_string(), - latitude: 12.34, - longitude: 56.78, - metro_code: 501, + continent: Some("NA".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 12.34, + longitude: 56.78, + }), + metro_code: Some(501), region: Some("CA".to_string()), asn: None, }), diff --git a/crates/trusted-server-core/src/consent/jurisdiction.rs b/crates/trusted-server-core/src/consent/jurisdiction.rs index 9073730731..539e219381 100644 --- a/crates/trusted-server-core/src/consent/jurisdiction.rs +++ b/crates/trusted-server-core/src/consent/jurisdiction.rs @@ -1,19 +1,20 @@ -//! Jurisdiction detection for consent observability. +//! Jurisdiction recognition for consent policy and observability. //! -//! Determines the applicable privacy regime based on geolocation data and -//! publisher configuration. Used for **logging and monitoring only** — the -//! detected jurisdiction never causes consent to be synthesized (see proposal -//! Key Decision #3). +//! Geography selects the applicable policy, never synthesizes consent. Partial +//! or unrecognized assertions remain unknown when policy requires subdivision +//! recognition. use core::fmt; +use edgezero_core::geo::CountryCode; + use crate::consent_config::ConsentConfig; use crate::geo::GeoInfo; /// The privacy jurisdiction applicable to a request. /// /// Derived from the user's geolocation and the publisher's configured -/// country/state lists. Used for observability — not for consent synthesis. +/// country/state lists. Used by consent policy, never for consent synthesis. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub enum Jurisdiction { /// GDPR applies (EU/EEA/UK per `consent.gdpr.applies_in`). @@ -43,12 +44,16 @@ impl fmt::Display for Jurisdiction { /// Checks the user's country against `config.gdpr.applies_in`, and for US /// users checks the region against `config.us_states.privacy_states`. /// -/// Returns [`Jurisdiction::Unknown`] when no geo data is available. +/// Returns [`Jurisdiction::Unknown`] for missing/unrecognized country data or +/// US geography without a recognized subdivision. #[must_use] pub fn detect_jurisdiction(geo: Option<&GeoInfo>, config: &ConsentConfig) -> Jurisdiction { let Some(geo) = geo else { return Jurisdiction::Unknown; }; + let Ok(country) = CountryCode::new(&geo.country) else { + return Jurisdiction::Unknown; + }; // Check GDPR countries first (EU/EEA/UK). This ordering also resolves // the `DE` code collision: ISO 3166-1 `DE` is Germany (GDPR), while @@ -58,26 +63,43 @@ pub fn detect_jurisdiction(geo: Option<&GeoInfo>, config: &ConsentConfig) -> Jur .gdpr .applies_in .iter() - .any(|code| code.eq_ignore_ascii_case(&geo.country)) + .any(|code| code.eq_ignore_ascii_case(country.as_str())) { return Jurisdiction::Gdpr; } - // For US users, check if the region is a state with a privacy law. - if geo.country.eq_ignore_ascii_case("US") - && let Some(region) = &geo.region - && config + if country.as_str() == "US" { + let Some(region) = geo.region.as_deref() else { + return Jurisdiction::Unknown; + }; + let region = region.to_ascii_uppercase(); + // Recognition is not the configurable list of states with privacy laws. + // CloudFront supplies syntax-checked assertions, not a state catalogue. + if !US_SUBDIVISIONS.contains(®ion.as_str()) { + return Jurisdiction::Unknown; + } + if config .us_states .privacy_states .iter() - .any(|state| state.eq_ignore_ascii_case(region)) - { - return Jurisdiction::UsState(region.to_uppercase()); + .any(|state| state.eq_ignore_ascii_case(®ion)) + { + return Jurisdiction::UsState(region); + } } Jurisdiction::NonRegulated } +// Assigned US state, district and outlying-area subdivision components. This +// recognition list is separate from the publisher's privacy-policy settings. +const US_SUBDIVISIONS: &[&str] = &[ + "AK", "AL", "AR", "AS", "AZ", "CA", "CO", "CT", "DC", "DE", "FL", "GA", "GU", "HI", "IA", "ID", + "IL", "IN", "KS", "KY", "LA", "MA", "MD", "ME", "MI", "MN", "MO", "MP", "MS", "MT", "NC", "ND", + "NE", "NH", "NJ", "NM", "NV", "NY", "OH", "OK", "OR", "PA", "PR", "RI", "SC", "SD", "TN", "TX", + "UM", "UT", "VA", "VI", "VT", "WA", "WI", "WV", "WY", +]; + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -89,16 +111,7 @@ mod tests { use crate::geo::GeoInfo; fn make_geo(country: &str, region: Option<&str>) -> GeoInfo { - GeoInfo { - city: "Test".to_owned(), - country: country.to_owned(), - continent: "Test".to_owned(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, - region: region.map(str::to_owned), - asn: None, - } + GeoInfo::country_region(country, region.map(str::to_owned)) } #[test] @@ -157,13 +170,13 @@ mod tests { } #[test] - fn us_no_region_is_non_regulated() { + fn us_no_region_remains_unknown() { let config = ConsentConfig::default(); let geo = make_geo("US", None); assert_eq!( detect_jurisdiction(Some(&geo), &config), - Jurisdiction::NonRegulated, - "US without region should be non-regulated" + Jurisdiction::Unknown, + "country-only US geography must not imply a non-regulated state" ); } @@ -199,6 +212,55 @@ mod tests { ); } + #[test] + fn unrecognized_subdivisions_remain_unknown_even_if_configured() { + let mut config = ConsentConfig::default(); + config.us_states.privacy_states.push("ZZ".to_owned()); + for region in ["ZZ", "01", "US-CA", "", " CA"] { + let geo = make_geo("US", Some(region)); + assert_eq!( + detect_jurisdiction(Some(&geo), &config), + Jurisdiction::Unknown + ); + } + for country in ["", "XX", "ZZ", "UK", "USA"] { + assert_eq!( + detect_jurisdiction(Some(&make_geo(country, None)), &config), + Jurisdiction::Unknown + ); + } + } + + #[test] + fn partial_geo_never_synthesizes_tcf_or_auction_permission() { + use crate::consent::{ConsentContext, consent_allows_server_side_auction}; + let config = ConsentConfig::default(); + for geo in [ + make_geo("US", None), + make_geo("US", Some("ZZ")), + make_geo("GB", None), + ] { + let context = ConsentContext { + jurisdiction: detect_jurisdiction(Some(&geo), &config), + ..ConsentContext::default() + }; + assert!(!consent_allows_server_side_auction(&context)); + assert!(context.tcf.is_none()); + } + let context = ConsentContext { + jurisdiction: detect_jurisdiction(Some(&make_geo("US", Some("CA"))), &config), + ..ConsentContext::default() + }; + assert!( + consent_allows_server_side_auction(&context), + "should retain existing non-GDPR auction admission policy" + ); + assert!( + context.tcf.is_none(), + "should not manufacture identity consent" + ); + } + #[test] fn display_formatting() { assert_eq!(Jurisdiction::Gdpr.to_string(), "GDPR"); diff --git a/crates/trusted-server-core/src/ec/kv_types.rs b/crates/trusted-server-core/src/ec/kv_types.rs index 589f280c11..628e54b63f 100644 --- a/crates/trusted-server-core/src/ec/kv_types.rs +++ b/crates/trusted-server-core/src/ec/kv_types.rs @@ -442,7 +442,7 @@ impl KvGeo { pub fn from_geo_info(geo: Option<&GeoInfo>) -> Self { match geo { Some(info) => { - let dma = (info.metro_code > 0).then_some(info.metro_code); + let dma = info.metro_code.filter(|code| *code > 0); Self { country: info.country.clone(), region: info.region.clone(), @@ -474,12 +474,14 @@ mod tests { fn sample_geo_info() -> GeoInfo { GeoInfo { - city: "San Francisco".to_owned(), + city: Some("San Francisco".to_owned()), country: "US".to_owned(), - continent: "NorthAmerica".to_owned(), - latitude: 37.7749, - longitude: -122.4194, - metro_code: 807, + continent: Some("NorthAmerica".to_owned()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 37.7749, + longitude: -122.4194, + }), + metro_code: Some(807), region: Some("CA".to_owned()), asn: Some(7922), } diff --git a/crates/trusted-server-core/src/geo.rs b/crates/trusted-server-core/src/geo.rs index 63f7907f53..d74c9795f6 100644 --- a/crates/trusted-server-core/src/geo.rs +++ b/crates/trusted-server-core/src/geo.rs @@ -20,22 +20,35 @@ use crate::constants::{ impl GeoInfo { /// Sets geo information headers on the response. /// - /// Adds `x-geo-city`, `x-geo-country`, `x-geo-continent`, `x-geo-coordinates`, - /// `x-geo-metro-code`, `x-geo-region` (when available), and - /// `x-geo-info-available: true` to the given response. + /// Adds available geographic fields and `x-geo-info-available: true`. + /// Missing fields never produce empty headers or default coordinates. pub fn set_response_headers(&self, response: &mut Response) { let headers = response.headers_mut(); + // An origin response may already carry geo headers. Missing request + // detail must not retain a different location from that response. + for name in [ + HEADER_X_GEO_CITY, + HEADER_X_GEO_COUNTRY, + HEADER_X_GEO_CONTINENT, + HEADER_X_GEO_COORDINATES, + HEADER_X_GEO_METRO_CODE, + HEADER_X_GEO_REGION, + ] { + headers.remove(name); + } - insert_geo_header(headers, HEADER_X_GEO_CITY, &self.city); + if let Some(ref city) = self.city { + insert_geo_header(headers, HEADER_X_GEO_CITY, city); + } insert_geo_header(headers, HEADER_X_GEO_COUNTRY, &self.country); - insert_geo_header(headers, HEADER_X_GEO_CONTINENT, &self.continent); - insert_geo_header( - headers, - HEADER_X_GEO_COORDINATES, - &self.coordinates_string(), - ); - if self.has_metro_code() { - let metro_code = self.metro_code.to_string(); + if let Some(ref continent) = self.continent { + insert_geo_header(headers, HEADER_X_GEO_CONTINENT, continent); + } + if let Some(coordinates) = self.coordinates_string() { + insert_geo_header(headers, HEADER_X_GEO_COORDINATES, &coordinates); + } + if let Some(code) = self.metro_code.filter(|code| *code > 0) { + let metro_code = code.to_string(); insert_geo_header(headers, HEADER_X_GEO_METRO_CODE, &metro_code); } if let Some(ref region) = self.region { @@ -100,12 +113,14 @@ mod tests { fn sample_geo_info() -> GeoInfo { GeoInfo { - city: "San Francisco".to_string(), + city: Some("San Francisco".to_string()), country: "US".to_string(), - continent: "NorthAmerica".to_string(), - latitude: 37.7749, - longitude: -122.4194, - metro_code: 807, + continent: Some("NorthAmerica".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 37.7749, + longitude: -122.4194, + }), + metro_code: Some(807), region: Some("CA".to_string()), asn: None, } @@ -200,7 +215,7 @@ mod tests { #[test] fn set_response_headers_omits_metro_code_when_zero() { let geo = GeoInfo { - metro_code: 0, + metro_code: Some(0), ..sample_geo_info() }; let mut response = build_response(); @@ -217,6 +232,45 @@ mod tests { ); } + #[test] + fn set_response_headers_omits_unavailable_partial_geo() { + for region in [None, Some("CA".to_owned())] { + let geo = GeoInfo::country_region("US", region.clone()); + let mut response = build_response(); + sample_geo_info().set_response_headers(&mut response); + geo.set_response_headers(&mut response); + let headers = response.headers(); + assert_eq!(headers[HEADER_X_GEO_COUNTRY], "US"); + assert_eq!(headers[HEADER_X_GEO_INFO_AVAILABLE], "true"); + for name in [ + HEADER_X_GEO_CITY, + HEADER_X_GEO_CONTINENT, + HEADER_X_GEO_COORDINATES, + HEADER_X_GEO_METRO_CODE, + ] { + assert!(!headers.contains_key(name)); + } + assert_eq!( + headers + .get(HEADER_X_GEO_REGION) + .map(|value| value.to_str().expect("should be valid header")), + region.as_deref() + ); + } + } + + #[test] + fn set_response_headers_preserves_supplied_zero_coordinates() { + let mut geo = GeoInfo::country_region("US", None); + geo.coordinates = Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }); + let mut response = build_response(); + geo.set_response_headers(&mut response); + assert_eq!(response.headers()[HEADER_X_GEO_COORDINATES], "0,0"); + } + #[test] fn is_gdpr_country_detects_eu_members() { assert!(is_gdpr_country("DE"), "Germany is EU"); diff --git a/crates/trusted-server-core/src/integrations/aps.rs b/crates/trusted-server-core/src/integrations/aps.rs index 4f45fbeb9c..130be1a39f 100644 --- a/crates/trusted-server-core/src/integrations/aps.rs +++ b/crates/trusted-server-core/src/integrations/aps.rs @@ -1133,8 +1133,11 @@ impl ApsAuctionProvider { geo: device.geo.as_ref().map(|geo| Geo { country: Some(geo.country.clone()), region: geo.region.clone(), - city: Some(geo.city.clone()), - metro: (geo.metro_code > 0).then(|| geo.metro_code.to_string()), + city: geo.city.clone(), + metro: geo + .metro_code + .filter(|code| *code > 0) + .map(|code| code.to_string()), r#type: Some(2), ..Default::default() }), @@ -2402,6 +2405,41 @@ mod tests { ); } + #[test] + fn builds_aps_partial_geo_without_missing_detail() { + let provider = ApsAuctionProvider::new(config()); + let mut auction_request = canonical_parity_auction_request(); + auction_request + .device + .as_mut() + .expect("should have device") + .geo = Some(crate::platform::GeoInfo::country_region( + "US", + Some("CA".to_owned()), + )); + let settings = create_test_settings(); + let services = noop_services(); + let downstream = http::Request::builder() + .uri("https://publisher.example/auction") + .body(EdgeBody::empty()) + .expect("should build request"); + let context = AuctionContext { + settings: &settings, + request: &downstream, + timeout_ms: 321, + transport_timeout_ms: 321, + provider_responses: None, + services: &services, + }; + let serialized = + serde_json::to_value(provider.build_openrtb_request(&auction_request, &context)) + .expect("should serialize APS request"); + assert_eq!( + serialized["device"]["geo"], + json!({"country": "US", "region": "CA", "type": 2}) + ); + } + #[test] fn request_language_enforces_byte_limit() { let settings = create_test_settings(); diff --git a/crates/trusted-server-core/src/integrations/datadome/protection.rs b/crates/trusted-server-core/src/integrations/datadome/protection.rs index a7c55bf864..e1cfd3f624 100644 --- a/crates/trusted-server-core/src/integrations/datadome/protection.rs +++ b/crates/trusted-server-core/src/integrations/datadome/protection.rs @@ -1562,12 +1562,14 @@ mod tests { ..protection_config() }; let geo_info = GeoInfo { - city: String::new(), + city: None, country: String::new(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, + continent: None, + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + metro_code: Some(0), region: None, asn: Some(64500), }; diff --git a/crates/trusted-server-core/src/integrations/didomi.rs b/crates/trusted-server-core/src/integrations/didomi.rs index e1e7c87e88..df8a59be09 100644 --- a/crates/trusted-server-core/src/integrations/didomi.rs +++ b/crates/trusted-server-core/src/integrations/didomi.rs @@ -593,16 +593,7 @@ mod tests { } fn geo_info(country: &str, region: Option<&str>) -> GeoInfo { - GeoInfo { - city: String::new(), - country: country.to_string(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, - region: region.map(str::to_string), - asn: None, - } + GeoInfo::country_region(country, region.map(str::to_string)) } fn config_with_geo_query_parameters() -> DidomiIntegrationConfig { diff --git a/crates/trusted-server-core/src/integrations/prebid.rs b/crates/trusted-server-core/src/integrations/prebid.rs index f1d1d0b509..af8a38ca2c 100644 --- a/crates/trusted-server-core/src/integrations/prebid.rs +++ b/crates/trusted-server-core/src/integrations/prebid.rs @@ -2786,19 +2786,15 @@ impl PrebidAuctionProvider { ip: d.ip.clone(), geo: d.geo.as_ref().map(|geo| Geo { country: Some(geo.country.clone()), - city: Some(geo.city.clone()), + city: geo.city.clone(), region: geo.region.clone(), - lat: Some(geo.latitude), - lon: Some(geo.longitude), - // DMA/metro code: convert i64 to string for OpenRTB. - // Fastly returns 0 for "no metro code"; negative values - // are not realistic for DMA codes so the > 0 guard is - // sufficient. - metro: if geo.metro_code > 0 { - Some(geo.metro_code.to_string()) - } else { - None - }, + lat: geo.coordinates.map(|coordinates| coordinates.latitude), + lon: geo.coordinates.map(|coordinates| coordinates.longitude), + // Fastly returns 0 for "no metro code". + metro: geo + .metro_code + .filter(|code| *code > 0) + .map(|code| code.to_string()), r#type: Some(2), ..Default::default() }), @@ -6020,12 +6016,14 @@ external_bundle_sri = "sha384-AAAA" user_agent: Some("TestAgent".to_string()), ip: None, geo: Some(GeoInfo { - city: "Berlin".to_string(), + city: Some("Berlin".to_string()), country: "DE".to_string(), - continent: "EU".to_string(), - latitude: 52.52, - longitude: 13.405, - metro_code: 0, + continent: Some("EU".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 52.52, + longitude: 13.405, + }), + metro_code: Some(0), region: None, asn: None, }), @@ -6114,12 +6112,14 @@ external_bundle_sri = "sha384-AAAA" user_agent: Some("TestAgent".to_string()), ip: None, geo: Some(GeoInfo { - city: "New York".to_string(), + city: Some("New York".to_string()), country: "US".to_string(), - continent: "NA".to_string(), - latitude: 40.7128, - longitude: -74.006, - metro_code: 501, + continent: Some("NA".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 40.7128, + longitude: -74.006, + }), + metro_code: Some(501), region: Some("NY".to_string()), asn: None, }), @@ -6153,12 +6153,14 @@ external_bundle_sri = "sha384-AAAA" user_agent: Some("TestAgent".to_string()), ip: None, geo: Some(GeoInfo { - city: "New York".to_string(), + city: Some("New York".to_string()), country: "US".to_string(), - continent: "NA".to_string(), - latitude: 40.7128, - longitude: -74.006, - metro_code: 501, + continent: Some("NA".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 40.7128, + longitude: -74.006, + }), + metro_code: Some(501), region: Some("NY".to_string()), asn: None, }), @@ -6587,12 +6589,14 @@ external_bundle_sri = "sha384-AAAA" user_agent: Some("TestAgent".to_string()), ip: Some("1.2.3.4".to_string()), geo: Some(GeoInfo { - city: "New York".to_string(), + city: Some("New York".to_string()), country: "US".to_string(), - continent: "NA".to_string(), - latitude: 40.7128, - longitude: -74.006, - metro_code: 501, + continent: Some("NA".to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 40.7128, + longitude: -74.006, + }), + metro_code: Some(501), region: Some("NY".to_string()), asn: None, }), @@ -6626,6 +6630,44 @@ external_bundle_sri = "sha384-AAAA" assert_eq!(geo.region.as_deref(), Some("NY")); } + #[test] + fn to_openrtb_partial_geo_omits_missing_detail_and_preserves_supplied_zero() { + let provider = PrebidAuctionProvider::new(base_config()); + let settings = make_settings(); + let request = build_test_request(); + let context = create_test_auction_context(&settings, &request); + for coordinates in [ + None, + Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + ] { + let mut auction_request = create_test_auction_request(); + let mut geo = GeoInfo::country_region("US", Some("CA".to_owned())); + geo.coordinates = coordinates; + auction_request.device = Some(DeviceInfo { + user_agent: None, + ip: None, + geo: Some(geo), + }); + let openrtb = provider.to_openrtb( + &auction_request, + &context, + None, + make_request_info(&context), + ); + let serialized = serde_json::to_value(openrtb).expect("should serialize request"); + let geo = &serialized["device"]["geo"]; + let mut expected = serde_json::json!({"country": "US", "region": "CA", "type": 2}); + if coordinates.is_some() { + expected["lat"] = serde_json::json!(0.0); + expected["lon"] = serde_json::json!(0.0); + } + assert_eq!(*geo, expected); + } + } + #[test] fn to_openrtb_sets_tmax_and_cur() { let provider = PrebidAuctionProvider::new(base_config()); diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 63f5c2a94e..8b9359469d 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -74,8 +74,8 @@ pub use template_cache::{ }; pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; pub use types::{ - ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, - StoreName, + ClientInfo, GeoCoordinates, GeoInfo, PlatformBackendSpec, RuntimeServices, + RuntimeServicesBuilder, StoreId, StoreName, }; /// Default first-byte timeout for platform backends. @@ -243,32 +243,112 @@ mod tests { #[test] fn geo_info_coordinates_string_formats_correctly() { let geo = GeoInfo { - city: "New York".to_owned(), + city: Some("New York".to_owned()), country: "US".to_owned(), - continent: "NorthAmerica".to_owned(), - latitude: 40.7128, - longitude: -74.0060, - metro_code: 501, + continent: Some("NorthAmerica".to_owned()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 40.7128, + longitude: -74.0060, + }), + metro_code: Some(501), region: Some("NY".to_owned()), asn: None, }; assert_eq!( geo.coordinates_string(), - "40.7128,-74.006", + Some("40.7128,-74.006".to_owned()), "should format coordinates as lat,lon" ); } + #[test] + fn geo_info_full_serialization_preserves_flat_fields() { + let original = serde_json::json!({ + "city": "San Francisco", + "country": "US", + "continent": "NorthAmerica", + "latitude": 37.7749, + "longitude": -122.4194, + "metro_code": 807, + "region": "CA", + "asn": 7922 + }); + let geo: GeoInfo = serde_json::from_value(original.clone()) + .expect("should deserialize the existing populated wire format"); + assert_eq!( + geo.coordinates, + Some(GeoCoordinates { + latitude: 37.7749, + longitude: -122.4194, + }) + ); + assert_eq!( + serde_json::to_value(&geo).expect("should serialize geo"), + original + ); + assert_eq!( + serde_json::to_string(&geo).expect("should serialize full geo"), + r#"{"city":"San Francisco","country":"US","continent":"NorthAmerica","latitude":37.7749,"longitude":-122.4194,"metro_code":807,"region":"CA","asn":7922}"# + ); + } + + #[test] + fn geo_info_partial_serialization_omits_unavailable_fields() { + for region in [None, Some("CA".to_owned())] { + let geo = GeoInfo::country_region("US", region.clone()); + let mut expected = serde_json::json!({"country": "US"}); + if let Some(region) = region { + expected["region"] = serde_json::json!(region); + } + let serialized = serde_json::to_value(&geo).expect("should serialize partial geo"); + assert_eq!(serialized, expected); + assert_eq!( + serde_json::from_value::(serialized) + .expect("should deserialize country without coordinates"), + geo + ); + assert_eq!(geo.coordinates_string(), None); + assert!(!geo.has_metro_code()); + } + } + + #[test] + fn geo_info_rejects_incomplete_coordinate_pairs() { + for input in [ + serde_json::json!({"country": "US", "latitude": 0.0}), + serde_json::json!({"country": "US", "longitude": 0.0}), + serde_json::json!({"country": "US", "latitude": 0.0, "longitude": null}), + ] { + let error = serde_json::from_value::(input) + .expect_err("should reject half a coordinate pair"); + assert!(error.to_string().contains("supplied together")); + } + } + + #[test] + fn geo_info_preserves_supplied_zero_coordinates() { + let original = serde_json::json!({"country": "US", "latitude": 0.0, "longitude": 0.0}); + let geo: GeoInfo = serde_json::from_value(original.clone()) + .expect("should accept a supplied zero coordinate pair"); + assert_eq!(geo.coordinates_string().as_deref(), Some("0,0")); + assert_eq!( + serde_json::to_value(geo).expect("should serialize geo"), + original + ); + } + #[test] fn geo_info_has_metro_code_returns_true_for_nonzero() { let geo = GeoInfo { - city: String::new(), + city: None, country: String::new(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 807, + continent: None, + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + metro_code: Some(807), region: None, asn: None, }; @@ -281,12 +361,14 @@ mod tests { #[test] fn geo_info_has_metro_code_returns_false_for_zero() { let geo = GeoInfo { - city: String::new(), + city: None, country: String::new(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, + continent: None, + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + metro_code: Some(0), region: None, asn: None, }; diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 3570bdc102..e8e4e9e3d6 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -10,43 +10,115 @@ use super::{ PlatformSecretStore, }; +/// A supplied latitude/longitude pair. Neither coordinate implies a default. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct GeoCoordinates { + /// Latitude coordinate. + pub latitude: f64, + /// Longitude coordinate. + pub longitude: f64, +} + /// Geographic information extracted from a request. /// -/// Serde derives are required because `GeoInfo` is embedded in -/// `AuctionRequest`, which is serialised for bid-request payloads. -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +/// Country-only results do not imply city or coordinate precision. Populated +/// coordinates retain the flat `latitude`/`longitude` JSON fields used in +/// serialised `AuctionRequest` payloads. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] pub struct GeoInfo { - /// City name. - pub city: String, + /// City name, when supplied. + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, /// Two-letter country code. pub country: String, - /// Continent name. - pub continent: String, - /// Latitude coordinate. - pub latitude: f64, - /// Longitude coordinate. - pub longitude: f64, - /// DMA (Designated Market Area) / metro code. - pub metro_code: i64, - /// Region code. + /// Continent name, when supplied. + #[serde(skip_serializing_if = "Option::is_none")] + pub continent: Option, + /// Coordinates, available only as a complete pair. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub coordinates: Option, + /// DMA (Designated Market Area) / metro code, when supplied. + #[serde(skip_serializing_if = "Option::is_none")] + pub metro_code: Option, + /// Region code, when supplied. + #[serde(skip_serializing_if = "Option::is_none")] pub region: Option, /// Autonomous System Number (e.g. `7922` = Comcast). /// Used to distinguish home ISP vs. corporate VPN. - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(skip_serializing_if = "Option::is_none")] pub asn: Option, } +impl<'de> serde::Deserialize<'de> for GeoInfo { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Deserialising a flattened Option directly would + // silently discard an incomplete pair as None. Reject that input. + #[derive(serde::Deserialize)] + struct Fields { + city: Option, + country: String, + continent: Option, + latitude: Option, + longitude: Option, + metro_code: Option, + region: Option, + asn: Option, + } + + let fields = Fields::deserialize(deserializer)?; + let coordinates = match (fields.latitude, fields.longitude) { + (Some(latitude), Some(longitude)) => Some(GeoCoordinates { + latitude, + longitude, + }), + (None, None) => None, + _ => { + return Err(serde::de::Error::custom( + "latitude and longitude must be supplied together", + )); + } + }; + Ok(Self { + city: fields.city, + country: fields.country, + continent: fields.continent, + coordinates, + metro_code: fields.metro_code, + region: fields.region, + asn: fields.asn, + }) + } +} + impl GeoInfo { - /// Returns coordinates as a formatted string `"latitude,longitude"`. + /// Construct country and optional region data without inventing detail. + #[must_use] + pub fn country_region(country: impl Into, region: Option) -> Self { + Self { + city: None, + country: country.into(), + continent: None, + coordinates: None, + metro_code: None, + region, + asn: None, + } + } + + /// Returns supplied coordinates as `"latitude,longitude"`. #[must_use] - pub fn coordinates_string(&self) -> String { - format!("{},{}", self.latitude, self.longitude) + pub fn coordinates_string(&self) -> Option { + self.coordinates + .map(|coordinates| format!("{},{}", coordinates.latitude, coordinates.longitude)) } /// Checks if a valid metro code is available. #[must_use] pub fn has_metro_code(&self) -> bool { - self.metro_code > 0 + self.metro_code.is_some_and(|code| code > 0) } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index a422d62360..1ca5e34aec 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -12831,12 +12831,14 @@ mod tests { _client_ip: Option, ) -> Result, Report> { Ok(Some(GeoInfo { - city: self.0.to_string(), + city: Some(self.0.to_string()), country: self.0.to_string(), - continent: self.0.to_string(), - latitude: 1.0, - longitude: 2.0, - metro_code: 3, + continent: Some(self.0.to_string()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 1.0, + longitude: 2.0, + }), + metro_code: Some(3), region: Some(self.0.to_string()), asn: Some(4), })) diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 37b8a9e657..2006a8a6e0 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -319,11 +319,13 @@ pub mod nextjs_auction { ) -> Result, Report> { Ok(Some(GeoInfo { country: "AU".to_owned(), - city: "Example City".to_owned(), - continent: "Oceania".to_owned(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, + city: Some("Example City".to_owned()), + continent: Some("Oceania".to_owned()), + coordinates: Some(crate::platform::GeoCoordinates { + latitude: 0.0, + longitude: 0.0, + }), + metro_code: Some(0), region: None, asn: None, })) diff --git a/docs/guide/native-rc-demo.md b/docs/guide/native-rc-demo.md index be84cfd6b1..8c3a530809 100644 --- a/docs/guide/native-rc-demo.md +++ b/docs/guide/native-rc-demo.md @@ -40,9 +40,9 @@ are immutable; restart to adopt changed configuration or secrets. Request-time management writes, native signing and automatic key rotation are not supplied. Unsupported configured consumers fail during startup. -This startup unit does not add native EC persistence or reader Geo. Those are -separate existing demo units. An AWS region is not a visitor's geography. Missing -Geo retains the existing fail-closed consent behavior. +Native EC persistence is a separate existing demo unit. Reader Geo defaults Off; +the optional protected CloudFront selection below supplies country/subdivision +facts without changing consent authority. An AWS region is not visitor geography. ## Prepare local inputs @@ -84,6 +84,49 @@ enable server providers. Selected AWS dependencies stay native-only. Source-buil images, runtime wrapping and sanitized infrastructure examples are separate review units; this startup unit provisions nothing. +## Optional protected native Geo + +Compile `geo-cloudfront` explicitly and set `TS_NATIVE_GEO_PROFILE_FILE` to a +separately delivered boot profile. Compiling the feature does not select it. +EdgeZero does not read this TS selection from publisher AppConfig, store bindings +or an `EDGEZERO__GEO__*` environment variable. + +```toml +version = 1 + +[geo] +provider = "cloudfront" +alb_peer_cidrs = ["10.42.1.0/24", "10.42.2.0/24"] +origin_credential_header_name = "x-origin-example-removal" +``` + +An absent profile keeps Geo Off. An unreadable/invalid selected profile or a +compiled-out provider fails before readiness. The profile contains admitted +immediate ALB peer ranges and the credential header's removal name, never its +expected value. Diagnostics do not retain profile bytes or policy inputs. + +This selection requires CloudFront-generated country/subdivision assertions, +HTTPS to an exact-credential-gated ALB, CloudFront-only ALB ingress and an ALB-only +private application port. EdgeZero does not authenticate an expected credential +value itself. The deployment must enforce that boundary. Disable shared caching, +including cacheable errors. Ordinary forwarding trust and protected Geo source +admission are independent. Provisioning and staged live-input changes are not +part of this feature. + +TS reads the immutable `RequestContext::geo()` result, not raw Geo headers. A +located country/subdivision supplies one request-local snapshot to Core and +response finalization. Missing detail is absent rather than empty text or zero +coordinates. Complete populated Geo fields retain their existing flat wire +names. Unselected, untrusted and unavailable outcomes supply no location. US +without a recognized subdivision remains Unknown for existing consent policy. +Geographic estimates do not grant consent or change TCF/GPC/identity rules. + +The admitted-error finalizer retains Geo/TS headers without replacing framework +status, required headers or failure classification. Pre-admission failures have +no managed outcome and remain outside that finalizer. The selected serving +provider is protected CloudFront, not the standalone EdgeZero KV Geo prototype. No +Geo database, remote lookup or licensed data is imported. + ## Request handling Native reader address and public URL come from checked EdgeZero From 633a0c76fa7c1486d217287e330ba5b505dd7ef7 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 12 Nov 2026 00:40:35 -0600 Subject: [PATCH 2/3] Migrate current Fastly Geo fixtures to partial location data --- .../trusted-server-adapter-fastly/src/app.rs | 14 ++++++----- .../trusted-server-adapter-fastly/src/main.rs | 24 +++++++++++-------- .../src/middleware.rs | 12 ++++++---- .../src/platform.rs | 14 ++++++----- .../src/post_delivery.rs | 12 ++++++---- 5 files changed, 44 insertions(+), 32 deletions(-) diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 1dd72dddb6..ca496c8cfd 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -1696,12 +1696,14 @@ mod tests { let state = build_state_from_settings(test_settings()).expect("should build test state"); let reader_ip = IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, 7)); let held = GeoInfo { - city: "Springfield".to_owned(), + city: Some("Springfield".to_owned()), country: "US".to_owned(), - continent: "NorthAmerica".to_owned(), - latitude: 39.78, - longitude: -89.65, - metro_code: 648, + continent: Some("NorthAmerica".to_owned()), + coordinates: Some(trusted_server_core::platform::GeoCoordinates { + latitude: 39.78, + longitude: -89.65, + }), + metro_code: Some(648), region: Some("IL".to_owned()), asn: None, }; @@ -1724,7 +1726,7 @@ mod tests { .geo() .lookup(services.client_info().client_ip) .expect("should answer geo") - .map(|geo| geo.city), + .and_then(|geo| geo.city), Some("Springfield".to_owned()), "should answer the client IP from the entry point's lookup" ); diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 0d7be4e357..b332b87f8b 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -1232,12 +1232,14 @@ mod tests { fn sample_geo() -> GeoInfo { GeoInfo { - city: "San Francisco".to_owned(), + city: Some("San Francisco".to_owned()), country: "US".to_owned(), - continent: "NorthAmerica".to_owned(), - latitude: 37.7749, - longitude: -122.4194, - metro_code: 807, + continent: Some("NorthAmerica".to_owned()), + coordinates: Some(trusted_server_core::platform::GeoCoordinates { + latitude: 37.7749, + longitude: -122.4194, + }), + metro_code: Some(807), region: Some("CA".to_owned()), asn: None, } @@ -1657,12 +1659,14 @@ mod tests { fn entry_point_finalize_withholds_geo_from_401() { let settings = test_settings(); let request_geo = GeoInfo { - city: "Springfield".to_owned(), + city: Some("Springfield".to_owned()), country: "US".to_owned(), - continent: "NorthAmerica".to_owned(), - latitude: 39.78, - longitude: -89.65, - metro_code: 648, + continent: Some("NorthAmerica".to_owned()), + coordinates: Some(trusted_server_core::platform::GeoCoordinates { + latitude: 39.78, + longitude: -89.65, + }), + metro_code: Some(648), region: Some("IL".to_owned()), asn: None, }; diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index d822c81d85..6529d244c6 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -864,12 +864,14 @@ mod tests { fn sample_geo(city: &str) -> GeoInfo { GeoInfo { - city: city.to_owned(), + city: Some(city.to_owned()), country: "US".to_owned(), - continent: "NorthAmerica".to_owned(), - latitude: 37.7749, - longitude: -122.4194, - metro_code: 807, + continent: Some("NorthAmerica".to_owned()), + coordinates: Some(trusted_server_core::platform::GeoCoordinates { + latitude: 37.7749, + longitude: -122.4194, + }), + metro_code: Some(807), region: Some("CA".to_owned()), asn: None, } diff --git a/crates/trusted-server-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 15842d63ff..63435afc1e 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -379,12 +379,14 @@ mod tests { let request_geo = RequestGeo::new( Some(resolved_ip), Some(GeoInfo { - city: "Springfield".to_owned(), + city: Some("Springfield".to_owned()), country: "US".to_owned(), - continent: "NorthAmerica".to_owned(), - latitude: 39.78, - longitude: -89.65, - metro_code: 648, + continent: Some("NorthAmerica".to_owned()), + coordinates: Some(trusted_server_core::platform::GeoCoordinates { + latitude: 39.78, + longitude: -89.65, + }), + metro_code: Some(648), region: Some("IL".to_owned()), asn: None, }), @@ -398,7 +400,7 @@ mod tests { .expect("should answer another IP"); assert_eq!( - held.map(|geo| geo.city), + held.and_then(|geo| geo.city), Some("Springfield".to_owned()), "should return the held geo for the resolved IP" ); diff --git a/crates/trusted-server-adapter-fastly/src/post_delivery.rs b/crates/trusted-server-adapter-fastly/src/post_delivery.rs index 63d5324bda..6082af5384 100644 --- a/crates/trusted-server-adapter-fastly/src/post_delivery.rs +++ b/crates/trusted-server-adapter-fastly/src/post_delivery.rs @@ -351,12 +351,14 @@ mod tests { .body(edgezero_core::body::Body::empty()) .expect("should build the reader request"); let geo = GeoInfo { - city: "Tokyo".to_owned(), + city: Some("Tokyo".to_owned()), country: "JP".to_owned(), - continent: "Asia".to_owned(), - latitude: 35.6762, - longitude: 139.6503, - metro_code: 0, + continent: Some("Asia".to_owned()), + coordinates: Some(trusted_server_core::platform::GeoCoordinates { + latitude: 35.6762, + longitude: 139.6503, + }), + metro_code: Some(0), region: None, asn: None, }; From 1a914d6998465acb8bae99dfc323fa276755b142 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 12 Nov 2026 01:37:56 -0600 Subject: [PATCH 3/3] Keep the trace parity fixture honest about partial Geo --- crates/trusted-server-integration-tests/tests/parity.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-integration-tests/tests/parity.rs b/crates/trusted-server-integration-tests/tests/parity.rs index a192140397..bfd3e4ead2 100644 --- a/crates/trusted-server-integration-tests/tests/parity.rs +++ b/crates/trusted-server-integration-tests/tests/parity.rs @@ -1830,12 +1830,11 @@ mod trace_parity { ); self.0.fetch_add(1, Ordering::SeqCst); Ok(Some(GeoInfo { - city: String::new(), + city: None, country: "GB".to_owned(), - continent: String::new(), - latitude: 0.0, - longitude: 0.0, - metro_code: 0, + continent: None, + coordinates: None, + metro_code: None, region: Some("EX".to_owned()), asn: Some(64512), }))