From d66b7ceef85ba42e781fcb45d3d8ddd054d360cf Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Tue, 13 Oct 2026 03:28:02 +0100 Subject: [PATCH 01/18] A geo module is called with the request, as a device module is --- CHANGELOG.md | 1 + crates/auction/prebid-server/src/lib.rs | 4 +- crates/cmp/didomi/src/lib.rs | 42 +++++- crates/loading-cache/51degrees/src/tests.rs | 4 +- crates/testing/seam-probe/src/lib.rs | 39 ++++- .../tests/seam_probe.rs | 9 +- .../trusted-server-adapter-fastly/src/app.rs | 7 +- .../trusted-server-adapter-fastly/src/lib.rs | 25 +++- .../src/middleware.rs | 99 ++++++++++--- .../src/tinybird.rs | 4 +- .../src/platform.rs | 10 +- .../src/auction/endpoints.rs | 36 ++++- .../src/auction/orchestrator.rs | 25 +++- crates/trusted-server-core/src/ec/mod.rs | 83 ++++++++++- .../src/integrations/registry.rs | 68 ++++++--- .../trusted-server-core/src/platform/geo.rs | 134 +++++++++++++++++- .../trusted-server-core/src/platform/mod.rs | 55 ++++--- .../src/platform/test_support.rs | 61 ++++++-- .../src/platform/traits.rs | 32 +++++ .../trusted-server-core/src/platform/types.rs | 14 +- crates/trusted-server-core/src/publisher.rs | 25 +++- .../src/robots_txt/holding.rs | 5 +- .../trusted-server-core/src/test_support.rs | 5 +- docs/guide/configuration.md | 2 +- docs/guide/integration-guide.md | 42 +++--- 25 files changed, 680 insertions(+), 151 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0feb98502..0af10a82db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Breaking for crates outside core:** a geo module is a `GeoModule`, called with the request's module call as a device module is (`locate(call)`), so it sees the request's evidence, the settings and the services. `RuntimeServices::geo` and `IntegrationRegistration::with_geo_module` take an `Arc`, and `build_geo_module` returns one. A module that resolves from the client address alone keeps implementing `PlatformGeo` and is offered through `PlatformGeoModule::new(name, module)`, which hands the platform lookup the address the call carries. Core's lookups on the Edge Cookie and auction paths, and the Fastly adapter's two finalize paths, build the call from the request. - A module declares the caches it loads through, by name, and a deployment selects a loading cache for each in its own `[cache.]` table, the way `[cache.robots-txt]` selects one for robots.txt. A `[cache.]` table naming a cache no linked module declares is refused when the deployment starts, naming the ones it does. `IntegrationBuilder::with_cache_features` declares them, `CacheSettings::loading_caches` lists core's own and the declared ones, and `LoadingCaches::insert` takes any name. - **Breaking for crates outside core:** the cookie constants `COOKIE_TS_EC`, `COOKIE_TS_EC_RESOLVED`, `COOKIE_TS_EC_PULL_COMPLETE`, `COOKIE_TS_EIDS` and `COOKIE_TS_TESTER` are `COOKIE_EC`, `COOKIE_EC_RESOLVED`, `COOKIE_EC_PULL_COMPLETE`, `COOKIE_EIDS` and `COOKIE_TESTER`, holding the entry's name alone, and a cookie is named through the request's public prefix with `services.public_prefix().cookie_name(COOKIE_EC)`. A module's response header naming a cookie under the public prefix is refused as one under `ts-` was. - A robots.txt module's answer is held by the deployment's loading cache, briefly in process memory and in the host's shared store outside it, so a module is asked once per refresh however many instances and requests want its rules, where each instance held its own copy in the key-value store. The last answer is still kept apart for 30 days and served when the module cannot answer, in the value cache on Fastly and in the key-value store elsewhere. diff --git a/crates/auction/prebid-server/src/lib.rs b/crates/auction/prebid-server/src/lib.rs index 092117af6a..44d7b9da7b 100644 --- a/crates/auction/prebid-server/src/lib.rs +++ b/crates/auction/prebid-server/src/lib.rs @@ -2847,7 +2847,9 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(backend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new("noop", Arc::new(NoopGeo)), + )) .client_info(ClientInfo { client_ip: None, tls_protocol: None, diff --git a/crates/cmp/didomi/src/lib.rs b/crates/cmp/didomi/src/lib.rs index 8e9d5a0087..3da10f5fbd 100644 --- a/crates/cmp/didomi/src/lib.rs +++ b/crates/cmp/didomi/src/lib.rs @@ -459,9 +459,38 @@ impl DidomiIntegration { && matches!(backend, DidomiBackend::Sdk) && is_notice_loader(&parts.method, consent_path) { - let geo = match services - .geo() - .lookup(services.client_info().client_ip, services) + // The geo module is called as every geo call is, with the + // request as evidence, its services and its client. + let address = services + .client_info() + .client_ip + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let evidence = trusted_server_core::evidence::BorrowedRequestInfo::new( + &address, + Some(&parts.headers), + ) + .with_request_target(parts.uri.path(), parts.uri.query().unwrap_or_default()); + let host = parts + .headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + let scheme = parts.uri.scheme_str().unwrap_or("https"); + let context = trusted_server_core::module_context::ModuleContext::new( + trusted_server_core::module_context::ModuleRequest::new( + &parts.method, + host, + scheme, + parts.uri.path(), + ), + ) + .with_evidence(&evidence) + .with_services(services) + .with_client(services.client_info()); + let module = services.geo(); + let geo = match module + .locate(context.call(module.id(), module.required_permissions())) .await { Ok(Some(geo)) => match normalize_didomi_geo(&geo) { @@ -693,7 +722,12 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(StubGeo(geo_result))) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new( + "stub", + Arc::new(StubGeo(geo_result)), + ), + )) .client_info(ClientInfo { client_ip: Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), ..ClientInfo::default() diff --git a/crates/loading-cache/51degrees/src/tests.rs b/crates/loading-cache/51degrees/src/tests.rs index fb9b1a99c8..9663976db6 100644 --- a/crates/loading-cache/51degrees/src/tests.rs +++ b/crates/loading-cache/51degrees/src/tests.rs @@ -175,7 +175,9 @@ fn services_builder(kv: Arc) -> trusted_server_core::platform::Runtime .kv_store(kv) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new("noop", Arc::new(NoopGeo)), + )) .client_info(ClientInfo::default()) } diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index ae85c75cbb..7552d62b11 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -52,7 +52,8 @@ use trusted_server_core::middleware::{ }; use trusted_server_core::module_context::{ModuleCall, ModuleRequest, Withheld}; use trusted_server_core::platform::{ - GeoInfo, PlatformError, PlatformGeo, PlatformResponse, RuntimeServices, ServerKeyPrefix, + GeoInfo, PlatformError, PlatformGeo, PlatformGeoModule, PlatformResponse, RuntimeServices, + ServerKeyPrefix, }; use trusted_server_core::prefix::{PrefixModule, PublicPrefix}; use trusted_server_core::robots_txt::{Contribution, RobotsTxtContributor}; @@ -633,9 +634,25 @@ impl SeamProbeProxy { req: Request, services: &RuntimeServices, ) -> Result, Report> { - let country = match services - .geo() - .lookup(services.client_info().client_ip, services) + // The geo module is called as every geo call is, with the request + // as evidence, its services and its client. + let address = services + .client_info() + .client_ip + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let evidence = + trusted_server_core::evidence::BorrowedRequestInfo::new(&address, Some(req.headers())) + .with_request_target(req.uri().path(), req.uri().query().unwrap_or_default()); + let context = trusted_server_core::module_context::ModuleContext::new( + trusted_server_core::module_context::ModuleRequest::of(&req, services.client_info()), + ) + .with_evidence(&evidence) + .with_services(services) + .with_client(services.client_info()); + let module = services.geo(); + let country = match module + .locate(context.call(module.id(), module.required_permissions())) .await { Ok(geo) => geo.map(|info| info.country), @@ -738,8 +755,13 @@ pub fn register( })) .with_middleware(Arc::new(SeamProbeReaderMiddleware)); if config.declares_geo { - registration = registration - .with_geo_module(module_name(), Arc::new(SeamProbeGeo::new(config.country))); + registration = registration.with_geo_module( + module_name(), + Arc::new(PlatformGeoModule::new( + module_name(), + Arc::new(SeamProbeGeo::new(config.country)), + )), + ); } { // Identity, device, the prefixes and robots.txt rules are declared @@ -1026,7 +1048,10 @@ mod tests { .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(StubBackend)) .http_client(Arc::new(UnavailableHttpClient)) - .geo(Arc::new(DisabledGeo)) + .geo(Arc::new(PlatformGeoModule::new( + "none", + Arc::new(DisabledGeo), + ))) .client_info(ClientInfo::default()) .build() } diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 6e7e75d20e..cb7aebeddb 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -26,8 +26,8 @@ use trusted_server_core::integrations::{ use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, - PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, - UnavailableHttpClient, UnavailableKvStore, + PlatformConfigStore, PlatformError, PlatformGeoModule, PlatformSecretStore, RuntimeServices, + StoreId, StoreName, UnavailableHttpClient, UnavailableKvStore, }; use trusted_server_core::settings::Settings; use trusted_server_core::tsjs::tsjs_script_src; @@ -1117,7 +1117,10 @@ fn stub_services() -> RuntimeServices { .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(StubBackend)) .http_client(Arc::new(UnavailableHttpClient)) - .geo(Arc::new(DisabledGeo)) + .geo(Arc::new(PlatformGeoModule::new( + "none", + Arc::new(DisabledGeo), + ))) .client_info(ClientInfo::default()) .build() } diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index 0fefe21f6a..8bb5c6cb89 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -3149,7 +3149,12 @@ mod tests { .kv_store(Arc::new(NoopKvStore) as Arc) .backend(Arc::new(FixedBackend)) .http_client(Arc::new(StreamingHttpClient)) - .geo(Arc::new(crate::platform::FastlyPlatformGeo)) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new( + "platform", + Arc::new(crate::platform::FastlyPlatformGeo), + ), + )) .client_info(ClientInfo::default()) .build() } diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 159af4532f..102f333c47 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -635,6 +635,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re settings, &mut response, client_ip, + kept.module_request(), entry_point_finalize_services(settings, &app_state, kept.module_request()), )); } else { @@ -644,6 +645,7 @@ fn edgezero_main(mut req: FastlyRequest, sandbox: &mut Sandbox, ordinal: u64, re &settings, &mut response, client_ip, + kept.module_request(), entry_point_finalize_services(&settings, &app_state, kept.module_request()), )); } @@ -772,21 +774,30 @@ async fn apply_entry_point_finalize_headers( settings: &Settings, response: &mut HttpResponse, client_ip: Option, + request: ModuleRequest<'_>, services: RuntimeServices, ) { // Route through the [geo] module selector, so a deployment that opts // out of geolocation makes no host geo call on the entry-point finalize - // path either. + // path either. The module is called with the request's view and the + // client, as every geo call is; no headers survive to here. let geo = build_geo_module(settings, Arc::new(FastlyPlatformGeo)); let geo_info = if geo_allowed_for_response(response) { - let services = services.with_client_info(ClientInfo { + let client_info = ClientInfo { client_ip, ..ClientInfo::default() - }); - geo.lookup(client_ip, &services).await.unwrap_or_else(|e| { - log::warn!("entry-point geo lookup failed: {e}"); - None - }) + }; + let services = services.with_client_info(client_info.clone()); + let context = ModuleContext::new(request) + .with_settings(settings) + .with_services(&services) + .with_client(&client_info); + geo.locate(context.call(geo.id(), geo.required_permissions())) + .await + .unwrap_or_else(|e| { + log::warn!("entry-point geo lookup failed: {e}"); + None + }) } else { None }; diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index a7c02610a3..e4355badd9 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -17,11 +17,12 @@ use trusted_server_core::constants::{ ENV_FASTLY_IS_STAGING, ENV_FASTLY_SERVICE_VERSION, HEADER_X_GEO_INFO_AVAILABLE, HEADER_X_TS_ENV, HEADER_X_TS_VERSION, }; +use trusted_server_core::evidence::BorrowedRequestInfo; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::{ROUTE_NAMESPACE, strip_deployment_headers}; use trusted_server_core::integrations::IntegrationRegistry; -use trusted_server_core::module_context::ModuleRequest; -use trusted_server_core::platform::{ClientInfo, PlatformGeo, RuntimeServices}; +use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; +use trusted_server_core::platform::{ClientInfo, GeoModule, RuntimeServices}; use trusted_server_core::prefix::Prefixes; use trusted_server_core::settings::Settings; @@ -50,7 +51,7 @@ pub(crate) const HEADER_X_TS_FINALIZED: &str = "x-ts-finalized"; /// 4. Operator-configured `settings.response_headers` (can override any managed header) pub struct FinalizeResponseMiddleware { settings: Arc, - geo: Arc, + geo: Arc, /// The services a geo module is given for the response-side lookup. /// Built once with the application and cloned per request with that /// request's client metadata applied. @@ -65,7 +66,7 @@ impl FinalizeResponseMiddleware { /// geo lookup service, and the services graph handed to the geo module. pub fn new( settings: Arc, - geo: Arc, + geo: Arc, services: RuntimeServices, ) -> Self { Self { @@ -112,6 +113,20 @@ impl Middleware for FinalizeResponseMiddleware { .with_prefixes(Prefixes::trusted_server(&self.settings)), }; + // What the geo module's call carries, kept here because the request + // goes into the handler and does not come back. + let method = ctx.request().method().clone(); + let uri = ctx.request().uri().clone(); + let headers = ctx.request().headers().clone(); + let scheme = uri.scheme_str().unwrap_or("https").to_owned(); + let host = ctx + .request() + .headers() + .get("host") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + let mut response = match next.run(ctx).await { Ok(r) => r, Err(e) => { @@ -121,12 +136,25 @@ impl Middleware for FinalizeResponseMiddleware { }; let geo_info = if geo_allowed_for_response(&response) { - let services = services.clone().with_client_info(ClientInfo { + let client_info = ClientInfo { client_ip, ..ClientInfo::default() - }); + }; + let services = services.clone().with_client_info(client_info.clone()); + // The module is called as the device module is, with the request + // as evidence, so a module whose one service answers location + // and device can answer both from one call. + let address = client_ip.map(|ip| ip.to_string()).unwrap_or_default(); + let evidence = BorrowedRequestInfo::new(&address, Some(&headers)) + .with_request_target(uri.path(), uri.query().unwrap_or_default()); + let context = + ModuleContext::new(ModuleRequest::new(&method, &host, &scheme, uri.path())) + .with_evidence(&evidence) + .with_settings(&self.settings) + .with_services(&services) + .with_client(&client_info); self.geo - .lookup(client_ip, &services) + .locate(context.call(self.geo.id(), self.geo.required_permissions())) .await .unwrap_or_else(|e| { log::warn!("geo lookup failed: {e}"); @@ -624,9 +652,12 @@ mod tests { let lookups = Arc::new(Mutex::new(Vec::new())); let middleware = FinalizeResponseMiddleware::new( Arc::new(settings), - Arc::new(RecordingGeo { - lookups: Arc::clone(&lookups), - }), + Arc::new(trusted_server_core::platform::PlatformGeoModule::new( + "recording", + Arc::new(RecordingGeo { + lookups: Arc::clone(&lookups), + }), + )), test_finalize_services(), ); let mut ctx = empty_ctx(); @@ -661,9 +692,12 @@ mod tests { let lookups = Arc::new(Mutex::new(Vec::new())); let middleware = FinalizeResponseMiddleware::new( Arc::new(settings), - Arc::new(RecordingGeo { - lookups: Arc::clone(&lookups), - }), + Arc::new(trusted_server_core::platform::PlatformGeoModule::new( + "recording", + Arc::new(RecordingGeo { + lookups: Arc::clone(&lookups), + }), + )), test_finalize_services(), ); let mut ctx = empty_ctx(); @@ -697,9 +731,12 @@ mod tests { let lookups = Arc::new(Mutex::new(Vec::new())); let middleware = FinalizeResponseMiddleware::new( Arc::new(settings), - Arc::new(RecordingGeo { - lookups: Arc::clone(&lookups), - }), + Arc::new(trusted_server_core::platform::PlatformGeoModule::new( + "recording", + Arc::new(RecordingGeo { + lookups: Arc::clone(&lookups), + }), + )), test_finalize_services(), ); let mut ctx = empty_ctx(); @@ -728,7 +765,10 @@ mod tests { let settings = settings_with_response_headers(vec![]); let middleware = FinalizeResponseMiddleware::new( Arc::new(settings), - Arc::new(FixedGeo(None)), + Arc::new(trusted_server_core::platform::PlatformGeoModule::new( + "fixed", + Arc::new(FixedGeo(None)), + )), test_finalize_services(), ); let handler = @@ -754,7 +794,10 @@ mod tests { let settings = settings_with_response_headers(vec![]); let middleware = FinalizeResponseMiddleware::new( Arc::new(settings), - Arc::new(FixedGeo(None)), + Arc::new(trusted_server_core::platform::PlatformGeoModule::new( + "fixed", + Arc::new(FixedGeo(None)), + )), test_finalize_services(), ); let handler = @@ -780,7 +823,10 @@ mod tests { let settings = settings_with_response_headers(vec![]); let middleware = FinalizeResponseMiddleware::new( Arc::new(settings), - Arc::new(FixedGeo(None)), + Arc::new(trusted_server_core::platform::PlatformGeoModule::new( + "fixed", + Arc::new(FixedGeo(None)), + )), test_finalize_services(), ); let handler = Arc::new(|_ctx: RequestContext| async move { @@ -818,7 +864,10 @@ mod tests { let settings = settings_with_response_headers(vec![]); let middleware = FinalizeResponseMiddleware::new( Arc::new(settings), - Arc::new(PanicGeo), + Arc::new(trusted_server_core::platform::PlatformGeoModule::new( + "panic", + Arc::new(PanicGeo), + )), test_finalize_services(), ); let handler = Arc::new(|_ctx: RequestContext| async move { @@ -854,7 +903,10 @@ mod tests { let settings = settings_with_response_headers(vec![]); let middleware = FinalizeResponseMiddleware::new( Arc::new(settings), - Arc::new(FixedGeo(None)), + Arc::new(trusted_server_core::platform::PlatformGeoModule::new( + "fixed", + Arc::new(FixedGeo(None)), + )), test_finalize_services(), ); let handler = Arc::new(|_ctx: RequestContext| async move { @@ -901,7 +953,10 @@ mod tests { fn finalized(path: &str, settings: Settings) -> Response { let middleware = FinalizeResponseMiddleware::new( Arc::new(settings), - Arc::new(FixedGeo(None)), + Arc::new(trusted_server_core::platform::PlatformGeoModule::new( + "fixed", + Arc::new(FixedGeo(None)), + )), test_finalize_services(), ); let handler = Arc::new(|_ctx: RequestContext| async move { diff --git a/crates/trusted-server-adapter-fastly/src/tinybird.rs b/crates/trusted-server-adapter-fastly/src/tinybird.rs index 31363181cb..dc983089f3 100644 --- a/crates/trusted-server-adapter-fastly/src/tinybird.rs +++ b/crates/trusted-server-adapter-fastly/src/tinybird.rs @@ -416,7 +416,9 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(backend) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new( + trusted_server_core::platform::PlatformGeoModule::new("noop", Arc::new(NoopGeo)), + )) .client_info(ClientInfo::default()) .build() } diff --git a/crates/trusted-server-adapter-spin/src/platform.rs b/crates/trusted-server-adapter-spin/src/platform.rs index b4914202af..0d43b37d5a 100644 --- a/crates/trusted-server-adapter-spin/src/platform.rs +++ b/crates/trusted-server-adapter-spin/src/platform.rs @@ -1088,15 +1088,7 @@ mod tests { services.client_info().client_ip.is_none(), "should not set client IP without Spin request context" ); - assert!( - services - .geo() - .lookup(None, &test_services()) - .await - .expect("should not fail") - .is_none(), - "should use null geo" - ); + assert!(services.geo().id() == "none", "should use null geo"); assert!( services .config_store() diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index 97c2b1ea1a..6c6f148782 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -327,10 +327,28 @@ pub async fn handle_auction( } let eids = resolve_auction_eids(&auction_kv_snapshot, registry, ec_context); - // Look up geo for device info. - let geo = services - .geo() - .lookup(services.client_info().client_ip, services) + // Look up geo for device info, through the module call a page's request + // makes, so the geo module sees the request as it does on a page. + let address = services + .client_info() + .client_ip + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let evidence = crate::evidence::BorrowedRequestInfo::new(&address, Some(http_req.headers())) + .with_request_target( + http_req.uri().path(), + http_req.uri().query().unwrap_or_default(), + ); + let geo_context = crate::module_context::ModuleContext::new( + crate::module_context::ModuleRequest::of(&http_req, services.client_info()), + ) + .with_evidence(&evidence) + .with_settings(settings) + .with_services(services) + .with_client(services.client_info()); + let geo_module = services.geo(); + let geo = geo_module + .locate(geo_context.call(geo_module.id(), geo_module.required_permissions())) .await .unwrap_or_else(|e| { log::warn!("geo lookup failed: {e}"); @@ -687,7 +705,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .auction_telemetry_sink(telemetry_sink) .client_info(ClientInfo::default()) .build() @@ -1028,7 +1049,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::clone(&stub) as Arc) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build(); let consent = ConsentContext { diff --git a/crates/trusted-server-core/src/auction/orchestrator.rs b/crates/trusted-server-core/src/auction/orchestrator.rs index 77a0266749..a8e0c9ff38 100644 --- a/crates/trusted-server-core/src/auction/orchestrator.rs +++ b/crates/trusted-server-core/src/auction/orchestrator.rs @@ -6803,7 +6803,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) @@ -6869,7 +6872,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) @@ -6929,7 +6935,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) @@ -7017,7 +7026,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) @@ -7072,7 +7084,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::clone(&backend) as Arc<_>) .http_client(Arc::clone(&http) as Arc<_>) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .auction_telemetry_sink(Arc::new( crate::auction::telemetry::NoopAuctionTelemetrySink, )) diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 4664e42b10..d28cc062bd 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -385,9 +385,25 @@ impl EcContext { req: &Request, services: &RuntimeServices, ) -> Result> { - let lookup = services - .geo() - .lookup(services.client_info().client_ip, services) + // The geo module is called as the device module is, with the + // request's evidence, its settings and its services, so a module + // that answers location and device from one service can answer both + // from one call. + let address = services + .client_info() + .client_ip + .map(|ip| ip.to_string()) + .unwrap_or_default(); + let evidence = crate::evidence::BorrowedRequestInfo::new(&address, Some(req.headers())) + .with_request_target(req.uri().path(), req.uri().query().unwrap_or_default()); + let context = ModuleContext::new(ModuleRequest::of(req, services.client_info())) + .with_evidence(&evidence) + .with_settings(settings) + .with_services(services) + .with_client(services.client_info()); + let geo = services.geo(); + let lookup = geo + .locate(context.call(geo.id(), geo.required_permissions())) .await; let geo_info = match &lookup { Ok(info) => info.clone(), @@ -1907,7 +1923,10 @@ pub(crate) mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(crate::platform::test_support::NoopBackend)) .http_client(Arc::new(crate::platform::test_support::NoopHttpClient)) - .geo(Arc::new(ConfigReadingGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "config-reading", + Arc::new(ConfigReadingGeo), + ))) .client_info(crate::platform::ClientInfo::default()) .build(); @@ -1922,6 +1941,62 @@ pub(crate) mod tests { ); } + /// A geo module that answers from the request's User-Agent, which only a + /// module called with the request's evidence can read, the way a device + /// module is called. + #[derive(Debug)] + struct UserAgentReadingGeo; + + #[async_trait::async_trait(?Send)] + impl crate::platform::GeoModule for UserAgentReadingGeo { + fn id(&self) -> &'static str { + "testing.user-agent-geo" + } + + async fn locate( + &self, + call: crate::module_context::ModuleCall<'_>, + ) -> Result, Report> { + use crate::module_context::FromModuleContext; + + let evidence = <&dyn crate::evidence::RequestInfo>::from_module_context(&call) + .map_err(|_| Report::new(crate::platform::PlatformError::Geo))?; + let country = match evidence.header("user-agent") { + Some(agent) if agent.contains("Kiosk/1.0") => "FR", + _ => "ZZ", + }; + Ok(Some(GeoInfo { + country: country.to_owned(), + ..non_regulated_geo() + })) + } + } + + #[tokio::test] + async fn a_geo_module_is_called_with_the_request_as_evidence() { + let settings = create_test_settings(); + let req = create_test_request(&[("user-agent", "Mozilla/5.0 Kiosk/1.0")]); + let services = RuntimeServices::builder() + .config_store(Arc::new(crate::platform::test_support::NoopConfigStore)) + .secret_store(Arc::new(crate::platform::test_support::NoopSecretStore)) + .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) + .backend(Arc::new(crate::platform::test_support::NoopBackend)) + .http_client(Arc::new(crate::platform::test_support::NoopHttpClient)) + .geo(Arc::new(UserAgentReadingGeo)) + .client_info(crate::platform::ClientInfo::default()) + .build(); + + let ec = EcContext::read_from_request_resolving_geo(&settings, &req, &services) + .await + .expect("should read EC context"); + + assert_eq!( + ec.geo_info().map(|info| info.country.as_str()), + Some("FR"), + "the country can only be FR if the module read the User-Agent from the call's evidence" + ); + } + /// A module that derives its identifier from a value it reads out of the /// config store at generate time, which is the whole point of handing modules /// the platform services. Nothing about the value is known when the diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index c57ed9e6df..f96b0b6359 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -20,7 +20,7 @@ use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; use crate::middleware::{Middleware, MiddlewareChain, MiddlewarePhase, PhaseEntries}; use crate::module_context::{ModuleCall, ModuleContext, ModuleRequest, ResolvedRequest}; -use crate::platform::{DisabledGeo, PlatformGeo, RuntimeServices}; +use crate::platform::{DisabledGeo, GeoModule, PlatformGeoModule, RuntimeServices}; use crate::prefix::{PrefixModule, Prefixes, SelectedPrefixModule}; use crate::robots_txt::{ALLOW_ALL, REFUSE_ALL, RobotsTxtContributor}; use crate::settings::Settings; @@ -653,7 +653,7 @@ pub struct IntegrationRegistration { /// /// Declaring one does not make it active, because the module is only asked /// to resolve location when `[geo] module` names it. - pub geo_module: Option<(&'static str, Arc)>, + pub geo_module: Option<(&'static str, Arc)>, /// Edge Cookie module this registration supplies, with the name /// `[ec] module` selects it by. /// @@ -762,7 +762,7 @@ impl IntegrationRegistrationBuilder { /// `[geo] module` names it, and a module the selector does not choose is /// logged as a warning when the registry is built. #[must_use] - pub fn with_geo_module(mut self, name: &'static str, module: Arc) -> Self { + pub fn with_geo_module(mut self, name: &'static str, module: Arc) -> Self { self.registration.geo_module = Some((name, module)); self } @@ -912,7 +912,7 @@ struct IntegrationRegistryInner { // Geo modules the running registrations supply, each with the name // `[geo] module` selects it by, in registration order. Declaring one does // not activate it. - geo_modules: Vec<(&'static str, Arc)>, + geo_modules: Vec<(&'static str, Arc)>, // Edge Cookie modules the running registrations supply, each with its // name, in registration order. `[ec] module` picks at most one of them. ec_modules: Vec<(&'static str, Arc)>, @@ -931,7 +931,7 @@ struct IntegrationRegistryInner { // The module `[geo] module` resolved to, or `None` when the selector // is `platform` and the adapter's own host lookup stands. Unset and `none` // both resolve the disabled module. - geo_module: Option>, + geo_module: Option>, ec_module: Option>, device_module: Option>, // The prefix modules the running registrations supply, each with its @@ -1369,13 +1369,16 @@ fn check_robots_txt_selection( fn resolve_geo_module( settings: &Settings, inner: &IntegrationRegistryInner, -) -> Result>, Report> { +) -> Result>, Report> { let selector = settings.geo.module.as_deref(); let resolved = match selector { // Unset resolves nothing and makes no host geo call, so a default // deployment is not tied to any host geo service. `none` spells the // same choice explicitly. - None | Some(GEO_MODULE_NONE) => Some(Arc::new(DisabledGeo) as Arc), + None | Some(GEO_MODULE_NONE) => Some(Arc::new(PlatformGeoModule::new( + GEO_MODULE_NONE, + Arc::new(DisabledGeo), + )) as Arc), // `platform` opts in to the adapter's own host lookup, so the registry // supplies nothing and the adapter's module stands. Some(GEO_MODULE_PLATFORM) => None, @@ -2008,7 +2011,7 @@ impl IntegrationRegistry { /// is `platform` and the adapter's own host lookup stands. Unset and `none` /// both resolve the disabled module. #[must_use] - pub fn geo_module(&self) -> Option> { + pub fn geo_module(&self) -> Option> { self.inner.geo_module.clone() } @@ -5794,12 +5797,34 @@ mod tests { const GEO_PROBE_MODULE: &str = "testing.geo-probe"; /// Builds a `geo-probe` registration that declares a geo module. + /// Calls a geo module as a request from no address does: with the + /// services and no client, so a platform lookup is asked about nothing. + async fn locate_without_a_client( + module: &dyn GeoModule, + ) -> Result, Report> { + let services = noop_services(); + let method = http::Method::GET; + let context = crate::module_context::ModuleContext::new( + crate::module_context::ModuleRequest::new(&method, "publisher.example", "https", "/"), + ) + .with_services(&services); + module + .locate(context.call(module.id(), module.required_permissions())) + .await + } + fn geo_probe_registration( _settings: &Settings, ) -> Result, Report> { Ok(Some( IntegrationRegistration::builder("geo-probe") - .with_geo_module(GEO_PROBE_MODULE, Arc::new(FixedCountryGeo)) + .with_geo_module( + GEO_PROBE_MODULE, + Arc::new(PlatformGeoModule::new( + GEO_PROBE_MODULE, + Arc::new(FixedCountryGeo), + )), + ) .build(), )) } @@ -6109,8 +6134,7 @@ mod tests { let module = registry .geo_module() .expect("should resolve the module's geo module"); - let resolved = module - .lookup(None, &noop_services()) + let resolved = locate_without_a_client(&*module) .await .expect("should look up without failing") .expect("should resolve a location"); @@ -6131,8 +6155,7 @@ mod tests { .geo_module() .expect("should resolve the disabled geo module"); assert!( - module - .lookup(None, &noop_services()) + locate_without_a_client(&*module) .await .expect("should look up without failing") .is_none(), @@ -6159,8 +6182,7 @@ mod tests { .geo_module() .expect("an unset selector should resolve the disabled module, not nothing"); assert!( - module - .lookup(None, &noop_services()) + locate_without_a_client(&*module) .await .expect("should look up without failing") .is_none(), @@ -6376,7 +6398,13 @@ mod tests { ) -> Result, Report> { Ok(Some( IntegrationRegistration::builder("example-vendor") - .with_geo_module("geo.example", Arc::new(FixedCountryGeo)) + .with_geo_module( + "geo.example", + Arc::new(PlatformGeoModule::new( + "geo.example", + Arc::new(FixedCountryGeo), + )), + ) .with_device_module("device.example", Arc::new(ExampleDevice)) .with_ec_module("edgecookie.example", Arc::new(ExampleEc)) .build(), @@ -6549,7 +6577,13 @@ mod tests { ) -> Result, Report> { Ok(Some( IntegrationRegistration::builder("geo-second") - .with_geo_module(GEO_PROBE_MODULE, Arc::new(FixedCountryGeo)) + .with_geo_module( + GEO_PROBE_MODULE, + Arc::new(PlatformGeoModule::new( + GEO_PROBE_MODULE, + Arc::new(FixedCountryGeo), + )), + ) .build(), )) } diff --git a/crates/trusted-server-core/src/platform/geo.rs b/crates/trusted-server-core/src/platform/geo.rs index c8077215dd..06ea7d095e 100644 --- a/crates/trusted-server-core/src/platform/geo.rs +++ b/crates/trusted-server-core/src/platform/geo.rs @@ -1,8 +1,11 @@ use std::net::IpAddr; +use std::sync::Arc; use error_stack::Report; -use super::{GeoInfo, PlatformError, PlatformGeo, RuntimeServices}; +use super::{ClientInfo, GeoInfo, GeoModule, PlatformError, PlatformGeo, RuntimeServices}; +use crate::module_context::{FromModuleContext, ModuleCall}; +use crate::permissions::PermissionSet; /// A geo module that resolves nothing. /// @@ -28,3 +31,132 @@ impl PlatformGeo for DisabledGeo { Ok(None) } } + +/// A [`PlatformGeo`] called as a [`GeoModule`]: the client address comes from +/// the call's client metadata and the services from the call, and the +/// platform lookup answers. A call carrying no services answers nothing. +pub struct PlatformGeoModule { + name: &'static str, + platform: Arc, +} + +impl PlatformGeoModule { + /// Calls `platform` as the module named `name`. + #[must_use] + pub fn new(name: &'static str, platform: Arc) -> Self { + Self { name, platform } + } +} + +impl std::fmt::Debug for PlatformGeoModule { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlatformGeoModule") + .field("name", &self.name) + .finish_non_exhaustive() + } +} + +#[async_trait::async_trait(?Send)] +impl GeoModule for PlatformGeoModule { + fn id(&self) -> &'static str { + self.name + } + + async fn locate(&self, call: ModuleCall<'_>) -> Result, Report> { + let Ok(services) = <&RuntimeServices>::from_module_context(&call) else { + return Ok(None); + }; + let client_ip = <&ClientInfo>::from_module_context(&call) + .ok() + .and_then(|client| client.client_ip); + self.platform.lookup(client_ip, services).await + } + + fn required_permissions(&self) -> PermissionSet { + self.platform.required_permissions() + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr}; + use std::sync::Mutex; + + use http::Method; + + use super::*; + use crate::module_context::{ModuleContext, ModuleRequest}; + use crate::permissions::PermissionSet; + use crate::platform::test_support::noop_services; + + /// A platform lookup that records the address it was asked about. + #[derive(Default)] + struct Recording { + asked: Mutex>>, + } + + #[async_trait::async_trait(?Send)] + impl PlatformGeo for Recording { + async fn lookup( + &self, + client_ip: Option, + _services: &RuntimeServices, + ) -> Result, Report> { + self.asked.lock().expect("lock").push(client_ip); + Ok(None) + } + } + + #[tokio::test] + async fn the_platform_lookup_is_given_the_address_the_call_carries() { + let recording = Arc::new(Recording::default()); + let module = + PlatformGeoModule::new("platform", Arc::clone(&recording) as Arc); + let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)); + let client = ClientInfo { + client_ip: Some(address), + ..ClientInfo::default() + }; + let services = noop_services().with_client_info(client.clone()); + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new( + &method, + "publisher.example", + "https", + "/", + )) + .with_services(&services) + .with_client(&client); + + let located = module + .locate(context.call(module.id(), PermissionSet::none())) + .await + .expect("the platform answers"); + + assert!(located.is_none(), "the recording platform answers nothing"); + assert_eq!(*recording.asked.lock().expect("lock"), vec![Some(address)]); + assert_eq!(module.id(), "platform"); + } + + #[tokio::test] + async fn a_call_without_services_asks_the_platform_nothing() { + let recording = Arc::new(Recording::default()); + let module = + PlatformGeoModule::new("platform", Arc::clone(&recording) as Arc); + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new( + &method, + "publisher.example", + "https", + "/", + )); + + let located = module + .locate(context.call(module.id(), PermissionSet::none())) + .await + .expect("no services is not an error"); + + assert!(located.is_none()); + assert!(recording.asked.lock().expect("lock").is_empty()); + } +} diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 36231dfc6c..db4640b4ee 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -65,7 +65,7 @@ pub use backend_naming::{ }; pub use edgezero_core::key_value_store::{KvError, KvHandle, KvStore as PlatformKvStore}; pub use error::PlatformError; -pub use geo::DisabledGeo; +pub use geo::{DisabledGeo, PlatformGeoModule}; pub use http::{ PlatformCacheIntent, PlatformHttpClient, PlatformHttpRequest, PlatformPendingRequest, PlatformResponse, PlatformSelectResult, UnavailableHttpClient, @@ -91,7 +91,9 @@ pub use template_cache::{ TemplateCookieValue, TemplateEntry, TemplateMetadata, TemplateMetadataEncodeError, UnavailableTemplateCache, VaryHeaderValues, VarySpec, reader_url_surrogate_key, }; -pub use traits::{PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore}; +pub use traits::{ + GeoModule, PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformSecretStore, +}; pub use types::{ ClientInfo, GeoInfo, PlatformBackendSpec, RuntimeServices, RuntimeServicesBuilder, StoreId, StoreName, @@ -125,10 +127,10 @@ use crate::settings::Settings; pub fn build_geo_module( settings: &Settings, host_default: Arc, -) -> Arc { +) -> Arc { match settings.geo.module.as_deref() { - Some("platform") => host_default, - _ => Arc::new(DisabledGeo), + Some("platform") => Arc::new(PlatformGeoModule::new("platform", host_default)), + _ => Arc::new(PlatformGeoModule::new("none", Arc::new(DisabledGeo))), } } @@ -225,9 +227,15 @@ mod tests { #[tokio::test] async fn runtime_services_geo_lookup_returns_none_for_no_ip() { let services = noop_services(); - let result = services - .geo() - .lookup(services.client_info().client_ip, &noop_services()) + let method = ::http::Method::GET; + let context = crate::module_context::ModuleContext::new( + crate::module_context::ModuleRequest::new(&method, "publisher.example", "https", "/"), + ) + .with_services(&services) + .with_client(services.client_info()); + let module = services.geo(); + let result = module + .locate(context.call(module.id(), module.required_permissions())) .await .expect("should not fail for noop geo with no ip"); assert!(result.is_none(), "should return None when no IP is present"); @@ -238,16 +246,25 @@ mod tests { let settings = Settings::default(); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_module(&settings, Arc::clone(&host)); - assert!( - !Arc::ptr_eq(&host, &selected), + assert_eq!( + selected.id(), + "none", "default settings should not use the host geo" ); + let client = ClientInfo { + client_ip: Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), + ..ClientInfo::default() + }; + let services = noop_services().with_client_info(client.clone()); + let method = ::http::Method::GET; + let context = crate::module_context::ModuleContext::new( + crate::module_context::ModuleRequest::new(&method, "publisher.example", "https", "/"), + ) + .with_services(&services) + .with_client(&client); assert!( selected - .lookup( - Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7))), - &noop_services() - ) + .locate(context.call(selected.id(), selected.required_permissions())) .await .expect("disabled geo lookup should not fail") .is_none(), @@ -261,8 +278,9 @@ mod tests { settings.geo.module = Some("none".to_owned()); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_module(&settings, Arc::clone(&host)); - assert!( - !Arc::ptr_eq(&host, &selected), + assert_eq!( + selected.id(), + "none", "module none should not use the host geo" ); } @@ -273,8 +291,9 @@ mod tests { settings.geo.module = Some("platform".to_owned()); let host: Arc = Arc::new(test_support::NoopGeo); let selected = build_geo_module(&settings, Arc::clone(&host)); - assert!( - Arc::ptr_eq(&host, &selected), + assert_eq!( + selected.id(), + "platform", "the platform selector should use the host geo" ); } diff --git a/crates/trusted-server-core/src/platform/test_support.rs b/crates/trusted-server-core/src/platform/test_support.rs index ee2ba8b1d7..82518a290c 100644 --- a/crates/trusted-server-core/src/platform/test_support.rs +++ b/crates/trusted-server-core/src/platform/test_support.rs @@ -958,7 +958,10 @@ pub fn build_services_with_config_and_secret( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build() } @@ -974,7 +977,10 @@ pub fn build_services_with_config_and_secret_and_client_ip( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: Some(client_ip), ..ClientInfo::default() @@ -1011,7 +1017,10 @@ pub fn build_services_with_config( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build() } @@ -1027,6 +1036,7 @@ pub fn noop_services() -> RuntimeServices { /// This is the only way to reach the lookup-failure path, because the seam is /// what turns an `Err` into the requires-signal floor. pub fn build_services_with_geo(geo: Arc) -> RuntimeServices { + let geo: Arc = Arc::new(super::PlatformGeoModule::new("test", geo)); RuntimeServices::builder() .config_store(Arc::new(NoopConfigStore)) .secret_store(Arc::new(NoopSecretStore)) @@ -1053,7 +1063,10 @@ pub fn noop_services_with_device_module( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build() .with_device_module(device_module) @@ -1125,7 +1138,10 @@ pub fn services_with_ec_module_and_config_store( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: Some("203.0.113.10".parse().expect("should parse test client IP")), ..ClientInfo::default() @@ -1150,7 +1166,10 @@ fn noop_services_with_ec_module_and_ip( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip, ..ClientInfo::default() @@ -1171,7 +1190,10 @@ pub fn noop_services_with_telemetry_sink( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .auction_telemetry_sink(auction_telemetry_sink) .client_info(ClientInfo::default()) .build() @@ -1201,7 +1223,10 @@ pub fn build_services_with_http_client_and_client_ip( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: Some(client_ip), ..ClientInfo::default() @@ -1216,7 +1241,10 @@ pub fn noop_services_with_client_ip(ip: IpAddr) -> RuntimeServices { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(NoopBackend)) .http_client(Arc::new(NoopHttpClient)) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: Some(ip), ..ClientInfo::default() @@ -1245,7 +1273,10 @@ pub fn build_services_with_backend_and_http_client( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(backend) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip: None, tls_protocol: None, @@ -1267,7 +1298,10 @@ pub fn build_services_with_config_secret_and_http_client( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .build() } @@ -1283,7 +1317,10 @@ pub fn build_services_with_secret_http_client_and_client_ip( .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(super::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo { client_ip, tls_protocol: None, diff --git a/crates/trusted-server-core/src/platform/traits.rs b/crates/trusted-server-core/src/platform/traits.rs index 47d7d9cf57..2ba160cd35 100644 --- a/crates/trusted-server-core/src/platform/traits.rs +++ b/crates/trusted-server-core/src/platform/traits.rs @@ -180,3 +180,35 @@ pub trait PlatformGeo: Send + Sync { crate::permissions::PermissionSet::none() } } + +/// A geo module as core calls it, with the request's module call, which +/// carries the request's evidence, the settings and the services as a device +/// module's call does, so a module whose one service answers location and +/// device from the same request can answer both from one call. A type that +/// resolves from the client address alone implements [`PlatformGeo`] and is +/// called through [`PlatformGeoModule`](super::PlatformGeoModule). +/// +/// Uses `#[async_trait(?Send)]` for the same reason as [`PlatformGeo`]. +#[async_trait::async_trait(?Send)] +pub trait GeoModule: Send + Sync { + /// The name the module is called as, for the call and the logs. + fn id(&self) -> &'static str; + + /// Resolves the request's location, `None` when the module has no data + /// for it, with the country as an ISO 3166-1 alpha-2 code and the region + /// as the ISO 3166-2 subdivision code without the country prefix, as + /// [`PlatformGeo::lookup`] gives them. + /// + /// # Errors + /// + /// Returns [`PlatformError::Geo`] when the lookup fails unexpectedly. + async fn locate( + &self, + call: crate::module_context::ModuleCall<'_>, + ) -> Result, Report>; + + /// The permissions this module's data use requires. Empty by default. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } +} diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 8b2102fa0f..9aa8007876 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -6,7 +6,7 @@ use std::time::Duration; use crate::auction::telemetry::{AuctionTelemetrySink, NoopAuctionTelemetrySink}; use super::{ - PlatformBackend, PlatformConfigStore, PlatformGeo, PlatformHttpClient, PlatformKvStore, + GeoModule, PlatformBackend, PlatformConfigStore, PlatformHttpClient, PlatformKvStore, PlatformSecretStore, }; use crate::ec::device::DeviceModule; @@ -198,7 +198,7 @@ pub struct RuntimeServices { /// Outbound HTTP client abstraction. pub(crate) http_client: Arc, /// Geographic information lookup. - pub(crate) geo: Arc, + pub(crate) geo: Arc, /// Auction telemetry sink. pub(crate) auction_telemetry_sink: Arc, /// Per-request client metadata extracted at the entry point. @@ -326,9 +326,9 @@ impl RuntimeServices { &*self.http_client } - /// Returns the platform geo lookup service. + /// Returns the geo module the request is located by. #[must_use] - pub fn geo(&self) -> &dyn PlatformGeo { + pub fn geo(&self) -> &dyn GeoModule { &*self.geo } @@ -451,7 +451,7 @@ impl RuntimeServices { /// the host's own lookup without the rest of the runtime services graph /// being rebuilt. #[must_use] - pub fn with_geo(self, geo: Arc) -> Self { + pub fn with_geo(self, geo: Arc) -> Self { Self { geo, ..self } } @@ -570,7 +570,7 @@ pub struct RuntimeServicesBuilder { template_assembler: Option>, backend: Option>, http_client: Option>, - geo: Option>, + geo: Option>, auction_telemetry_sink: Option>, client_info: Option, host_signals: Option>, @@ -671,7 +671,7 @@ impl RuntimeServicesBuilder { /// Set the geo lookup implementation. #[must_use] - pub fn geo(mut self, geo: Arc) -> Self { + pub fn geo(mut self, geo: Arc) -> Self { self.geo = Some(geo); self } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 1d5d4a40c0..bdd66636c9 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -11239,7 +11239,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .template_cache(cache) .build() @@ -11271,7 +11274,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .client_info(ClientInfo::default()) .template_cache(cache) .auction_telemetry_sink(telemetry_sink) @@ -14317,7 +14323,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(Arc::clone(&stub) as Arc) - .geo(Arc::new(StubGeo(user.geo_marker))) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "stub", + Arc::new(StubGeo(user.geo_marker)), + ))) .client_info(ClientInfo::default()) .template_cache( Arc::clone(&cache) as Arc @@ -18076,7 +18085,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .auction_telemetry_sink(telemetry_sink) .client_info(ClientInfo::default()) .build() @@ -27945,7 +27957,10 @@ mod tests { .kv_store(Arc::new(edgezero_core::key_value_store::NoopKvStore)) .backend(Arc::new(StubBackend)) .http_client(http_client) - .geo(Arc::new(NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(NoopGeo), + ))) .auction_telemetry_sink(telemetry_sink) .client_info(ClientInfo::default()) .build() diff --git a/crates/trusted-server-core/src/robots_txt/holding.rs b/crates/trusted-server-core/src/robots_txt/holding.rs index f0ffeddcbf..bffeb2b84a 100644 --- a/crates/trusted-server-core/src/robots_txt/holding.rs +++ b/crates/trusted-server-core/src/robots_txt/holding.rs @@ -892,7 +892,10 @@ pub(crate) mod tests { .kv_store(Arc::new(crate::platform::UnavailableKvStore)) .backend(Arc::new(crate::platform::test_support::NoopBackend)) .http_client(Arc::new(StubHttpClient::new())) - .geo(Arc::new(crate::platform::test_support::NoopGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "noop", + Arc::new(crate::platform::test_support::NoopGeo), + ))) .value_cache(Arc::clone(&value_cache) as Arc) .loading_caches(Arc::new(crate::platform::LoadingCaches::every(Arc::new( MemoryLoadingCache::default(), diff --git a/crates/trusted-server-core/src/test_support.rs b/crates/trusted-server-core/src/test_support.rs index 74546c6c12..388fcecb31 100644 --- a/crates/trusted-server-core/src/test_support.rs +++ b/crates/trusted-server-core/src/test_support.rs @@ -538,7 +538,10 @@ pub mod nextjs_auction { .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(FixtureStore)) .http_client(client) - .geo(Arc::new(FixtureGeo)) + .geo(Arc::new(crate::platform::PlatformGeoModule::new( + "fixture", + Arc::new(FixtureGeo), + ))) .client_info(ClientInfo::default()) .build() } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e078b0e3c4..eb893c88a6 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -881,7 +881,7 @@ TRUSTED_SERVER__DEVICE__MODULE=builtin ## Geo Configuration -Selects how a client IP is resolved into geolocation (country, region, coordinates), mirroring the Edge Cookie module selection. The resolved country also feeds the [permission model](/guide/permission-model). +Selects the module that resolves the request into geolocation (country, region, coordinates), mirroring the Edge Cookie module selection. The module is called with the request as a device module is, so a module may resolve from more than the client address. The resolved country also feeds the [permission model](/guide/permission-model). ### `[geo]` diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index 82c485f2d3..9ca9a49788 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -45,7 +45,7 @@ use trusted_server_core::middleware::{ }; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, GeoInfo, PlatformBackend, - PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, + PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformGeo, PlatformGeoModule, PlatformSecretStore, RuntimeServices, StoreId, StoreName, UnavailableHttpClient, UnavailableKvStore, }; @@ -185,7 +185,7 @@ pub fn runtime_services() -> RuntimeServices { .kv_store(Arc::new(UnavailableKvStore)) .backend(Arc::new(FixtureBackend)) .http_client(Arc::new(UnavailableHttpClient)) - .geo(Arc::new(FixtureGeo)) + .geo(Arc::new(PlatformGeoModule::new("fixture", Arc::new(FixtureGeo)))) .client_info(ClientInfo::default()) .build() } @@ -402,26 +402,34 @@ this repository that leaves the declaration out. The build function returns an `IntegrationRegistration`, built with the builder every integration uses. -| Declaration | What it does | -| ----------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| `.with_proxy(...)` | Routes the paths the proxy declares | -| `.with_request_filter(...)` | Inspects a request and can turn it back before it reaches the origin | -| `.with_middleware(...)` | Offers a page change that an entry may place by the middleware's name | -| `.with_bundle_tag_attribute(name, value)` | Puts an attribute on the script bundle's tag, for a browser module that reads a setting from it | -| `.with_js_module(CarriedJsModule { source, sha256 })` | Carries the integration's own browser script, built outside `trusted-server-js` | -| `.with_deferred_js()` | Serves the script as its own `