diff --git a/crates/ad-tag/google/src/lib.rs b/crates/ad-tag/google/src/lib.rs index 3472280ea9..9d29c6b780 100644 --- a/crates/ad-tag/google/src/lib.rs +++ b/crates/ad-tag/google/src/lib.rs @@ -440,25 +440,12 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for GptIntegration { - fn integration_name(&self) -> &'static str { - GPT_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![ - self.get("/script"), - self.get("/pagead/*"), - self.get("/tag/*"), - ] - } - - async fn handle( +impl GptIntegration { + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let path = req.uri().path().to_string(); @@ -477,6 +464,29 @@ impl IntegrationProxy for GptIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for GptIntegration { + fn integration_name(&self) -> &'static str { + GPT_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![ + self.get("/script"), + self.get("/pagead/*"), + self.get("/tag/*"), + ] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for GptIntegration { fn integration_id(&self) -> &'static str { GPT_INTEGRATION_ID diff --git a/crates/auction/aps/src/lib.rs b/crates/auction/aps/src/lib.rs index 24c44c8bdc..7d8692d2ff 100644 --- a/crates/auction/aps/src/lib.rs +++ b/crates/auction/aps/src/lib.rs @@ -58,9 +58,9 @@ use trusted_server_core::openrtb::{ Banner, Device, Format, Geo, Imp, OpenRtbRequest, Publisher, Regs, RegsExt, Site, User, UserExt, to_openrtb_i32, }; +use trusted_server_core::platform::PlatformResponse; #[cfg(test)] -use trusted_server_core::platform::PlatformHttpRequest; -use trusted_server_core::platform::{PlatformResponse, RuntimeServices}; +use trusted_server_core::platform::{PlatformHttpRequest, RuntimeServices}; use trusted_server_core::settings::Settings; pub(crate) const APS_INTEGRATION_ID: &str = "aps"; @@ -2048,10 +2048,11 @@ impl IntegrationProxy for ApsRendererIntegration { .collect() } + // The renderer page is the same for every request, so the route takes + // nothing from its module call. async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + _call: trusted_server_core::module_context::ModuleCall<'_>, request: http::Request, ) -> Result, Report> { if request.method() != Method::GET || request.uri().path() != APS_RENDERER_ROUTE { @@ -3206,16 +3207,16 @@ mod tests { assert_eq!(routes[0].method, Method::GET); assert_eq!(routes[0].path, APS_RENDERER_ROUTE); - let settings = create_test_settings(); - let services = noop_services(); let request = http::Request::builder() .method(Method::GET) .uri(APS_RENDERER_ROUTE) .body(EdgeBody::empty()) .expect("should build renderer request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should serve renderer"); + let response = futures::executor::block_on(integration.handle( + trusted_server_core::module_context::ModuleCall::empty(), + request, + )) + .expect("should serve renderer"); assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers()[header::CONTENT_TYPE], @@ -3233,8 +3234,11 @@ mod tests { .uri(APS_RENDERER_ROUTE) .body(EdgeBody::empty()) .expect("should build method rejection request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, post)) - .expect("should reject unsupported method"); + let response = futures::executor::block_on(integration.handle( + trusted_server_core::module_context::ModuleCall::empty(), + post, + )) + .expect("should reject unsupported method"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } diff --git a/crates/auction/prebid/src/lib.rs b/crates/auction/prebid/src/lib.rs index 4e3fab11f2..40489e459f 100644 --- a/crates/auction/prebid/src/lib.rs +++ b/crates/auction/prebid/src/lib.rs @@ -1126,6 +1126,33 @@ pub fn register( )) } +impl PrebidIntegration { + async fn route( + &self, + req: http::Request, + settings: &Settings, + services: &RuntimeServices, + ) -> Result, Report> { + let path = req.uri().path().to_string(); + let method = req.method().clone(); + + match method { + Method::GET if self.is_managed_external() && path == PREBID_BUNDLE_ROUTE => { + self.handle_external_bundle(settings, services, req).await + } + // Serve empty JS for matching script patterns + Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), + _ => http::Response::builder() + .status(StatusCode::NOT_FOUND) + .body(EdgeBody::from("Not Found")) + .change_context(TrustedServerError::Integration { + integration: PREBID_INTEGRATION_ID.to_string(), + message: "Failed to build Prebid not found response".to_string(), + }), + } + } +} + #[async_trait(?Send)] impl IntegrationProxy for PrebidIntegration { fn integration_name(&self) -> &'static str { @@ -1152,27 +1179,10 @@ impl IntegrationProxy for PrebidIntegration { async fn handle( &self, - settings: &Settings, - services: &RuntimeServices, + call: trusted_server_core::module_context::ModuleCall<'_>, req: http::Request, ) -> Result, Report> { - let path = req.uri().path().to_string(); - let method = req.method().clone(); - - match method { - Method::GET if self.is_managed_external() && path == PREBID_BUNDLE_ROUTE => { - self.handle_external_bundle(settings, services, req).await - } - // Serve empty JS for matching script patterns - Method::GET if self.matches_script_pattern(&path) => self.handle_script_handler(), - _ => http::Response::builder() - .status(StatusCode::NOT_FOUND) - .body(EdgeBody::from("Not Found")) - .change_context(TrustedServerError::Integration { - integration: PREBID_INTEGRATION_ID.to_string(), - message: "Failed to build Prebid not found response".to_string(), - }), - } + call.inject_with(self, req, Self::route)?.await } } diff --git a/crates/audience/permutive/src/lib.rs b/crates/audience/permutive/src/lib.rs index 081f5fed8b..452f85ea05 100644 --- a/crates/audience/permutive/src/lib.rs +++ b/crates/audience/permutive/src/lib.rs @@ -357,38 +357,12 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for PermutiveIntegration { - fn integration_name(&self) -> &'static str { - PERMUTIVE_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![ - // API proxy endpoints - self.get("/api/*"), - self.post("/api/*"), - // Secure Signals endpoints - self.get("/secure-signal/*"), - self.post("/secure-signal/*"), - // Events endpoints - self.get("/events/*"), - self.post("/events/*"), - // Sync endpoints - self.get("/sync/*"), - self.post("/sync/*"), - // CDN endpoint - self.get("/cdn/*"), - // SDK serving - self.get("/sdk"), - ] - } - - async fn handle( +impl PermutiveIntegration { + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let path = req.uri().path().to_string(); @@ -448,6 +422,42 @@ impl IntegrationProxy for PermutiveIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for PermutiveIntegration { + fn integration_name(&self) -> &'static str { + PERMUTIVE_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![ + // API proxy endpoints + self.get("/api/*"), + self.post("/api/*"), + // Secure Signals endpoints + self.get("/secure-signal/*"), + self.post("/secure-signal/*"), + // Events endpoints + self.get("/events/*"), + self.post("/events/*"), + // Sync endpoints + self.get("/sync/*"), + self.post("/sync/*"), + // CDN endpoint + self.get("/cdn/*"), + // SDK serving + self.get("/sdk"), + ] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for PermutiveIntegration { fn integration_id(&self) -> &'static str { PERMUTIVE_INTEGRATION_ID @@ -678,7 +688,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); assert_eq!( @@ -720,7 +730,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); assert_eq!( response.status(), @@ -793,7 +803,7 @@ mod tests { .header("x-permutive-trace", "kept") .body(EdgeBody::empty()) .expect("should build request"); - futures::executor::block_on(integration.handle(&settings, &services, req)) + futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); } diff --git a/crates/bot-protection/datadome/src/lib.rs b/crates/bot-protection/datadome/src/lib.rs index 82506d2a8c..e8994d2ba2 100644 --- a/crates/bot-protection/datadome/src/lib.rs +++ b/crates/bot-protection/datadome/src/lib.rs @@ -874,6 +874,27 @@ impl DataDomeIntegration { } } +impl DataDomeIntegration { + async fn route( + &self, + req: http::Request, + services: &RuntimeServices, + ) -> Result, Report> { + let path = req.uri().path().to_string(); + + if path == "/integrations/datadome/tags.js" { + self.handle_tags_js(services, req).await + } else if path.starts_with("/integrations/datadome/js/") { + self.handle_js_api(services, req).await + } else { + Err(Report::new(Self::error(format!( + "Unknown DataDome route: {}", + path + )))) + } + } +} + #[async_trait(?Send)] impl IntegrationProxy for DataDomeIntegration { fn integration_name(&self) -> &'static str { @@ -895,22 +916,10 @@ impl IntegrationProxy for DataDomeIntegration { async fn handle( &self, - _settings: &Settings, - services: &RuntimeServices, + call: trusted_server_core::module_context::ModuleCall<'_>, req: http::Request, ) -> Result, Report> { - let path = req.uri().path().to_string(); - - if path == "/integrations/datadome/tags.js" { - self.handle_tags_js(services, req).await - } else if path.starts_with("/integrations/datadome/js/") { - self.handle_js_api(services, req).await - } else { - Err(Report::new(Self::error(format!( - "Unknown DataDome route: {}", - path - )))) - } + call.inject_with(self, req, Self::route)?.await } } @@ -1718,7 +1727,6 @@ mod tests { let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - let settings = create_test_settings(); let integration = DataDomeIntegration::new(test_config()); let req = http::Request::builder() .method(http::Method::GET) @@ -1726,7 +1734,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &services)) .expect("should proxy request"); assert_eq!( diff --git a/crates/cmp/didomi/src/lib.rs b/crates/cmp/didomi/src/lib.rs index 97bb6a18f6..b8b7aa6461 100644 --- a/crates/cmp/didomi/src/lib.rs +++ b/crates/cmp/didomi/src/lib.rs @@ -442,25 +442,11 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for DidomiIntegration { - fn integration_name(&self) -> &'static str { - DIDOMI_INTEGRATION_ID - } - - fn proxy_prefix(&self) -> String { - self.resolved_prefix() - } - - fn routes(&self) -> Vec { - vec![self.get("/*"), self.post("/*")] - } - - async fn handle( +impl DidomiIntegration { + async fn route( &self, - _settings: &Settings, - services: &RuntimeServices, req: http::Request, + services: &RuntimeServices, ) -> Result, Report> { let (parts, body) = req.into_parts(); let path = parts.uri.path().to_string(); @@ -556,6 +542,29 @@ impl IntegrationProxy for DidomiIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for DidomiIntegration { + fn integration_name(&self) -> &'static str { + DIDOMI_INTEGRATION_ID + } + + fn proxy_prefix(&self) -> String { + self.resolved_prefix() + } + + fn routes(&self) -> Vec { + vec![self.get("/*"), self.post("/*")] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationHeadInjector for DidomiIntegration { fn integration_id(&self) -> &'static str { DIDOMI_INTEGRATION_ID @@ -839,7 +848,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("us", Some("ca")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -847,9 +855,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should return redirect"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should return redirect"); assert_eq!( response.status(), @@ -895,7 +902,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("us", Some("ca")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(DidomiIntegrationConfig { proxy_path: Some("publisher/privacy".to_string()), ..config_with_geo_query_parameters() @@ -906,9 +912,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should return redirect"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should return redirect"); assert_eq!(response.status(), http::StatusCode::TEMPORARY_REDIRECT); assert_eq!( @@ -933,7 +938,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("us", Some("us-ca")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -943,9 +947,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy canonical loader"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy canonical loader"); assert_eq!(response.status(), http::StatusCode::OK); assert_eq!( @@ -976,7 +979,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("US", Some("CA")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config())); let request = http::Request::builder() .method(Method::GET) @@ -984,9 +986,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy loader"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy loader"); assert_eq!(response.status(), http::StatusCode::OK); assert_eq!( @@ -1001,7 +1002,6 @@ mod tests { let stub = Arc::new(StubHttpClient::new()); stub.push_response(200, b"sdk".to_vec()); let services = services_with_geo(Arc::clone(&stub), GeoResult::Failure); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -1009,9 +1009,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy unrelated SDK asset"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy unrelated SDK asset"); assert_eq!(response.status(), http::StatusCode::OK); assert_eq!( @@ -1031,7 +1030,6 @@ mod tests { ] { let stub = Arc::new(StubHttpClient::new()); let services = services_with_geo(Arc::clone(&stub), geo_result); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -1039,9 +1037,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should return controlled geo failure"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should return controlled geo failure"); assert_eq!( response.status(), @@ -1088,7 +1085,6 @@ mod tests { ], ); let services = services_with_geo(Arc::clone(&stub), GeoResult::Value(None)); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config())); let request = http::Request::builder() .method(Method::GET) @@ -1096,9 +1092,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy API request"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy API request"); assert_eq!( stub.recorded_cache_intents(), @@ -1147,7 +1142,6 @@ mod tests { Arc::clone(&stub), GeoResult::Value(Some(geo_info("US", Some("CA")))), ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config_with_geo_query_parameters())); let request = http::Request::builder() .method(Method::GET) @@ -1155,9 +1149,8 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = - futures::executor::block_on(integration.handle(&settings, &services, request)) - .expect("should proxy SDK request"); + let response = futures::executor::block_on(integration.route(request, &services)) + .expect("should proxy SDK request"); assert_eq!( stub.recorded_cache_intents(), @@ -1419,7 +1412,6 @@ mod tests { let services = build_services_with_http_client( Arc::clone(&stub) as Arc ); - let settings = create_test_settings(); let integration = DidomiIntegration::new(Arc::new(config())); let req = http::Request::builder() .method(http::Method::GET) @@ -1427,7 +1419,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &services)) .expect("should proxy request"); assert_eq!( diff --git a/crates/cmp/sourcepoint/src/lib.rs b/crates/cmp/sourcepoint/src/lib.rs index 744b3d1f92..4fc401ba96 100644 --- a/crates/cmp/sourcepoint/src/lib.rs +++ b/crates/cmp/sourcepoint/src/lib.rs @@ -827,27 +827,11 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for SourcepointIntegration { - fn integration_name(&self) -> &'static str { - SOURCEPOINT_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - let endpoint_path = format!("/integrations/{SOURCEPOINT_INTEGRATION_ID}/cdn/*"); - vec![ - self.get("/cdn/*"), - self.post("/cdn/*"), - IntegrationEndpoint::new(Method::HEAD, endpoint_path.clone()), - IntegrationEndpoint::new(Method::OPTIONS, endpoint_path), - ] - } - - async fn handle( +impl SourcepointIntegration { + async fn route( &self, - _settings: &Settings, - services: &RuntimeServices, req: Request, + services: &RuntimeServices, ) -> Result, Report> { let path = req.uri().path().to_string(); let method = req.method().clone(); @@ -1079,6 +1063,31 @@ impl IntegrationProxy for SourcepointIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for SourcepointIntegration { + fn integration_name(&self) -> &'static str { + SOURCEPOINT_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + let endpoint_path = format!("/integrations/{SOURCEPOINT_INTEGRATION_ID}/cdn/*"); + vec![ + self.get("/cdn/*"), + self.post("/cdn/*"), + IntegrationEndpoint::new(Method::HEAD, endpoint_path.clone()), + IntegrationEndpoint::new(Method::OPTIONS, endpoint_path), + ] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for SourcepointIntegration { fn integration_id(&self) -> &'static str { SOURCEPOINT_INTEGRATION_ID @@ -1249,7 +1258,6 @@ mod tests { #[test] fn handle_rewrites_streamed_javascript_without_content_length() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); let client = Arc::new(StreamingHttpClient::new()); let input = format!(r#"var api="https://{SOURCEPOINT_CDN_HOST}/consent/tcfv2";"#); @@ -1261,13 +1269,12 @@ mod tests { let services = build_services_with_http_client(client.clone()); let response = integration - .handle( - &settings, - &services, + .route( make_req( Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/wrapper.js", ), + &services, ) .await .expect("should proxy JavaScript without Content-Length"); @@ -1293,7 +1300,6 @@ mod tests { #[test] fn handle_keeps_encoded_responses_streaming() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); for content_type in ["application/javascript", "text/html"] { for encodings in [ @@ -1329,13 +1335,12 @@ mod tests { let services = build_services_with_http_client(client.clone()); let response = integration - .handle( - &settings, - &services, + .route( make_req( Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/asset", ), + &services, ) .await .expect("should pass through encoded responses regardless of size"); @@ -1390,7 +1395,6 @@ mod tests { #[test] fn handle_rewrites_explicit_identity_encoded_responses() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); for content_type in ["application/javascript", "text/html"] { for encoding in ["identity", " IdEnTiTy "] { @@ -1417,13 +1421,12 @@ mod tests { ); let services = build_services_with_http_client(client.clone()); let response = integration - .handle( - &settings, - &services, + .route( make_req( Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/asset", ), + &services, ) .await .expect("should rewrite explicitly identity-encoded responses"); @@ -1449,7 +1452,6 @@ mod tests { #[test] fn handle_rewrites_streamed_html_without_content_length() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); let client = Arc::new(StreamingHttpClient::new()); client.stub.push_response_with_headers( @@ -1460,10 +1462,9 @@ mod tests { let services = build_services_with_http_client(client.clone()); let response = integration - .handle( - &settings, - &services, + .route( make_req(Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/us_pm/index.html"), + &services, ) .await .expect("should proxy HTML without Content-Length"); @@ -1484,7 +1485,6 @@ mod tests { #[test] fn handle_accepts_exact_rewrite_limit_and_stops_reading_on_overflow() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); let limit = MAX_REWRITE_BODY_SIZE as usize; for content_type in ["application/javascript", "text/html"] { @@ -1502,10 +1502,9 @@ mod tests { ); let services = build_services_with_http_client(client.clone()); - let result = integration.handle( - &settings, - &services, + let result = integration.route( make_req(Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/asset"), + &services, ).await; if extra_bytes == 0 { @@ -1546,7 +1545,6 @@ mod tests { #[test] fn handle_passes_through_declared_oversize_without_reading() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); for content_type in ["application/javascript", "text/html"] { let client = Arc::new(StreamingHttpClient::new()); @@ -1563,13 +1561,12 @@ mod tests { let services = build_services_with_http_client(client.clone()); let response = integration - .handle( - &settings, - &services, + .route( make_req( Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/asset", ), + &services, ) .await .expect("should pass through a declared oversized response"); @@ -1600,7 +1597,6 @@ mod tests { #[test] fn handle_keeps_ineligible_responses_streaming() { futures::executor::block_on(async { - let settings = create_test_settings(); for (content_type, method, status, rewrite_sdk) in [ ("application/json", Method::GET, 200, true), ("text/css", Method::GET, 200, true), @@ -1638,7 +1634,7 @@ mod tests { set_req_header(&mut request, header::ACCEPT_ENCODING, "gzip, br"); let response = integration - .handle(&settings, &services, request) + .route(request, &services) .await .expect("should pass through an ineligible response"); @@ -1684,7 +1680,6 @@ mod tests { #[test] fn handle_rewrites_on_buffered_adapters_with_or_without_content_length() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); for has_length in [false, true] { let client = Arc::new(StubHttpClient::new()); @@ -1702,13 +1697,12 @@ mod tests { let services = build_services_with_http_client(client.clone()); let response = integration - .handle( - &settings, - &services, + .route( make_req( Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/wrapper.js", ), + &services, ) .await .expect("should rewrite a buffered response"); @@ -1753,7 +1747,6 @@ mod tests { #[test] fn handle_rejects_buffered_overflow_without_content_length() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); for content_type in ["application/javascript", "text/html"] { let client = Arc::new(StubHttpClient::new()); @@ -1765,13 +1758,12 @@ mod tests { let services = build_services_with_http_client(client.clone()); let error = integration - .handle( - &settings, - &services, + .route( make_req( Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/asset", ), + &services, ) .await .expect_err("should reject a buffered body over the rewrite limit"); @@ -1793,7 +1785,6 @@ mod tests { #[test] fn handle_javascript_cache_policy_follows_static_path_classification() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); for (path, is_static) in [ ("/wrapper/v2/script", false), @@ -1848,7 +1839,7 @@ mod tests { } let response = integration - .handle(&settings, &services, request) + .route(request, &services) .await .expect("should rewrite JavaScript with path-aware cache policy"); let expected_cache = if is_static && !sets_cookie { @@ -1877,7 +1868,6 @@ mod tests { #[test] fn handle_site_data_requests_identity_and_preserves_dynamic_cache_policy() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); for (upstream_cache, forwarded_cookies, sets_cookie, expected_cache) in [ (Some("no-store"), false, false, "no-store"), @@ -1919,7 +1909,7 @@ mod tests { } let response = integration - .handle(&settings, &services, request) + .route(request, &services) .await .expect("should rewrite dynamic site data"); @@ -1946,7 +1936,6 @@ mod tests { #[test] fn handle_preserves_invalid_utf8_bytes_and_headers() { futures::executor::block_on(async { - let settings = create_test_settings(); let integration = SourcepointIntegration::new(Arc::new(config())); let client = Arc::new(StreamingHttpClient::new()); let bytes = vec![b'a', 0xff, b'b']; @@ -1962,13 +1951,12 @@ mod tests { let services = build_services_with_http_client(client.clone()); let response = integration - .handle( - &settings, - &services, + .route( make_req( Method::GET, "https://publisher.example.com/integrations/sourcepoint/cdn/wrapper.js", ), + &services, ) .await .expect("should retain non-UTF-8 content unchanged"); diff --git a/crates/device/fastly/src/lib.rs b/crates/device/fastly/src/lib.rs index 7ed8399ff3..3a71e493a5 100644 --- a/crates/device/fastly/src/lib.rs +++ b/crates/device/fastly/src/lib.rs @@ -3,7 +3,8 @@ //! [`FastlyDeviceModule`] strengthens the built-in User-Agent classification //! with the host's TLS (JA4) and HTTP/2 signals, for deployments on Fastly //! Compute. It is selected by `[device] module = "fastly"` and wired in by the -//! Fastly adapter, which injects the request info and the captured host signals. +//! Fastly adapter, which injects the captured host signals and hands the module +//! the request's module context. //! //! [`FastlyHostSignals`] captures those signals from a live Fastly request //! (`get_tls_ja4()`, `get_client_h2_fingerprint()`) into owned values, so it can @@ -20,6 +21,7 @@ use std::sync::Arc; use fastly::Request as FastlyRequest; use trusted_server_core::ec::device::{DeviceModule, DeviceSignals}; use trusted_server_core::evidence::{HostSignals, RequestInfo}; +use trusted_server_core::module_context::ModuleCall; /// Host-computed client signals captured from a live Fastly request. /// @@ -68,9 +70,9 @@ impl HostSignals for FastlyHostSignals { /// /// Classifies a request with [`DeviceSignals::derive`], which strengthens the /// User-Agent classification with the host signals. It reads the User-Agent -/// from its injected [`RequestInfo`] and the TLS and HTTP/2 signals from its -/// injected [`HostSignals`], so the browser/bot gate is backed by the live -/// request. +/// from the [`RequestInfo`] it names in the module context and the TLS and +/// HTTP/2 signals from its injected [`HostSignals`], so the browser/bot gate +/// is backed by the live request. pub struct FastlyDeviceModule { host_signals: Arc, } @@ -81,6 +83,15 @@ impl FastlyDeviceModule { pub fn new(host_signals: Arc) -> Self { Self { host_signals } } + + /// The signals the User-Agent and the host signals give together. + fn classify(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive( + request_info.user_agent(), + self.host_signals.ja4(), + self.host_signals.h2(), + ) + } } #[async_trait::async_trait(?Send)] @@ -89,15 +100,8 @@ impl DeviceModule for FastlyDeviceModule { "fastly" } - async fn detect( - &self, - request_info: &dyn RequestInfo, - _services: &trusted_server_core::platform::RuntimeServices, - ) -> DeviceSignals { - DeviceSignals::derive( - request_info.user_agent(), - self.host_signals.ja4(), - self.host_signals.h2(), - ) + async fn detect(&self, call: ModuleCall<'_>) -> DeviceSignals { + call.inject(self, Self::classify) + .unwrap_or_else(|_| DeviceSignals::unknown()) } } diff --git a/crates/identity/lockr/src/lib.rs b/crates/identity/lockr/src/lib.rs index f251e3906f..74e26f02ff 100644 --- a/crates/identity/lockr/src/lib.rs +++ b/crates/identity/lockr/src/lib.rs @@ -378,21 +378,12 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for LockrIntegration { - fn integration_name(&self) -> &'static str { - LOCKR_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![self.get("/sdk"), self.post("/api/*"), self.get("/api/*")] - } - - async fn handle( +impl LockrIntegration { + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let path = req.uri().path().to_string(); @@ -409,6 +400,25 @@ impl IntegrationProxy for LockrIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for LockrIntegration { + fn integration_name(&self) -> &'static str { + LOCKR_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![self.get("/sdk"), self.post("/api/*"), self.get("/api/*")] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for LockrIntegration { fn integration_id(&self) -> &'static str { LOCKR_INTEGRATION_ID @@ -593,7 +603,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); assert_eq!( @@ -636,7 +646,7 @@ mod tests { .body(EdgeBody::from(payload.to_vec())) .expect("should build request"); - let response = futures::executor::block_on(integration.handle(&settings, &services, req)) + let response = futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); assert_eq!(response.status(), http::StatusCode::OK, "should return OK"); @@ -693,7 +703,7 @@ mod tests { .body(EdgeBody::empty()) .expect("should build request"); - futures::executor::block_on(integration.handle(&settings, &services, req)) + futures::executor::block_on(integration.route(req, &settings, &services)) .expect("should proxy request"); let recorded = stub.recorded_request_headers(); diff --git a/crates/permission-signal/gpc/src/lib.rs b/crates/permission-signal/gpc/src/lib.rs index 5673982bdf..326d640fb8 100644 --- a/crates/permission-signal/gpc/src/lib.rs +++ b/crates/permission-signal/gpc/src/lib.rs @@ -10,7 +10,7 @@ //! scheme. Why is set out once, in `permission_signal/README.md` in core. use trusted_server_core::consent::ConsentContext; -use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; @@ -40,6 +40,12 @@ impl GpcModule { pub const fn new() -> Self { Self } + + /// The signal this scheme reads from the request's consent record, when + /// it can use one. + fn read_signal(&self, consent: &ConsentContext) -> Option { + consent.gpc.then(|| ValidSignal::new(short(), "gpc", "1")) + } } impl PermissionSignalModule for GpcModule { @@ -65,12 +71,8 @@ impl PermissionSignalModule for GpcModule { /// The header's one value, when it was sent. There is nothing to /// decode, so a sent header is always valid. - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { - consent.gpc.then(|| ValidSignal::new(short(), "gpc", "1")) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } } @@ -107,14 +109,13 @@ mod tests { #[test] fn vouches_for_the_header_only_when_it_was_sent() { - let evidence = OwnedRequestInfo::default(); assert_eq!( - GpcModule::new().valid_signal(&with_header(true), &evidence), + GpcModule::new().read_signal(&with_header(true)), Some(ValidSignal::new(short(), "gpc", "1")), "a sent header is the one value it can carry" ); assert_eq!( - GpcModule::new().valid_signal(&with_header(false), &evidence), + GpcModule::new().read_signal(&with_header(false)), None, "no header is no signal" ); diff --git a/crates/permission-signal/gpp/src/lib.rs b/crates/permission-signal/gpp/src/lib.rs index b0b15e1820..bda01ec46b 100644 --- a/crates/permission-signal/gpp/src/lib.rs +++ b/crates/permission-signal/gpp/src/lib.rs @@ -8,7 +8,7 @@ //! scheme. Why is set out once, in `permission_signal/README.md` in core. use trusted_server_core::consent::ConsentContext; -use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; @@ -38,6 +38,14 @@ impl GppSaleOptOutModule { pub const fn new() -> Self { Self } + + /// The signal this scheme reads from the request's consent record, when + /// it can use one. + fn read_signal(&self, consent: &ConsentContext) -> Option { + consent.gpp.as_ref()?; + let raw = consent.raw_gpp_string.as_deref()?; + Some(ValidSignal::new(short(), "gpp", raw)) + } } impl PermissionSignalModule for GppSaleOptOutModule { @@ -76,14 +84,8 @@ impl PermissionSignalModule for GppSaleOptOutModule { } /// The GPP string, when it decoded. - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { - consent.gpp.as_ref()?; - let raw = consent.raw_gpp_string.as_deref()?; - Some(ValidSignal::new(short(), "gpp", raw)) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } } @@ -155,13 +157,12 @@ mod tests { #[test] fn vouches_for_the_gpp_string_only_when_it_decoded() { - let evidence = OwnedRequestInfo::default(); let decoded = ConsentContext { raw_gpp_string: Some("DBABMA~CPreadable".to_owned()), ..with_sale_opt_out(Some(false)) }; assert_eq!( - GppSaleOptOutModule::new().valid_signal(&decoded, &evidence), + GppSaleOptOutModule::new().read_signal(&decoded), Some(ValidSignal::new(short(), "gpp", "DBABMA~CPreadable")), "a decoded string is vouched for as received, whatever it says" ); @@ -170,7 +171,7 @@ mod tests { ..ConsentContext::default() }; assert_eq!( - GppSaleOptOutModule::new().valid_signal(&unreadable, &evidence), + GppSaleOptOutModule::new().read_signal(&unreadable), None, "an unreadable string is not" ); diff --git a/crates/permission-signal/mtm/src/lib.rs b/crates/permission-signal/mtm/src/lib.rs index 3d621c7cf3..e42d4617ee 100644 --- a/crates/permission-signal/mtm/src/lib.rs +++ b/crates/permission-signal/mtm/src/lib.rs @@ -39,9 +39,9 @@ use std::sync::OnceLock; -use trusted_server_core::consent::ConsentContext; use trusted_server_core::constants::COOKIE_MTM_PREF; use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, @@ -147,6 +147,19 @@ impl MtmModule { pub const fn new() -> Self { Self } + + /// The word in the request's evidence, when one is present. + fn read_signal(&self, evidence: &dyn RequestInfo) -> Option { + preference(evidence).map(|word| ValidSignal::new(short(), SCHEME, word.as_str())) + } + + /// The Model Terms, when the request's evidence carries a word. + fn read_tdls(&self, evidence: &dyn RequestInfo) -> Vec { + if preference(evidence).is_none() { + return Vec::new(); + } + Tdl::new(TERMS).into_iter().collect() + } } /// Every Data Use either word says something about, computed once. @@ -219,21 +232,14 @@ impl PermissionSignalModule for MtmModule { /// The word, when one is present. There is nothing else to validate, /// because a value that is not one of the two words is not an answer. - fn valid_signal( - &self, - _consent: &ConsentContext, - evidence: &dyn RequestInfo, - ) -> Option { - preference(evidence).map(|word| ValidSignal::new(short(), SCHEME, word.as_str())) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } /// The Model Terms, whenever a word is present, because either answer is /// given under them. Nothing is declared without an answer. - fn tdls(&self, _consent: &ConsentContext, evidence: &dyn RequestInfo) -> Vec { - if preference(evidence).is_none() { - return Vec::new(); - } - Tdl::new(TERMS).into_iter().collect() + fn tdls(&self, call: ModuleCall<'_>) -> Vec { + call.inject(self, Self::read_tdls).unwrap_or_default() } } @@ -242,6 +248,7 @@ mod tests { use http::HeaderMap; use super::*; + use trusted_server_core::consent::ConsentContext; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::permissions::Acquisition; @@ -510,29 +517,21 @@ mod tests { #[test] fn vouches_for_the_word_and_declares_the_terms_only_when_one_is_present() { - let consent = ConsentContext::default(); let module = MtmModule::new(); assert_eq!( - module.valid_signal(&consent, &with_cookie("standard")), + module.read_signal(&with_cookie("standard")), Some(ValidSignal::new(short(), SCHEME, "standard")) ); - let declared = module.tdls(&consent, &with_cookie("personalized")); + let declared = module.read_tdls(&with_cookie("personalized")); assert_eq!( declared.iter().map(Tdl::as_str).collect::>(), vec![TERMS], "an answer is given under the Model Terms" ); - assert_eq!(module.valid_signal(&consent, &with_cookie("maybe")), None); - assert!(module.tdls(&consent, &with_cookie("maybe")).is_empty()); - assert_eq!( - module.valid_signal(&consent, &OwnedRequestInfo::default()), - None - ); - assert!( - module - .tdls(&consent, &OwnedRequestInfo::default()) - .is_empty() - ); + assert_eq!(module.read_signal(&with_cookie("maybe")), None); + assert!(module.read_tdls(&with_cookie("maybe")).is_empty()); + assert_eq!(module.read_signal(&OwnedRequestInfo::default()), None); + assert!(module.read_tdls(&OwnedRequestInfo::default()).is_empty()); } /// `non-marketing` changes nothing, so every Data Use the Model Terms @@ -556,15 +555,14 @@ mod tests { #[test] fn non_marketing_is_still_a_recorded_answer_under_the_terms() { let evidence = with_cookie("non-marketing"); - let consent = ConsentContext::default(); let module = MtmModule::new(); let signal = module - .valid_signal(&consent, &evidence) + .read_signal(&evidence) .expect("a decline is a valid answer"); assert_eq!(signal.value, "non-marketing"); assert_eq!( - module.tdls(&consent, &evidence).len(), + module.read_tdls(&evidence).len(), 1, "the answer was given under the Model Terms whatever it said", ); diff --git a/crates/permission-signal/tcf/src/lib.rs b/crates/permission-signal/tcf/src/lib.rs index 359f37e28d..b479f66fbc 100644 --- a/crates/permission-signal/tcf/src/lib.rs +++ b/crates/permission-signal/tcf/src/lib.rs @@ -21,7 +21,7 @@ pub use mapping::purpose_for; #[cfg(test)] use trusted_server_core::consent::types::TcfConsent; use trusted_server_core::consent::{ConsentContext, effective_tcf}; -use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, @@ -57,6 +57,14 @@ impl TcfModule { pub const fn new() -> Self { Self } + + /// The signal this scheme reads from the request's consent record, when + /// it can use one. + fn read_signal(&self, consent: &ConsentContext) -> Option { + consent.tcf.as_ref()?; + let raw = consent.raw_tc_string.as_deref()?; + Some(ValidSignal::new(short(), "tcf", raw)) + } } impl PermissionSignalModule for TcfModule { @@ -112,14 +120,8 @@ impl PermissionSignalModule for TcfModule { /// The standalone TC string, when it decoded and has not expired. A record /// carried inside a GPP string is the GPP string's, which the GPP /// module vouches for. - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { - consent.tcf.as_ref()?; - let raw = consent.raw_tc_string.as_deref()?; - Some(ValidSignal::new(short(), "tcf", raw)) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } /// Only a TCF record refusing storage withdraws, because only TCF records @@ -322,13 +324,12 @@ mod tests { #[test] fn vouches_for_the_standalone_tc_string_only_when_it_decoded() { - let evidence = OwnedRequestInfo::default(); let decoded = ConsentContext { raw_tc_string: Some("CPreadable".to_owned()), ..with_record(&[1]) }; assert_eq!( - TcfModule::new().valid_signal(&decoded, &evidence), + TcfModule::new().read_signal(&decoded), Some(ValidSignal::new(short(), "tcf", "CPreadable")), "a decoded record vouches for the string as received" ); @@ -337,7 +338,7 @@ mod tests { ..ConsentContext::default() }; assert_eq!( - TcfModule::new().valid_signal(&unreadable, &evidence), + TcfModule::new().read_signal(&unreadable), None, "an unreadable string is not vouched for" ); @@ -347,12 +348,12 @@ mod tests { ..ConsentContext::default() }; assert_eq!( - TcfModule::new().valid_signal(&expired, &evidence), + TcfModule::new().read_signal(&expired), None, "an expired record is not one this module uses, so it is not vouched for" ); assert_eq!( - TcfModule::new().valid_signal(&ConsentContext::default(), &evidence), + TcfModule::new().read_signal(&ConsentContext::default()), None, "and no record is no signal" ); diff --git a/crates/permission-signal/us-privacy/src/lib.rs b/crates/permission-signal/us-privacy/src/lib.rs index ea3bd14b07..19c4a83c7b 100644 --- a/crates/permission-signal/us-privacy/src/lib.rs +++ b/crates/permission-signal/us-privacy/src/lib.rs @@ -10,7 +10,7 @@ //! scheme. Why is set out once, in `permission_signal/README.md` in core. use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; -use trusted_server_core::evidence::RequestInfo; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::permission_signal::{PermissionSignalModule, SignalInput}; use trusted_server_core::permissions::{ConsentSignal, OptOutSource, Permission, ValidSignal}; @@ -40,6 +40,14 @@ impl UsPrivacyModule { pub const fn new() -> Self { Self } + + /// The signal this scheme reads from the request's consent record, when + /// it can use one. + fn read_signal(&self, consent: &ConsentContext) -> Option { + consent.us_privacy.as_ref()?; + let raw = consent.raw_us_privacy.as_deref()?; + Some(ValidSignal::new(short(), "us_privacy", raw)) + } } impl PermissionSignalModule for UsPrivacyModule { @@ -78,14 +86,8 @@ impl PermissionSignalModule for UsPrivacyModule { } /// The US Privacy string, when it decoded. - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { - consent.us_privacy.as_ref()?; - let raw = consent.raw_us_privacy.as_deref()?; - Some(ValidSignal::new(short(), "us_privacy", raw)) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::read_signal).ok().flatten() } } @@ -157,13 +159,12 @@ mod tests { #[test] fn vouches_for_the_us_privacy_string_only_when_it_decoded() { - let evidence = OwnedRequestInfo::default(); let decoded = ConsentContext { raw_us_privacy: Some("1YNN".to_owned()), ..with_sale_flag(PrivacyFlag::No) }; assert_eq!( - UsPrivacyModule::new().valid_signal(&decoded, &evidence), + UsPrivacyModule::new().read_signal(&decoded), Some(ValidSignal::new(short(), "us_privacy", "1YNN")), "a decoded string is vouched for as received, whatever it says" ); @@ -172,7 +173,7 @@ mod tests { ..ConsentContext::default() }; assert_eq!( - UsPrivacyModule::new().valid_signal(&unreadable, &evidence), + UsPrivacyModule::new().read_signal(&unreadable), None, "an unreadable string is not" ); diff --git a/crates/tag/google-tag-manager/src/lib.rs b/crates/tag/google-tag-manager/src/lib.rs index aaa76fbd47..5dc84ff739 100644 --- a/crates/tag/google-tag-manager/src/lib.rs +++ b/crates/tag/google-tag-manager/src/lib.rs @@ -947,33 +947,12 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for GoogleTagManagerIntegration { - fn integration_name(&self) -> &'static str { - GTM_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![ - // Proxy for the main GTM script - self.get("/gtm.js"), - // Proxy for the gtag script (if used) - self.get("/gtag/js"), - self.get("/gtag.js"), - // Analytics beacons (GA4/UA) - // The GTM script is rewritten to point these beacons to our proxy. - self.get("/collect"), - self.post("/collect"), - self.get("/g/collect"), - self.post("/g/collect"), - ] - } - - async fn handle( +impl GoogleTagManagerIntegration { + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let mut req = req; let path = req.uri().path().to_string(); @@ -1022,6 +1001,37 @@ impl IntegrationProxy for GoogleTagManagerIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for GoogleTagManagerIntegration { + fn integration_name(&self) -> &'static str { + GTM_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![ + // Proxy for the main GTM script + self.get("/gtm.js"), + // Proxy for the gtag script (if used) + self.get("/gtag/js"), + self.get("/gtag.js"), + // Analytics beacons (GA4/UA) + // The GTM script is rewritten to point these beacons to our proxy. + self.get("/collect"), + self.post("/collect"), + self.get("/g/collect"), + self.post("/g/collect"), + ] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for GoogleTagManagerIntegration { fn integration_id(&self) -> &'static str { GTM_INTEGRATION_ID @@ -1771,7 +1781,7 @@ mod tests { ); integration - .handle(&settings, &services, req) + .route(req, &settings, &services) .await .expect("should handle the GTM request") }) @@ -2111,7 +2121,7 @@ mod tests { ); integration - .handle(&settings, &services, req) + .route(req, &settings, &services) .await .expect("should handle the GTM request") }) @@ -2879,7 +2889,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= let settings = make_settings(); let response = integration - .handle(&settings, &noop_services(), req) + .route(req, &settings, &noop_services()) .await .expect("handle should not return error"); @@ -2913,7 +2923,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= let settings = make_settings(); let response = integration - .handle(&settings, &noop_services(), req) + .route(req, &settings, &noop_services()) .await .expect("handle should not return error"); diff --git a/crates/testing/seam-probe/src/lib.rs b/crates/testing/seam-probe/src/lib.rs index fd75a50f85..e02faf1ade 100644 --- a/crates/testing/seam-probe/src/lib.rs +++ b/crates/testing/seam-probe/src/lib.rs @@ -36,15 +36,14 @@ use trusted_server_core::auction::demand::{ use trusted_server_core::auction::provider::{AuctionProvider, ProviderRequestOutcome}; use trusted_server_core::auction::types::{AuctionContext, AuctionRequest, AuctionResponse}; use trusted_server_core::ec::device::{DeviceModule, DeviceSignals}; -use trusted_server_core::ec::module::{ - EdgeCookieModule, GeneratedEdgeCookie, IdentityInput, ModuleCode, -}; +use trusted_server_core::ec::module::{EdgeCookieModule, GeneratedEdgeCookie, ModuleCode}; use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::RequestInfo; use trusted_server_core::integrations::{ CarriedJsModule, IntegrationBuilder, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, }; +use trusted_server_core::module_context::ModuleCall; use trusted_server_core::platform::{ GeoInfo, PlatformError, PlatformGeo, PlatformResponse, RuntimeServices, }; @@ -206,6 +205,20 @@ pub struct SeamProbeEc; /// The four-character code core stamps on identifiers this module owns. const SEAM_PROBE_EC_CODE: ModuleCode = trusted_server_core::module_code!("sprb"); +impl SeamProbeEc { + /// An identifier derived from the client IP in the request's evidence. + fn derive(&self, request_info: &dyn RequestInfo) -> GeneratedEdgeCookie { + // Derived from evidence so the test can prove this ran rather than the + // built-in module, and prove the evidence actually arrived. + let ip = request_info.client_ip(); + let id = format!("seam-probe-{}", if ip.is_empty() { "no-ip" } else { ip }); + GeneratedEdgeCookie { + id: Some(id), + response_headers: Vec::new(), + } + } +} + #[async_trait::async_trait(?Send)] impl EdgeCookieModule for SeamProbeEc { fn id(&self) -> &'static str { @@ -218,18 +231,9 @@ impl EdgeCookieModule for SeamProbeEc { async fn generate( &self, - request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &trusted_server_core::platform::RuntimeServices, + call: ModuleCall<'_>, ) -> Result> { - // Derived from evidence so the test can prove this ran rather than the - // built-in module, and prove the evidence actually arrived. - let ip = request_info.client_ip(); - let id = format!("seam-probe-{}", if ip.is_empty() { "no-ip" } else { ip }); - Ok(GeneratedEdgeCookie { - id: Some(id), - response_headers: Vec::new(), - }) + Ok(call.inject(self, Self::derive).unwrap_or_default()) } fn accepts_id(&self, value: &str) -> bool { @@ -247,11 +251,7 @@ impl DeviceModule for SeamProbeDevice { module_name() } - async fn detect( - &self, - _request_info: &dyn RequestInfo, - _services: &trusted_server_core::platform::RuntimeServices, - ) -> DeviceSignals { + async fn detect(&self, _call: ModuleCall<'_>) -> DeviceSignals { DeviceSignals { is_mobile: 1, platform_class: Some("seam-probe".to_owned()), @@ -415,21 +415,11 @@ impl AuctionProvider for SeamProbeAdServer { /// Proxy that reports what the seam delivered to this request. pub struct SeamProbeProxy; -#[async_trait(?Send)] -impl IntegrationProxy for SeamProbeProxy { - fn integration_name(&self) -> &'static str { - SEAM_PROBE_ID - } - - fn routes(&self) -> Vec { - vec![self.get(REPORT_ROUTE_SUFFIX)] - } - - async fn handle( +impl SeamProbeProxy { + async fn route( &self, - _settings: &Settings, - services: &RuntimeServices, req: Request, + services: &RuntimeServices, ) -> Result, Report> { let country = match services .geo() @@ -460,6 +450,25 @@ impl IntegrationProxy for SeamProbeProxy { } } +#[async_trait(?Send)] +impl IntegrationProxy for SeamProbeProxy { + fn integration_name(&self) -> &'static str { + SEAM_PROBE_ID + } + + fn routes(&self) -> Vec { + vec![self.get(REPORT_ROUTE_SUFFIX)] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + /// Rejects a country that is not exactly two ASCII letters. /// /// # Errors diff --git a/crates/testing/testlight/src/lib.rs b/crates/testing/testlight/src/lib.rs index 6cfbbf26db..884af941a6 100644 --- a/crates/testing/testlight/src/lib.rs +++ b/crates/testing/testlight/src/lib.rs @@ -197,21 +197,12 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for TestlightIntegration { - fn integration_name(&self) -> &'static str { - TESTLIGHT_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - vec![self.post("/auction")] - } - - async fn handle( +impl TestlightIntegration { + async fn route( &self, + req: http::Request, settings: &Settings, services: &RuntimeServices, - req: http::Request, ) -> Result, Report> { let (parts, body) = req.into_parts(); let payload_bytes = @@ -270,6 +261,25 @@ impl IntegrationProxy for TestlightIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for TestlightIntegration { + fn integration_name(&self) -> &'static str { + TESTLIGHT_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + vec![self.post("/auction")] + } + + async fn handle( + &self, + call: trusted_server_core::module_context::ModuleCall<'_>, + req: http::Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for TestlightIntegration { fn integration_id(&self) -> &'static str { TESTLIGHT_INTEGRATION_ID @@ -479,7 +489,7 @@ mod tests { ); let response = integration - .handle(&settings, &services, req) + .route(req, &settings, &services) .await .expect("should proxy Testlight request"); @@ -543,10 +553,10 @@ mod tests { let settings = create_test_settings(); let refused = testlight_integration() - .handle( + .route( + testlight_auction_request(FOREIGN_CODED_EC_ID), &settings, &services, - testlight_auction_request(FOREIGN_CODED_EC_ID), ) .await .expect_err("a foreign module code should not be proxied upstream"); @@ -574,10 +584,10 @@ mod tests { settings.ec.module_blocks.clear(); let refused = testlight_integration() - .handle( + .route( + testlight_auction_request(VALID_SYNTHETIC_ID), &settings, &services, - testlight_auction_request(VALID_SYNTHETIC_ID), ) .await .expect_err("a deployment that creates no identifier should proxy none"); diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index a77bd456fc..cd409d53a4 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -388,7 +388,9 @@ async fn dispatch_fallback( } if state.registry.has_route(&method, &path) { - let mut ec_context = EcContext::default(); + // A module route is handed what was resolved for its request, the + // permissions among them, as the publisher path is. + let mut ec_context = build_ec_context(state, services, &req).await?; return state .registry .handle_proxy(ProxyDispatchInput { diff --git a/crates/trusted-server-adapter-axum/tests/permission_signals.rs b/crates/trusted-server-adapter-axum/tests/permission_signals.rs index ee0449b60d..0fb0457c1f 100644 --- a/crates/trusted-server-adapter-axum/tests/permission_signals.rs +++ b/crates/trusted-server-adapter-axum/tests/permission_signals.rs @@ -23,6 +23,7 @@ use trusted_server_core::consent::types::{GppConsent, TcfConsent, UsPrivacy}; use trusted_server_core::consent::{ConsentContext, PrivacyFlag}; use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::module_context::{ModuleContext, test_support}; use trusted_server_core::permission_signal::{ PermissionSignalModule, build_permission_signal_modules, }; @@ -89,13 +90,22 @@ fn assembled( geo: GeoStatus<'_>, modules: &[Arc], ) -> PermissionState { - assemble_permissions( - PermissionMaps::sample(), - consent, - &no_evidence(), - geo, - modules, - ) + assembled_with(consent, &no_evidence(), geo, modules) +} + +/// The permissions assembled for a request carrying `consent` and `evidence`, +/// with the modules handed the request's module context, under the +/// repository's sample policy. +fn assembled_with( + consent: &ConsentContext, + evidence: &OwnedRequestInfo, + geo: GeoStatus<'_>, + modules: &[Arc], +) -> PermissionState { + let context = ModuleContext::new(test_support::request("/")) + .with_consent(consent) + .with_evidence(evidence); + assemble_permissions(PermissionMaps::sample(), &context, geo, modules) } /// Builds a minimal decoded TCF record consenting to the given 1-indexed @@ -624,8 +634,7 @@ fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() ); OwnedRequestInfo::new(String::new(), headers) }; - let standard = assemble_permissions( - PermissionMaps::sample(), + let standard = assembled_with( &ConsentContext::default(), &evidence("standard"), GeoStatus::Located(&geo), @@ -654,8 +663,7 @@ fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() vec!["https://m4ow.uk/mtm/2.txt"], "an answer is given under the versioned Model Terms" ); - let personalized = assemble_permissions( - PermissionMaps::sample(), + let personalized = assembled_with( &ConsentContext::default(), &evidence("personalized"), GeoStatus::Located(&geo), @@ -665,8 +673,7 @@ fn a_pmp_answer_in_the_eu_settles_what_the_model_terms_cover_and_declares_them() personalized.is_set(Permission::SelectPersonalisedAds), "personalized grants targeting too" ); - let unanswered = assemble_permissions( - PermissionMaps::sample(), + let unanswered = assembled_with( &ConsentContext::default(), &no_evidence(), GeoStatus::Located(&geo), diff --git a/crates/trusted-server-adapter-axum/tests/seam_probe.rs b/crates/trusted-server-adapter-axum/tests/seam_probe.rs index 7c4459f2b3..ae5fd82717 100644 --- a/crates/trusted-server-adapter-axum/tests/seam_probe.rs +++ b/crates/trusted-server-adapter-axum/tests/seam_probe.rs @@ -18,12 +18,12 @@ use tower::{Service as _, ServiceExt as _}; use trusted_server_adapter_axum::app::TrustedServerApp; use trusted_server_core::auction::compile_auction_plan; use trusted_server_core::config::validate_settings_for_deploy_with; -use trusted_server_core::ec::module::IdentityInput; use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::integrations::{ IntegrationBuilder, IntegrationRegistration, IntegrationRegistry, }; +use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::{ BackendNamingPolicy, ClientInfo, DisabledGeo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, PlatformSecretStore, RuntimeServices, StoreId, StoreName, @@ -701,8 +701,18 @@ async fn ec_module_generates_an_identifier_with_the_modules_prefix() { .expect("`[ec] module = \"testing.seam-probe\"` should resolve the module's module"); let request_info = OwnedRequestInfo::new("192.0.2.1".to_owned(), HeaderMap::new()); + let services = stub_services(); + let method = axum::http::Method::GET; + let context = ModuleContext::new(ModuleRequest::new( + &method, + "publisher.example", + "https", + "/", + )) + .with_evidence(&request_info) + .with_services(&services); let generated = module - .generate(&request_info, &IdentityInput::default(), &stub_services()) + .generate(context.call(module.id(), module.required_permissions())) .await .expect("the module's module should generate an identifier"); diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 9eabab2869..290e212ca1 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -665,7 +665,13 @@ fn build_router(state: &Arc) -> RouterService { EdgeCacheHeader::CloudflareCdnCacheControl, ) } else if state.registry.has_route(&method, &path) { - let mut ec_context = EcContext::default(); + // A module route is handed what was resolved for its request, + // the permissions among them, as the publisher path is. + let mut ec_context = match build_ec_context(&state.settings, &services, &req).await + { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; state .registry .handle_proxy(ProxyDispatchInput { diff --git a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs index 24cb84c6d5..8be7ef8aae 100644 --- a/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs +++ b/crates/trusted-server-adapter-fastly/src/app/seam_probe_tests.rs @@ -14,10 +14,10 @@ use edgezero_core::http::{HeaderMap, Method, Response, request_builder}; use edgezero_core::router::RouterService; use error_stack::Report; use futures::executor::block_on; -use trusted_server_core::ec::module::IdentityInput; use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::OwnedRequestInfo; use trusted_server_core::integrations::{IntegrationBuilder, IntegrationRegistration}; +use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::settings::Settings; use trusted_server_core::tsjs::tsjs_script_src; use trusted_server_core::tsjs_bundle::{JsModulePart, compile_time_parts}; @@ -441,8 +441,18 @@ fn ec_and_device_selectors_naming_a_module_resolve_the_modules_it_declares() { let services = build_finalize_services(&state.settings, Arc::clone(&state.default_kv_store)); let request_info = OwnedRequestInfo::new("192.0.2.1".to_owned(), HeaderMap::new()); - let generated = block_on(module.generate(&request_info, &IdentityInput::default(), &services)) - .expect("the module's Edge Cookie module should generate an identifier"); + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new( + &method, + "publisher.example", + "https", + "/", + )) + .with_evidence(&request_info) + .with_services(&services); + let generated = + block_on(module.generate(context.call(module.id(), module.required_permissions()))) + .expect("the module's Edge Cookie module should generate an identifier"); assert_eq!( generated.id.as_deref(), diff --git a/crates/trusted-server-adapter-fastly/src/lib.rs b/crates/trusted-server-adapter-fastly/src/lib.rs index 431ac08631..0cd0baeeac 100644 --- a/crates/trusted-server-adapter-fastly/src/lib.rs +++ b/crates/trusted-server-adapter-fastly/src/lib.rs @@ -32,6 +32,7 @@ use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::TrustedServerError; use trusted_server_core::evidence::{BorrowedRequestInfo, HostSignals}; use trusted_server_core::integrations::RequestFilterEffects; +use trusted_server_core::module_context::{ModuleContext, ModuleRequest}; use trusted_server_core::platform::build_geo_module; use trusted_server_core::platform::{ ClientInfo, PlatformKvStore, RuntimeServices, UnavailableKvStore, @@ -967,7 +968,9 @@ pub(crate) fn extract_cookie_value(req: &HttpRequest, name: &str) -> Option>, @@ -992,18 +995,46 @@ pub(crate) async fn derive_device_signals( .map(|ip| ip.to_string()) .unwrap_or_default(); let request_info = BorrowedRequestInfo::new(&client_ip, None).with_headers(&headers); - match registered { - Some(module) => module.detect(&request_info, services).await, + let built; + let module: &dyn DeviceModule = match registered.as_deref() { + Some(module) => module, None => { - build_device_module(settings, || { + built = build_device_module(settings, || { let host_signals: Arc = Arc::new(FastlyHostSignals::from_request(req)); Box::new(FastlyDeviceModule::new(host_signals)) as Box - }) - .detect(&request_info, services) - .await + }); + built.as_ref() } - } + }; + let context = ModuleContext::new(entry_request(req)) + .with_evidence(&request_info) + .with_settings(settings) + .with_services(services); + module + .detect(context.call(module.id(), module.required_permissions())) + .await +} + +/// What the client's request resolved to, read off the Fastly request before +/// it is converted. +/// +/// The scheme is `https` when the entry point marked the request as arriving +/// over TLS, which it does from Fastly's own TLS metadata. +fn entry_request(req: &FastlyRequest) -> ModuleRequest<'_> { + let scheme = if req.get_header_str("fastly-ssl").is_some() { + "https" + } else { + "http" + }; + ModuleRequest::new( + req.get_method(), + req.get_header_str(header::HOST.as_str()) + .unwrap_or_default(), + scheme, + req.get_path(), + ) + .with_query(req.get_query_str().unwrap_or_default()) } #[cfg(test)] @@ -1050,10 +1081,30 @@ mod tests { } /// A device module standing in for one a registration supplies. It marks - /// the signals it returns with the cookie header it was shown, so a test - /// can tell both that it ran and what it saw. + /// the signals it returns with the cookie header, the host, the scheme + /// and the address it was shown, so a test can tell both that it ran and + /// what it saw. struct MarkedDeviceModule; + impl MarkedDeviceModule { + fn mark( + &self, + request: ModuleRequest<'_>, + request_info: &dyn trusted_server_core::evidence::RequestInfo, + ) -> DeviceSignals { + let mut signals = DeviceSignals::derive_ua_only(request_info.user_agent()); + signals.platform_class = Some(format!( + "marked:{}:{}://{}{}?{}", + request_info.header("cookie").unwrap_or_default(), + request.scheme(), + request.host(), + request.path(), + request.query(), + )); + signals + } + } + #[async_trait::async_trait(?Send)] impl DeviceModule for MarkedDeviceModule { fn id(&self) -> &'static str { @@ -1062,22 +1113,20 @@ mod tests { async fn detect( &self, - request_info: &dyn trusted_server_core::evidence::RequestInfo, - _services: &RuntimeServices, + call: trusted_server_core::module_context::ModuleCall<'_>, ) -> DeviceSignals { - let mut signals = DeviceSignals::derive_ua_only(request_info.user_agent()); - signals.platform_class = Some(format!( - "marked:{}", - request_info.header("cookie").unwrap_or_default() - )); - signals + call.inject(self, Self::mark) + .unwrap_or_else(|_| DeviceSignals::unknown()) } } fn page_request() -> FastlyRequest { - let mut req = FastlyRequest::get("https://example.com/"); + let mut req = FastlyRequest::get("https://example.com/article?page=2"); + req.set_header("host", "example.com"); req.set_header("user-agent", "Mozilla/5.0 (X11; Linux x86_64) Chrome/140.0"); req.set_header("cookie", "example_width=1280"); + // The entry point marks a request that arrived over TLS this way. + req.set_header("fastly-ssl", "1"); req } @@ -1096,8 +1145,9 @@ mod tests { assert_eq!( signals.platform_class.as_deref(), - Some("marked:example_width=1280"), - "the registered module should run and see the page's cookies" + Some("marked:example_width=1280:https://example.com/article?page=2"), + "the registered module should run and see the page's cookies and the request \ + it classifies" ); } diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index 87ea85c0dc..6e704e8dc9 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -1016,7 +1016,13 @@ fn build_router(state: &Arc) -> RouterService { let result = if method == Method::GET && path.starts_with("/static/tsjs=") { handle_tsjs_dynamic(&req, &state.registry, EdgeCacheHeader::SMaxageFallback) } else if state.registry.has_route(&method, &path) { - let mut ec_context = EcContext::default(); + // A module route is handed what was resolved for its request, + // the permissions among them, as the publisher path is. + let mut ec_context = match build_ec_context(&state.settings, &services, &req).await + { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; state .registry .handle_proxy(ProxyDispatchInput { diff --git a/crates/trusted-server-core/src/ec/consent.rs b/crates/trusted-server-core/src/ec/consent.rs index 57229d9b53..8cb9f2527e 100644 --- a/crates/trusted-server-core/src/ec/consent.rs +++ b/crates/trusted-server-core/src/ec/consent.rs @@ -18,7 +18,8 @@ use std::sync::Arc; use crate::consent::ConsentContext; use crate::consent::jurisdiction::Jurisdiction; -use crate::evidence::RequestInfo; +use crate::evidence::{OwnedRequestInfo, RequestInfo}; +use crate::module_context::ModuleContext; use crate::permission_signal::{self, PermissionSignalModule}; use crate::permissions::{ Acquisition, ConsentSignal, Permission, PermissionMaps, PermissionState, SignalPolicy, @@ -103,15 +104,28 @@ pub fn default_jurisdiction(maps: &PermissionMaps, geo: GeoStatus<'_>) -> Jurisd /// [`PermissionState::storage_withdrawn`], and declare the terms documents the /// request's data is available under, read through /// [`PermissionState::tdls`]. +/// +/// The modules are handed `context`, the request's module context, and the +/// signals are read from the consent and evidence it carries. A context +/// carrying neither assembles the state of a request that carried no signal. #[must_use] pub fn assemble_permissions( maps: &PermissionMaps, - consent: &ConsentContext, - evidence: &dyn RequestInfo, + context: &ModuleContext<'_>, geo: GeoStatus<'_>, modules: &[Arc], ) -> PermissionState { - let signal = permission_signal(consent, evidence, maps.signals(), modules); + let no_consent; + let consent = match context.consent() { + Some(consent) => consent, + None => { + no_consent = ConsentContext::default(); + &no_consent + } + }; + let no_evidence = OwnedRequestInfo::default(); + let evidence = context.evidence().unwrap_or(&no_evidence); + let signal = permission_signal(context, consent, evidence, maps.signals(), modules); let state = match geo { GeoStatus::Failed => PermissionMaps::floor_with(signal), GeoStatus::Located(_) | GeoStatus::NoLocation => { @@ -126,6 +140,7 @@ pub fn assemble_permissions( let withdrawn = permission_signal::withdrawn( modules, Permission::StoreOnDevice, + context, consent, evidence, maps.signals(), @@ -136,8 +151,8 @@ pub fn assemble_permissions( // worth a page waiting for. The rest are unset, not pending. .awaiting_only(permission_signal::answerable(modules, maps.signals())) .with_storage_withdrawn(withdrawn) - .with_tdls(permission_signal::tdls(modules, consent, evidence)) - .with_signals(permission_signal::signals(modules, consent, evidence)) + .with_tdls(permission_signal::tdls(modules, context)) + .with_signals(permission_signal::signals(modules, context)) } /// The acquisition rule for Edge Cookie storage in the request's resolved @@ -184,13 +199,16 @@ pub fn storage_acquisition(maps: &PermissionMaps, geo: GeoStatus<'_>) -> Acquisi /// /// [`combine`]: crate::permission_signal::combine fn permission_signal<'a>( + context: &'a ModuleContext<'a>, consent: &'a ConsentContext, evidence: &'a dyn RequestInfo, signals: &'a SignalPolicy, modules: &'a [Arc], ) -> impl Fn(Permission, Acquisition) -> ConsentSignal + 'a { move |permission, baseline| { - permission_signal::combine(modules, permission, consent, evidence, signals, baseline) + permission_signal::combine( + modules, permission, context, consent, evidence, signals, baseline, + ) } } @@ -199,7 +217,7 @@ mod tests { use http::HeaderMap; use super::*; - use crate::evidence::OwnedRequestInfo; + use crate::module_context::{ModuleCall, ModuleRequest}; use crate::permission_signal::SignalInput; use crate::permissions::{PermissionSet, ValidSignal}; use crate::test_support::tests::create_test_settings; @@ -233,11 +251,7 @@ mod tests { ConsentSignal::Neutral } - fn tdls( - &self, - _consent: &ConsentContext, - _evidence: &dyn crate::evidence::RequestInfo, - ) -> Vec { + fn tdls(&self, _call: ModuleCall<'_>) -> Vec { vec![ crate::tdl::Tdl::new("https://terms.example.com/marketing/2.txt") .expect("should accept the test locator"), @@ -258,13 +272,11 @@ mod tests { geo: GeoStatus<'_>, modules: &[Arc], ) -> PermissionState { - assemble_permissions( - PermissionMaps::sample(), - consent, - &no_evidence(), - geo, - modules, - ) + let evidence = no_evidence(); + let context = ModuleContext::new(crate::module_context::test_support::request("/")) + .with_consent(consent) + .with_evidence(&evidence); + assemble_permissions(PermissionMaps::sample(), &context, geo, modules) } fn us_ca_geo() -> GeoInfo { @@ -334,13 +346,7 @@ mod tests { fn a_module_declaring_terms_reaches_the_assembled_state() { let consent = ConsentContext::default(); let modules: Vec> = vec![Arc::new(DeclaringTerms)]; - let state = assemble_permissions( - PermissionMaps::sample(), - &consent, - &no_evidence(), - GeoStatus::NoLocation, - &modules, - ); + let state = assembled(&consent, GeoStatus::NoLocation, &modules); let addresses: Vec<&str> = state.tdls().iter().map(crate::tdl::Tdl::as_str).collect(); assert_eq!( addresses, @@ -352,13 +358,7 @@ mod tests { #[test] fn a_state_assembled_from_the_shipped_kind_of_module_declares_no_terms() { let consent = ConsentContext::default(); - let state = assemble_permissions( - PermissionMaps::sample(), - &consent, - &no_evidence(), - GeoStatus::NoLocation, - &granting(), - ); + let state = assembled(&consent, GeoStatus::NoLocation, &granting()); assert!( state.tdls().is_empty(), "should declare nothing, because a scheme carrying no terms says nothing about them" @@ -443,6 +443,15 @@ mod tests { /// standing in for a scheme that decoded it. struct VouchingForTcf; + impl VouchingForTcf { + fn vouch(&self, consent: &ConsentContext) -> Option { + consent + .raw_tc_string + .as_deref() + .map(|raw| ValidSignal::new("vouching", "tcf", raw)) + } + } + impl PermissionSignalModule for VouchingForTcf { fn id(&self) -> &'static str { "vouching" @@ -452,18 +461,59 @@ mod tests { ConsentSignal::Neutral } - fn valid_signal( - &self, - consent: &ConsentContext, - _evidence: &dyn crate::evidence::RequestInfo, - ) -> Option { - consent - .raw_tc_string - .as_deref() - .map(|raw| ValidSignal::new("vouching", "tcf", raw)) + fn valid_signal(&self, call: ModuleCall<'_>) -> Option { + call.inject(self, Self::vouch).ok().flatten() + } + } + + /// A module that grants storage only on one host, so a test can see the + /// request reach a signal module through its module call. + struct GrantingOnHost(&'static str); + + impl GrantingOnHost { + fn on_host(&self, request: ModuleRequest<'_>) -> bool { + request.host() == self.0 + } + } + + impl PermissionSignalModule for GrantingOnHost { + fn id(&self) -> &'static str { + "granting-on-host" + } + + fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal { + match input.call().inject(self, Self::on_host) { + Ok(true) if permission == Permission::StoreOnDevice => ConsentSignal::Grant, + _ => ConsentSignal::Neutral, + } + } + + fn grants(&self, _policy: &SignalPolicy) -> PermissionSet { + PermissionSet::none().with(Permission::StoreOnDevice) } } + #[test] + fn a_signal_module_is_handed_the_request_it_answers_for() { + // The top node requires a signal for storage, so storage is set only + // where the module grants it, and it grants only on its own host. + let consent = ConsentContext::default(); + let here: Vec> = + vec![Arc::new(GrantingOnHost("publisher.example"))]; + let elsewhere: Vec> = + vec![Arc::new(GrantingOnHost("elsewhere.example"))]; + + assert!( + assembled(&consent, GeoStatus::NoLocation, &here).is_set(Permission::StoreOnDevice), + "the module answers for the request it was handed" + ); + assert!( + !assembled(&consent, GeoStatus::NoLocation, &elsewhere) + .is_set(Permission::StoreOnDevice), + "and a request for another host is not that request" + ); + } + #[test] fn the_assembled_state_carries_the_signals_the_modules_vouched_for() { let consent = ConsentContext { @@ -593,13 +643,8 @@ mod tests { ) .expect("should parse the open policy"); - let state = assemble_permissions( - &open, - &ConsentContext::default(), - &no_evidence(), - GeoStatus::NoLocation, - &[], - ); + let context = ModuleContext::new(crate::module_context::test_support::request("/")); + let state = assemble_permissions(&open, &context, GeoStatus::NoLocation, &[]); assert!( state.is_set(Permission::StoreOnDevice), "the open policy should set storage without a signal" diff --git a/crates/trusted-server-core/src/ec/device.rs b/crates/trusted-server-core/src/ec/device.rs index 950942ecc2..41cec34968 100644 --- a/crates/trusted-server-core/src/ec/device.rs +++ b/crates/trusted-server-core/src/ec/device.rs @@ -1,11 +1,12 @@ //! Device signal derivation for bot detection and browser classification. //! //! The [`DeviceSignals`] derivation here is pure computation, with no KV I/O or -//! Fastly SDK calls. A [`DeviceModule`] is wired by dependency injection. It -//! reads the [`RequestInfo`] for the User-Agent from the borrowed argument -//! passed to `detect` at call time, and on a host that supplies them the -//! [`HostSignals`](crate::evidence::HostSignals) for the TLS and HTTP/2 signals -//! injected into its constructor, then classifies the request from both. +//! Fastly SDK calls. A [`DeviceModule`] is handed the request's module context +//! when `detect` is called and names what it reads from it, the +//! [`RequestInfo`] for the User-Agent among them. On a host that supplies +//! them, it also reads the [`HostSignals`](crate::evidence::HostSignals) for +//! the TLS and HTTP/2 signals injected into its constructor, then classifies +//! the request from both. //! //! # Signals //! @@ -21,6 +22,7 @@ use sha2::{Digest as _, Sha256}; use super::kv_types::KvDevice; use crate::evidence::RequestInfo; +use crate::module_context::ModuleCall; use crate::settings::Settings; /// Device signals derived from a single request. @@ -49,6 +51,13 @@ pub struct DeviceSignals { } impl DeviceSignals { + /// The signals of a request nothing could be read from, being an unknown + /// device that does not look like a browser. + #[must_use] + pub fn unknown() -> Self { + Self::derive_ua_only("") + } + /// Derives device signals from the User-Agent alone, with no /// host-specific TLS or HTTP/2 evidence. /// @@ -138,22 +147,21 @@ pub trait DeviceModule: Send + Sync { /// and logs. fn id(&self) -> &'static str; - /// Classifies the request into [`DeviceSignals`], reading the request data - /// it needs from the [`RequestInfo`] passed borrowed at call time (plus any - /// host signals injected into its constructor). + /// Classifies the request into [`DeviceSignals`]. + /// + /// See [`crate::module_context`] for what an implementation may name. + /// Classification runs before the permissions are resolved, so no value + /// whose use needs a permission is passed here. /// /// Device signals gate identity operations and must always yield a value, - /// so this is infallible: a module that cannot determine a signal returns - /// the unknown variant rather than failing the request. + /// so this is infallible. A module that cannot determine a signal, or + /// whose function was not passed what it names, returns + /// [`DeviceSignals::unknown`] rather than failing the request. /// Asynchronous because a device module may reach a backend, a /// key-value store or a secret to classify a request, and a module that /// cannot make those calls cannot be written at all. The built-in - /// User-Agent provider does no I/O and returns immediately. - async fn detect( - &self, - request_info: &dyn RequestInfo, - services: &crate::platform::RuntimeServices, - ) -> DeviceSignals; + /// User-Agent module does no I/O and returns immediately. + async fn detect(&self, call: ModuleCall<'_>) -> DeviceSignals; /// The permissions this module's data use requires. /// @@ -179,6 +187,11 @@ impl BuiltinDeviceModule { pub fn new() -> Self { Self } + + /// The signals the User-Agent alone gives. + fn classify(&self, request_info: &dyn RequestInfo) -> DeviceSignals { + DeviceSignals::derive_ua_only(request_info.user_agent()) + } } #[async_trait::async_trait(?Send)] @@ -187,12 +200,9 @@ impl DeviceModule for BuiltinDeviceModule { "builtin" } - async fn detect( - &self, - request_info: &dyn RequestInfo, - _services: &crate::platform::RuntimeServices, - ) -> DeviceSignals { - DeviceSignals::derive_ua_only(request_info.user_agent()) + async fn detect(&self, call: ModuleCall<'_>) -> DeviceSignals { + call.inject(self, Self::classify) + .unwrap_or_else(|_| DeviceSignals::unknown()) } } @@ -402,6 +412,7 @@ fn evaluate_known_browser(ja4_class: Option<&str>, h2_fp_hash: Option<&str>) -> mod tests { use super::*; use crate::evidence::OwnedRequestInfo; + use crate::module_context::{ModuleContext, test_support}; // Chrome Mac UA const CHROME_MAC_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ @@ -801,14 +812,12 @@ mod tests { let module = BuiltinDeviceModule::new(); assert_eq!(module.id(), "builtin"); - // The built-in module classifies from the User-Agent in the request - // info passed to `detect` alone, recording no host signal. + // The built-in module classifies from the User-Agent in the request's + // evidence alone, recording no host signal. let request_info = request_info_with_ua(CHROME_MAC_UA); + let context = ModuleContext::new(test_support::request("/")).with_evidence(&request_info); let signals = module - .detect( - &request_info, - &crate::platform::test_support::noop_services(), - ) + .detect(context.call(module.id(), module.required_permissions())) .await; assert_eq!( signals, @@ -821,6 +830,27 @@ mod tests { ); } + #[tokio::test] + async fn a_device_module_not_passed_what_it_names_answers_unknown() { + let module = BuiltinDeviceModule::new(); + // A context with no evidence, as for work shared by every reader. + let context = ModuleContext::new(test_support::request("/")); + + let signals = module + .detect(context.call(module.id(), module.required_permissions())) + .await; + + assert_eq!( + signals, + DeviceSignals::unknown(), + "should answer the unknown device rather than fail the request" + ); + assert!( + !signals.looks_like_browser, + "the unknown device should not look like a browser" + ); + } + /// Builds request info carrying the given User-Agent, for module tests. fn request_info_with_ua(user_agent: &str) -> OwnedRequestInfo { let mut headers = http::HeaderMap::new(); @@ -842,12 +872,8 @@ mod tests { "fastly" } - async fn detect( - &self, - _request_info: &dyn RequestInfo, - _services: &crate::platform::RuntimeServices, - ) -> DeviceSignals { - DeviceSignals::derive_ua_only("") + async fn detect(&self, _call: ModuleCall<'_>) -> DeviceSignals { + DeviceSignals::unknown() } } diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 267f1e4a91..fc20690038 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -334,7 +334,10 @@ async fn recover_orphaned_ec( // identifier. Whether the client IP is needed is that module's decision. const MAX_RECOVERY_ATTEMPTS: usize = 5; for _attempt in 0..MAX_RECOVERY_ATTEMPTS { - let ec_id = match ec_context.candidate_id(module.as_ref(), services).await { + let ec_id = match ec_context + .candidate_id(module.as_ref(), settings, services) + .await + { Ok(Some(ec_id)) => ec_id, Ok(None) => { log::info!("Orphan EC recovery skipped because the module produced no identifier"); @@ -2613,9 +2616,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _input: &crate::ec::module::IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> Result< crate::ec::module::GeneratedEdgeCookie, error_stack::Report, @@ -2741,9 +2742,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _input: &crate::ec::module::IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> Result< crate::ec::module::GeneratedEdgeCookie, error_stack::Report, diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 823b6f6285..583bf836ed 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -89,12 +89,13 @@ use crate::ec::cookies::ec_id_has_only_allowed_chars; use crate::error::TrustedServerError; use crate::evidence::BorrowedRequestInfo; use crate::geo::GeoInfo; +use crate::module_context::{ModuleContext, ModuleRequest, ResolvedRequest}; use crate::openrtb::Eid; use crate::permissions::{Permission, PermissionState}; use crate::platform::RuntimeServices; use crate::settings::Settings; use device::DeviceSignals; -use module::{EdgeCookieModule, GeneratedEdgeCookie, IdentityInput}; +use module::{EdgeCookieModule, GeneratedEdgeCookie}; use self::kv::{CreateIfAbsentOutcome, KvIdentityGraph}; use self::kv_types::KvEntry; @@ -278,18 +279,17 @@ pub struct EcContext { /// instead of being dropped by the built-in shape check. `None` when no /// module is configured. selected_module: Option>, - /// A snapshot of the request evidence a module reads when it creates an - /// identifier, at generation or in orphan recovery: the request headers (so - /// a module can read cookies and client hints), and the URL path and query - /// string (so it can read request parameters). Captured once at - /// construction, when a module is configured and the request either - /// carries no usable identifier or is a document navigation, the only - /// request that can recover an orphaned one. So a no-module deployment - /// and a returning visitor's subresource requests clone nothing. A module - /// reads these through [`RequestInfo`](crate::evidence::RequestInfo). + /// A snapshot of the request a module is handed when it creates an + /// identifier, at generation or in orphan recovery, being the request + /// headers (so a module can read cookies and client hints) and what the + /// request resolved to (its method, address, host and scheme). Captured + /// once at construction, when a module is configured and the request + /// either carries no usable identifier or is a document navigation, the + /// only request that can recover an orphaned one. So a no-module + /// deployment and a returning visitor's subresource requests clone + /// nothing. A module reads them through its module call. request_headers: http::HeaderMap, - request_path: String, - request_query: String, + request: ResolvedRequest, /// Response headers a module asked to set, captured when it creates an /// identifier (in [`EcContext::generate_if_needed`], or during orphan /// recovery in EC finalization) and applied to the response by EC @@ -435,24 +435,16 @@ impl EcContext { log::trace!("Existing EC ID found: {}", log_id(id)); } - // Snapshot the request evidence a module reads when it creates an - // identifier (the headers, so it can read cookies and client hints, and - // the URL path and query, so it can read request parameters). Capture - // only when a module is configured and either no identifier exists or - // the request is a document navigation, which orphan recovery may need, - // so a no-module deployment and a returning visitor's subresource - // requests clone nothing. Creation runs after the request body may be - // consumed, so the snapshot is owned. - let (request_headers, request_path, request_query) = if selected_module.is_some() + // Owned, because creation runs after the request body may be consumed. + let (request_headers, request) = if selected_module.is_some() && (ec_value.is_none() || crate::http_util::is_navigation_request(req)) { ( req.headers().clone(), - req.uri().path().to_owned(), - req.uri().query().unwrap_or_default().to_owned(), + ResolvedRequest::of(req, services.client_info()), ) } else { - (http::HeaderMap::new(), String::new(), String::new()) + (http::HeaderMap::new(), ResolvedRequest::default()) }; // Capture the client IP from platform services (normalized). @@ -492,10 +484,18 @@ impl EcContext { Some(req.headers()), ) .with_request_target(req.uri().path(), req.uri().query().unwrap_or_default()); + let mut module_context = ModuleContext::new(ModuleRequest::of(req, services.client_info())) + .with_evidence(&evidence) + .with_consent(&consent) + .with_settings(settings) + .with_services(services) + .with_client(services.client_info()); + if let Some(geo) = geo_info { + module_context = module_context.with_geo(geo, services.geo().required_permissions()); + } let permissions = consent::assemble_permissions( maps, - &consent, - &evidence, + &module_context, geo_status, services.permission_signal_modules(), ); @@ -531,8 +531,7 @@ impl EcContext { device_signals: None, selected_module, request_headers, - request_path, - request_query, + request, response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, @@ -618,10 +617,7 @@ impl EcContext { /// identifier, and the finished identifier, with its module code applied, /// is checked against the global bounds. Generation and orphan recovery both /// use this, so a rotated identifier comes from the same module - /// and passes the same checks as a new one. The request evidence captured at - /// read time (client IP, headers, and the URL path and query) is passed - /// borrowed through [`RequestInfo`](crate::evidence::RequestInfo), and the - /// built-in HMAC module reads only the client IP. + /// and passes the same checks as a new one. /// /// # Errors /// @@ -635,19 +631,22 @@ impl EcContext { pub(crate) async fn candidate_id( &mut self, ec_module: &dyn EdgeCookieModule, + settings: &Settings, services: &RuntimeServices, ) -> Result, Report> { - let input = IdentityInput { - permissions: Some(&self.permissions), - consent: Some(&self.consent), - }; + let request = self.request.view(); let request_info = BorrowedRequestInfo::new( self.client_ip.as_deref().unwrap_or_default(), Some(&self.request_headers), ) - .with_request_target(&self.request_path, &self.request_query); - let generated: GeneratedEdgeCookie = - ec_module.generate(&request_info, &input, services).await?; + .with_request_target(request.path(), request.query()); + let context = ModuleContext::new(request) + .with_evidence(&request_info) + .with_settings(settings) + .with_request_state(self, services); + let generated: GeneratedEdgeCookie = ec_module + .generate(context.call(ec_module.id(), ec_module.required_permissions())) + .await?; // Check every response header the module asked for against core's // reserved surface before any of them are kept. A module may set its // own cookies and headers, but not a managed `ts-` cookie, a header in @@ -745,7 +744,7 @@ impl EcContext { ) -> Result<(), Report> { const MAX_CREATE_ATTEMPTS: usize = 5; for attempt in 0..MAX_CREATE_ATTEMPTS { - let Some(ec_id) = self.candidate_id(ec_module, services).await? else { + let Some(ec_id) = self.candidate_id(ec_module, settings, services).await? else { return Ok(()); }; // Key the identity graph by the module's canonical form of the @@ -1028,6 +1027,14 @@ impl EcContext { &mut self.pull_sync_marker } + /// The same test-only context with a resolved location. + #[cfg(test)] + #[must_use] + pub(crate) fn with_geo_for_test(mut self, geo: GeoInfo) -> Self { + self.geo_info = Some(geo); + self + } + /// Sets pull-sync marker state in focused unit tests. #[cfg(test)] pub(crate) fn set_pull_sync_marker_for_test(&mut self, state: PullSyncMarkerState) { @@ -1170,8 +1177,7 @@ impl EcContext { device_signals: None, selected_module: None, request_headers: http::HeaderMap::new(), - request_path: String::new(), - request_query: String::new(), + request: ResolvedRequest::default(), response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, @@ -1217,8 +1223,7 @@ impl EcContext { device_signals: None, selected_module: None, request_headers: http::HeaderMap::new(), - request_path: String::new(), - request_query: String::new(), + request: ResolvedRequest::default(), response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, @@ -1253,8 +1258,7 @@ impl EcContext { device_signals: None, selected_module: None, request_headers: http::HeaderMap::new(), - request_path: String::new(), - request_query: String::new(), + request: ResolvedRequest::default(), response_headers: Vec::new(), kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, @@ -1354,6 +1358,7 @@ pub(crate) mod tests { }; use crate::ec::module::{EcModuleSelection, ModuleCode}; use crate::evidence::RequestInfo; + use crate::module_context::{ModuleCall, ModuleRequest}; use crate::platform::test_support::noop_services; use crate::test_support::tests::create_test_settings; @@ -1536,9 +1541,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1660,31 +1663,28 @@ pub(crate) mod tests { ); } - /// A module that records the request query parameter `id` and the `Cookie` - /// header it is given at generate time, proving request evidence (parameters - /// and cookies) reaches a module through the organic generate path. + /// A module that records the request query parameter `id`, the `Cookie` + /// header, the client IP and the host and address the request resolved + /// to, as it is given them at generate time, proving the request reaches a + /// module through the organic generate path. #[derive(Debug, Default)] struct EvidenceCapturingModule { seen: std::sync::Mutex>, seen_client_ip: std::sync::Mutex>, + seen_request: std::sync::Mutex>, } - #[async_trait::async_trait(?Send)] - impl EdgeCookieModule for EvidenceCapturingModule { - fn id(&self) -> &'static str { - "evidence" - } - - fn code(&self) -> ModuleCode { - crate::module_code!("t0ev") - } - - async fn generate( + impl EvidenceCapturingModule { + fn capture( &self, request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + request: ModuleRequest<'_>, ) -> Result> { + *self + .seen_request + .lock() + .expect("should lock the seen request") = + Some((request.host().to_owned(), request.path().to_owned())); let query_id = request_info.query_param("id").unwrap_or_default(); let cookie = request_info.header("cookie").unwrap_or_default().to_owned(); *self.seen.lock().expect("should lock seen evidence") = Some((query_id, cookie)); @@ -1698,6 +1698,24 @@ pub(crate) mod tests { response_headers: Vec::new(), }) } + } + + #[async_trait::async_trait(?Send)] + impl EdgeCookieModule for EvidenceCapturingModule { + fn id(&self) -> &'static str { + "evidence" + } + + fn code(&self) -> ModuleCode { + crate::module_code!("t0ev") + } + + async fn generate( + &self, + call: ModuleCall<'_>, + ) -> Result> { + call.inject(self, Self::capture)? + } fn accepts_id(&self, value: &str) -> bool { !value.is_empty() @@ -1717,6 +1735,7 @@ pub(crate) mod tests { let req = Request::builder() .method("GET") .uri("http://example.com/page?id=abc123&debug=1") + .header("host", "example.com") .header("cookie", "client-id=xyz789") .body(EdgeBody::empty()) .expect("should build request"); @@ -1739,6 +1758,15 @@ pub(crate) mod tests { Some(("abc123".to_owned(), "client-id=xyz789".to_owned())), "the module should read the request query parameter and cookies at generate time" ); + assert_eq!( + module + .seen_request + .lock() + .expect("should lock the seen request") + .clone(), + Some(("example.com".to_owned(), "/page".to_owned())), + "the module should be handed the host and address the request resolved to" + ); assert_eq!( ec.ec_value(), Some("t0ev~evidence-ec"), @@ -1771,7 +1799,7 @@ pub(crate) mod tests { .expect("should read EC context"); assert!(ec.ec_was_present(), "the identifier should be read back"); let selected = ec.selected_module().expect("a module is selected"); - ec.candidate_id(selected.as_ref(), &services) + ec.candidate_id(selected.as_ref(), &settings, &services) .await .expect("should create a replacement"); @@ -1805,9 +1833,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie { id: Some("Opaque_EC_Value_MixedCase_123".to_owned()), @@ -1929,24 +1955,13 @@ pub(crate) mod tests { /// config store at generate time, which is the whole point of handing modules /// the platform services. Nothing about the value is known when the /// module is constructed, so an identifier carrying it can only come from - /// a real read through the services passed to `generate`. + /// a real read through the services it names in `generate`. #[derive(Debug)] struct ConfigReadingModule; - #[async_trait::async_trait(?Send)] - impl EdgeCookieModule for ConfigReadingModule { - fn id(&self) -> &'static str { - "config-reading" - } - - fn code(&self) -> ModuleCode { - crate::module_code!("t0cr") - } - - async fn generate( + impl ConfigReadingModule { + fn read_tenant( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, services: &crate::platform::RuntimeServices, ) -> Result> { let tenant = services @@ -1962,6 +1977,24 @@ pub(crate) mod tests { response_headers: Vec::new(), }) } + } + + #[async_trait::async_trait(?Send)] + impl EdgeCookieModule for ConfigReadingModule { + fn id(&self) -> &'static str { + "config-reading" + } + + fn code(&self) -> ModuleCode { + crate::module_code!("t0cr") + } + + async fn generate( + &self, + call: ModuleCall<'_>, + ) -> Result> { + call.inject(self, Self::read_tenant)? + } fn accepts_id(&self, value: &str) -> bool { !value.is_empty() @@ -2111,9 +2144,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie { id: Some("bad;value with spaces".to_owned()), @@ -2174,9 +2205,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie { id: self.id.map(str::to_owned), @@ -2518,9 +2547,7 @@ pub(crate) mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie { id: Some("MiXeD.CaseId".to_owned()), diff --git a/crates/trusted-server-core/src/ec/module.rs b/crates/trusted-server-core/src/ec/module.rs index 5cfb6afdd3..d5acf6e8fd 100644 --- a/crates/trusted-server-core/src/ec/module.rs +++ b/crates/trusted-server-core/src/ec/module.rs @@ -16,12 +16,13 @@ //! [`is_request_scoped`](EdgeCookieModule::is_request_scoped)). //! //! Request evidence reaches a module at call time rather than at -//! construction. [`EdgeCookieModule::generate`] borrows a [`RequestInfo`], -//! which carries the normalized client IP, the User-Agent and the request -//! headers, for the life of the call, alongside an [`IdentityInput`] holding -//! the request's gating context. A module reads what it needs and retains -//! nothing. Core snapshots the headers, path and query it lends to the module -//! at generate time, and the module itself keeps none of it. +//! construction. [`EdgeCookieModule::generate`] is handed a [`ModuleCall`], +//! and the module names what it reads from the request's module context, such +//! as the [`RequestInfo`] carrying the normalized client IP, the User-Agent and +//! the request headers, or the permissions and consent resolved for the +//! request. A module reads what it needs and retains nothing. Core snapshots +//! the headers and what the request resolved to at read time, for generation +//! later in the request, and the module itself keeps none of it. //! //! [`HmacModule`] is the built-in server-side implementation. It derives the //! identifier from the client IP using HMAC over the configured passphrase. @@ -31,10 +32,10 @@ use std::sync::Arc; use error_stack::Report; use serde::{Deserialize, Serialize}; -use crate::consent::ConsentContext; use crate::error::TrustedServerError; use crate::evidence::{HostSignals, RequestInfo}; -use crate::permissions::{Permission, PermissionSet, PermissionState}; +use crate::module_context::ModuleCall; +use crate::permissions::{Permission, PermissionSet}; use crate::redacted::Redacted; use crate::settings::{Ec, EcModuleBlock}; @@ -164,53 +165,6 @@ const BUILTIN_MODULE_KEYS: &[&str] = &[ /// enveloped identifier is one of its own. pub const HMAC_MODULE_CODE: ModuleCode = crate::module_code!(HMAC_MODULE_KEY); -/// The request-scoped gating context passed to [`EdgeCookieModule::generate`]. -/// -/// Request data reaches a module through the `request_info` parameter of -/// [`EdgeCookieModule::generate`], not through this struct and not through -/// anything injected into the module's constructor. This struct carries only -/// the per-request gating context a module may read for behavior beyond -/// gating. On the organic request path the gate has confirmed the module's -/// required permissions are set before `generate` is called. A test calling -/// `edge_cookie::generate_ec_id` reaches `generate` without that gate. -#[derive(Default)] -pub struct IdentityInput<'a> { - /// The permissions resolved for this request, when the calling path carries - /// them. A module reads this only for behavior beyond gating. The main - /// organic path supplies them; the publisher path passes `None`. - pub permissions: Option<&'a PermissionState>, - - /// The request's consent context, when available, for module-specific - /// logic. The core gates on permissions, not consent, so a module reads - /// this only to forward or record consent. [`HmacModule`] ignores it. - pub consent: Option<&'a ConsentContext>, -} - -/// Inputs available to [`EdgeCookieModule::resolve_from_client`]. -/// -/// Carries the value a client produced and posted to the Edge Cookie resolve -/// endpoint, alongside the same gating context as [`IdentityInput`]. The posted -/// value reaches the module as [`payload`](Self::payload), not through -/// anything injected into the module. Unlike trusted edge-derived -/// data, [`payload`](Self::payload) arrives from the browser, so an -/// implementation must verify it before deriving an identifier from it. -pub struct ClientResolveInput<'a> { - /// The raw body the client posted to the resolve endpoint. For a vendor - /// module this is its own JSON envelope; for the built-in - /// `ClientFixedModule` demo it is the fixed known word the page script - /// posts. - pub payload: &'a [u8], - - /// The permissions resolved for the resolve request. The endpoint has - /// already confirmed the module's required permissions are set, so a - /// module reads this only for behavior beyond gating. - pub permissions: Option<&'a PermissionState>, - - /// The resolve request's consent context, for module-specific logic. The - /// core gates on permissions, not consent. - pub consent: Option<&'a ConsentContext>, -} - /// The outcome of [`EdgeCookieModule::generate`]. /// /// Carries the derived identifier, if any, and any response headers the module @@ -659,8 +613,12 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { false } - /// Derives an Edge Cookie identifier from the request evidence in - /// `request_info` and the gating context in `input`. + /// Derives an Edge Cookie identifier for the request. + /// + /// See [`crate::module_context`] for what an implementation may name. On + /// the request path core calls this only once the permissions the module + /// declares are set. A function that is not passed what it names produces + /// no identifier for the request. /// /// A server-side module creates here. A client-side module defers here /// (returns `id: None`) and creates later in @@ -670,16 +628,14 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when derivation fails. - /// Asynchronous, and handed the platform services, because a module may - /// reach a backend, a key-value store or a secret to derive an identifier, - /// and a module that cannot make those calls cannot be written at all. - /// A module that derives from data already in hand still declares an - /// async method and returns immediately. + /// Asynchronous, and able to name the platform services, because a module + /// may reach a backend, a key-value store or a secret to derive an + /// identifier, and a module that cannot make those calls cannot be written + /// at all. A module that derives from data already in hand still declares + /// an async method and returns immediately. async fn generate( &self, - request_info: &dyn RequestInfo, - input: &IdentityInput<'_>, - services: &crate::platform::RuntimeServices, + call: ModuleCall<'_>, ) -> Result>; /// Returns whether `value` is a well-formed identifier this module issues. @@ -735,31 +691,36 @@ pub trait EdgeCookieModule: Send + Sync + core::fmt::Debug { PermissionSet::none() } - /// Derives an Edge Cookie identifier from a value the client produced and - /// posted to the resolve endpoint (`POST /_ts/api/v1/ec/resolve`). + /// Derives an Edge Cookie identifier from `payload`, the value the client + /// produced and posted to the resolve endpoint + /// (`POST /_ts/api/v1/ec/resolve`). /// /// This is the client-side counterpart to [`generate`](Self::generate). A /// module that cannot derive an identifier at the edge defers from /// `generate` (returning `id: None`, optionally with response headers that /// trigger client-side work), and the page posts its result back here. The /// payload arrives from the browser, so an implementation MUST verify it - /// (for example checking a signature) before trusting it. The default - /// returns no identifier, so a module that creates entirely server-side - /// (such as [`HmacModule`]) need not implement it. + /// (for example checking a signature) before trusting it. It names what + /// else it reads from the resolve request's module context through + /// [`ModuleCall::inject_with`], with the payload as its own argument. The + /// endpoint has already confirmed the permissions the module declares are + /// set. The default returns no identifier, so a module that creates + /// entirely server-side (such as [`HmacModule`]) need not implement it. /// /// # Errors /// /// Returns [`TrustedServerError::EdgeCookie`] when processing the payload /// fails. A payload that is merely unverified or absent yields `id: None` /// rather than an error, so the request proceeds without an Edge Cookie. - /// Asynchronous, and handed the platform services, for the same reason as - /// [`generate`](Self::generate). Verifying a payload the browser posted is - /// the case that most needs them, because checking a signature or a nonce - /// generally means reading a secret or calling the vendor's backend. + /// Asynchronous, and able to name the platform services, for the same + /// reason as [`generate`](Self::generate). Verifying a payload the browser + /// posted is the case that most needs them, because checking a signature + /// or a nonce generally means reading a secret or calling the vendor's + /// backend. async fn resolve_from_client( &self, - _input: &ClientResolveInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, + _payload: &[u8], ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -789,23 +750,12 @@ impl HmacModule { pub fn new(passphrase: Redacted) -> Self { Self { passphrase } } -} - -#[async_trait::async_trait(?Send)] -impl EdgeCookieModule for HmacModule { - fn id(&self) -> &'static str { - HMAC_MODULE_KEY - } - - fn code(&self) -> ModuleCode { - HMAC_MODULE_CODE - } - async fn generate( + /// The identifier the client IP in `request_info` gives under the + /// passphrase. + fn derive( &self, request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, ) -> Result> { let client_ip = request_info.client_ip(); if client_ip.is_empty() { @@ -821,6 +771,25 @@ impl EdgeCookieModule for HmacModule { response_headers: Vec::new(), }) } +} + +#[async_trait::async_trait(?Send)] +impl EdgeCookieModule for HmacModule { + fn id(&self) -> &'static str { + HMAC_MODULE_KEY + } + + fn code(&self) -> ModuleCode { + HMAC_MODULE_CODE + } + + async fn generate( + &self, + call: ModuleCall<'_>, + ) -> Result> { + call.inject(self, Self::derive) + .unwrap_or_else(|_| Ok(GeneratedEdgeCookie::default())) + } fn required_permissions(&self) -> PermissionSet { // The HMAC module writes the Edge Cookie to the device, so it requires @@ -852,29 +821,12 @@ impl HostSignalModule { host_signals, } } -} - -#[async_trait::async_trait(?Send)] -impl EdgeCookieModule for HostSignalModule { - fn id(&self) -> &'static str { - HOST_SIGNALS_MODULE_KEY - } - - // Built from the signals of one request, so it is only ever valid for - // that request and must never be kept and reused. - fn is_request_scoped(&self) -> bool { - true - } - - fn code(&self) -> ModuleCode { - crate::module_code!("hs00") - } - async fn generate( + /// The identifier the host signals and the client IP in `request_info` + /// give under the passphrase, or none without any host signal. + fn derive( &self, request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, ) -> Result> { let ja4 = self.host_signals.ja4().unwrap_or_default(); let h2 = self.host_signals.h2().unwrap_or_default(); @@ -894,6 +846,31 @@ impl EdgeCookieModule for HostSignalModule { response_headers: Vec::new(), }) } +} + +#[async_trait::async_trait(?Send)] +impl EdgeCookieModule for HostSignalModule { + fn id(&self) -> &'static str { + HOST_SIGNALS_MODULE_KEY + } + + // Built from the signals of one request, so it is only ever valid for + // that request and must never be kept and reused. + fn is_request_scoped(&self) -> bool { + true + } + + fn code(&self) -> ModuleCode { + crate::module_code!("hs00") + } + + async fn generate( + &self, + call: ModuleCall<'_>, + ) -> Result> { + call.inject(self, Self::derive) + .unwrap_or_else(|_| Ok(GeneratedEdgeCookie::default())) + } fn required_permissions(&self) -> PermissionSet { // Writes the Edge Cookie to the device, so it requires necessary.operations.storage @@ -947,25 +924,23 @@ impl EdgeCookieModule for ClientFixedModule { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { - // No identifier is derived at the edge: the value comes from the page - // script, which posts it to the resolve endpoint. + // No identifier is derived at the edge, because the value comes from + // the page script, which posts it to the resolve endpoint. Ok(GeneratedEdgeCookie::default()) } async fn resolve_from_client( &self, - input: &ClientResolveInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, + payload: &[u8], ) -> Result> { // Verify the posted value against the known shared word, then create it as // the Edge Cookie. A value that does not match yields no Edge Cookie. // This stands in for a real module's verification (for example // checking a signature) before it trusts a client-supplied value. - let matches = core::str::from_utf8(input.payload) + let matches = core::str::from_utf8(payload) .map(str::trim) .is_ok_and(|value| value == EXPECTED_VALUE); @@ -1390,11 +1365,9 @@ impl EdgeCookieModule for SharedModule { async fn generate( &self, - request_info: &dyn RequestInfo, - input: &IdentityInput<'_>, - services: &crate::platform::RuntimeServices, + call: ModuleCall<'_>, ) -> Result> { - self.0.generate(request_info, input, services).await + self.0.generate(call).await } fn accepts_id(&self, value: &str) -> bool { @@ -1411,10 +1384,10 @@ impl EdgeCookieModule for SharedModule { async fn resolve_from_client( &self, - input: &ClientResolveInput<'_>, - services: &crate::platform::RuntimeServices, + call: ModuleCall<'_>, + payload: &[u8], ) -> Result> { - self.0.resolve_from_client(input, services).await + self.0.resolve_from_client(call, payload).await } } @@ -1422,6 +1395,7 @@ impl EdgeCookieModule for SharedModule { mod tests { use super::*; use crate::evidence::OwnedRequestInfo; + use crate::module_context::ModuleContext; use crate::platform::test_support::noop_services; use crate::test_support::tests::{select_hmac_module, select_host_signals_module}; use http::HeaderMap; @@ -1619,9 +1593,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1846,6 +1818,21 @@ mod tests { Redacted::from("a-test-passphrase-32-bytes-minimum".to_owned()) } + /// What `module` generates with `request_info` as the request's evidence, + /// asked through its module call as core asks it. + async fn generated_by( + module: &dyn EdgeCookieModule, + request_info: &OwnedRequestInfo, + ) -> Result> { + let services = noop_services(); + let context = ModuleContext::new(crate::module_context::test_support::request("/")) + .with_evidence(request_info) + .with_services(&services); + module + .generate(context.call(module.id(), module.required_permissions())) + .await + } + fn test_request_info() -> OwnedRequestInfo { OwnedRequestInfo::new("203.0.113.1".to_owned(), HeaderMap::new()) } @@ -1913,9 +1900,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -1970,9 +1955,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -2037,9 +2020,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -2114,8 +2095,7 @@ mod tests { }); let module = HostSignalModule::new(test_passphrase(), signals); let request_info = test_request_info(); - let generated = module - .generate(&request_info, &IdentityInput::default(), &noop_services()) + let generated = generated_by(&module, &request_info) .await .expect("should generate"); assert!( @@ -2147,9 +2127,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } @@ -2194,8 +2172,7 @@ mod tests { #[tokio::test] async fn client_fixed_defers_in_generate() { let request_info = test_request_info(); - let generated = ClientFixedModule - .generate(&request_info, &IdentityInput::default(), &noop_services()) + let generated = generated_by(&ClientFixedModule, &request_info) .await .expect("should generate"); assert!( @@ -2231,13 +2208,12 @@ mod tests { ("hmac with any payload", &hmac, b"anything", None), ]; for (case, module, payload, expected) in cases { - let input = ClientResolveInput { - payload, - permissions: None, - consent: None, - }; + let context = ModuleContext::new(crate::module_context::test_support::request("/")); let generated = module - .resolve_from_client(&input, &noop_services()) + .resolve_from_client( + context.call(module.id(), module.required_permissions()), + payload, + ) .await .unwrap_or_else(|err| panic!("{case}: should resolve, got: {err}")); assert_eq!( @@ -2321,8 +2297,7 @@ mod tests { }); let module = HostSignalModule::new(test_passphrase(), signals); let request_info = test_request_info(); - let generated = module - .generate(&request_info, &IdentityInput::default(), &noop_services()) + let generated = generated_by(&module, &request_info) .await .expect("should generate"); assert!( diff --git a/crates/trusted-server-core/src/ec/resolve.rs b/crates/trusted-server-core/src/ec/resolve.rs index faeb16d829..be0b71c058 100644 --- a/crates/trusted-server-core/src/ec/resolve.rs +++ b/crates/trusted-server-core/src/ec/resolve.rs @@ -19,13 +19,15 @@ use error_stack::Report; use http::{HeaderValue, Request, Response, StatusCode, header}; use crate::error::TrustedServerError; +use crate::evidence::BorrowedRequestInfo; +use crate::module_context::{ModuleContext, ResolvedRequest}; use crate::settings::Settings; use super::EcContext; use super::cookies::{ec_id_has_only_allowed_chars, set_ec_cookie, set_resolved_marker_cookie}; use super::kv::KvIdentityGraph; use super::kv_types::KvEntry; -use super::module::{ClientResolveInput, apply_module_response_headers}; +use super::module::apply_module_response_headers; /// Maximum size of a resolve request body. /// @@ -111,18 +113,32 @@ pub async fn handle_ec_resolve( if content_length_exceeds_limit(&req, MAX_BODY_SIZE) { return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); } - let payload = req.into_body().into_bytes().unwrap_or_default(); + let resolved = ResolvedRequest::of(&req, services.client_info()); + let (parts, body) = req.into_parts(); + let payload = body.into_bytes().unwrap_or_default(); if payload.len() > MAX_BODY_SIZE { return Ok(status_only(StatusCode::PAYLOAD_TOO_LARGE)); } - let input = ClientResolveInput { - payload: payload.as_ref(), - permissions: Some(ec_context.permissions()), - consent: Some(ec_context.consent()), - }; - - let generated = module.resolve_from_client(&input, services).await?; + // The module is handed the resolve request's own context, its evidence + // and what was resolved for it, with the posted value as its argument. + // The client IP is the normalized one the request's Edge Cookie state + // holds, the same form the module sees when it generates. + let evidence = BorrowedRequestInfo::new( + ec_context.client_ip().unwrap_or_default(), + Some(&parts.headers), + ) + .with_request_target(parts.uri.path(), parts.uri.query().unwrap_or_default()); + let context = ModuleContext::new(resolved.view()) + .with_evidence(&evidence) + .with_settings(settings) + .with_request_state(ec_context, services); + let generated = module + .resolve_from_client( + context.call(module.id(), module.required_permissions()), + payload.as_ref(), + ) + .await?; log::debug!( "EC resolve handled (module={}): id {}", module.id(), @@ -410,10 +426,9 @@ mod tests { use crate::ec::finalize::ec_finalize_response; use crate::ec::module::{ CLIENT_FIXED_MODULE_KEY, EcModuleSelection, EdgeCookieModule, GeneratedEdgeCookie, - IdentityInput, }; use crate::ec::registry::PartnerRegistry; - use crate::evidence::RequestInfo; + use crate::module_context::{ModuleCall, ModuleRequest}; use crate::permission_signal::{PermissionSignalModule, SignalInput}; use crate::permissions::{ConsentSignal, Permission}; use crate::platform::test_support::{ @@ -504,8 +519,12 @@ mod tests { /// (like any opaque-identifier module) returns the value unchanged. /// /// [`normalize_id_for_kv`]: EdgeCookieModule::normalize_id_for_kv - #[derive(Debug)] - struct TestIdModule; + #[derive(Debug, Default)] + struct TestIdModule { + /// The address the module was last asked for, so a test can read + /// back the request it was handed. + seen_path: std::sync::Mutex>, + } #[async_trait::async_trait(?Send)] impl EdgeCookieModule for TestIdModule { @@ -519,9 +538,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { // The identifier is created in the browser, so the edge derives nothing. Ok(GeneratedEdgeCookie::default()) @@ -529,13 +546,33 @@ mod tests { async fn resolve_from_client( &self, - input: &ClientResolveInput<'_>, - _services: &crate::platform::RuntimeServices, + call: ModuleCall<'_>, + payload: &[u8], + ) -> Result> { + call.inject_with(self, payload, Self::verify)? + } + + fn accepts_id(&self, value: &str) -> bool { + is_uuid(value) + } + + fn normalize_id_for_kv(&self, value: &str) -> String { + value.to_owned() + } + } + + impl TestIdModule { + /// Accepts a well-formed UUID, recording the address of the request + /// it was handed. + fn verify( + &self, + payload: &[u8], + request: ModuleRequest<'_>, ) -> Result> { + *self.seen_path.lock().expect("should lock the seen address") = + Some(request.path().to_owned()); // The page posts the identifier it generated. Accept a well-formed UUID. - let value = core::str::from_utf8(input.payload) - .unwrap_or_default() - .trim(); + let value = core::str::from_utf8(payload).unwrap_or_default().trim(); if is_uuid(value) { Ok(GeneratedEdgeCookie { id: Some(value.to_owned()), @@ -545,14 +582,6 @@ mod tests { Ok(GeneratedEdgeCookie::default()) } } - - fn accepts_id(&self, value: &str) -> bool { - is_uuid(value) - } - - fn normalize_id_for_kv(&self, value: &str) -> String { - value.to_owned() - } } /// The identifier [`ResolveHeaderModule`] creates when asked to. @@ -580,17 +609,15 @@ mod tests { async fn generate( &self, - _request_info: &dyn RequestInfo, - _input: &IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, ) -> Result> { Ok(GeneratedEdgeCookie::default()) } async fn resolve_from_client( &self, - _input: &ClientResolveInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: ModuleCall<'_>, + _payload: &[u8], ) -> Result> { Ok(GeneratedEdgeCookie { id: self.id.map(str::to_owned), @@ -813,7 +840,8 @@ mod tests { // A client-generated first-party UUID, the value the browser posts back. const TEST_ID: &str = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; - let module: Arc = Arc::new(TestIdModule); + let test_id = Arc::new(TestIdModule::default()); + let module: Arc = test_id.clone(); let mut settings = create_test_settings(); settings.ec.module = Some(EcModuleSelection::from("testid")); let services = noop_services_with_ec_module(Arc::clone(&module)); @@ -869,6 +897,15 @@ mod tests { .is_some(), "the resolve should persist the identity-graph row, so withdrawal can reach it" ); + assert_eq!( + test_id + .seen_path + .lock() + .expect("should lock the seen address") + .as_deref(), + Some("/_ts/api/v1/ec/resolve"), + "the module is handed the resolve request it answers" + ); // 3. A later request carries the EC cookie, and the identifier reads // back verbatim because the module's own `accepts_id` decides its diff --git a/crates/trusted-server-core/src/edge_cookie.rs b/crates/trusted-server-core/src/edge_cookie.rs index c649318263..46e47922e6 100644 --- a/crates/trusted-server-core/src/edge_cookie.rs +++ b/crates/trusted-server-core/src/edge_cookie.rs @@ -15,12 +15,12 @@ use crate::cookies::handle_request_cookies; use crate::ec::cookies::ec_id_has_only_allowed_chars; #[cfg(test)] use crate::ec::generation::normalize_ip; -#[cfg(test)] -use crate::ec::module::IdentityInput; use crate::ec::module::{module_owns_id, request_module}; use crate::error::TrustedServerError; #[cfg(test)] use crate::evidence::BorrowedRequestInfo; +#[cfg(test)] +use crate::module_context::{ModuleContext, test_support}; use crate::platform::RuntimeServices; use crate::settings::Settings; @@ -65,10 +65,13 @@ pub async fn generate_ec_id( // than from this request info. let request_info = BorrowedRequestInfo::new(&client_ip, request_headers); // This helper skips the permission gate, and the built-in module reads - // neither the resolved permissions nor the consent context, so they are - // not threaded here. + // neither the resolved permissions nor the consent context, so the + // context carries the request's evidence and the services alone. + let context = ModuleContext::new(test_support::request("/")) + .with_evidence(&request_info) + .with_services(services); let generated = module - .generate(&request_info, &IdentityInput::default(), services) + .generate(context.call(module.id(), module.required_permissions())) .await?; let generated = crate::ec::module::GeneratedEdgeCookie { id: generated diff --git a/crates/trusted-server-core/src/http_util.rs b/crates/trusted-server-core/src/http_util.rs index e12eefdbbb..8b61a73568 100644 --- a/crates/trusted-server-core/src/http_util.rs +++ b/crates/trusted-server-core/src/http_util.rs @@ -152,18 +152,30 @@ impl RequestInfo { /// [`sanitize_forwarded_headers`] at the edge, so `Host` and /// [`ClientInfo`] TLS detection are the only sources that fire. pub fn from_request(req: &Request, client_info: &ClientInfo) -> Self { - let host = extract_request_host(req); - let scheme = detect_request_scheme( - req, - client_info.tls_protocol.as_deref(), - client_info.tls_cipher.as_deref(), - ); - - Self { host, scheme } + Self { + host: request_host(req).to_owned(), + scheme: request_scheme(req, client_info).to_owned(), + } } } -fn extract_request_host(req: &Request) -> String { +/// The host a request was made for, read as [`RequestInfo::from_request`] +/// reads it, borrowed from the request. +pub(crate) fn request_host(req: &Request) -> &str { + extract_request_host(req) +} + +/// The scheme a request was made over, read as +/// [`RequestInfo::from_request`] reads it. +pub(crate) fn request_scheme(req: &Request, client_info: &ClientInfo) -> &'static str { + detect_request_scheme( + req, + client_info.tls_protocol.as_deref(), + client_info.tls_cipher.as_deref(), + ) +} + +fn extract_request_host(req: &Request) -> &str { req.headers() .get("forwarded") .and_then(|h| h.to_str().ok()) @@ -180,7 +192,6 @@ fn extract_request_host(req: &Request) -> String { .and_then(|h| h.to_str().ok()) }) .unwrap_or_default() - .to_owned() } fn parse_forwarded_param<'a>(forwarded: &'a str, param: &str) -> Option<&'a str> { @@ -221,9 +232,15 @@ fn strip_quotes(value: &str) -> &str { } } -fn normalize_scheme(value: &str) -> Option { - let scheme = value.trim().to_ascii_lowercase(); - (scheme == "https" || scheme == "http").then_some(scheme) +fn normalize_scheme(value: &str) -> Option<&'static str> { + let scheme = value.trim(); + if scheme.eq_ignore_ascii_case("https") { + Some("https") + } else if scheme.eq_ignore_ascii_case("http") { + Some("http") + } else { + None + } } /// Detects the request scheme (HTTP or HTTPS) using Fastly SDK methods and headers. @@ -238,17 +255,17 @@ fn detect_request_scheme( req: &Request, tls_protocol: Option<&str>, tls_cipher: Option<&str>, -) -> String { +) -> &'static str { // 1. First try ClientInfo TLS fields populated at the adapter entry point. if let Some(tls_protocol) = tls_protocol { log::debug!("TLS protocol detected: {tls_protocol}"); - return "https".to_owned(); + return "https"; } // Also check TLS cipher - if present, connection is HTTPS. if tls_cipher.is_some() { log::debug!("TLS cipher detected, using HTTPS"); - return "https".to_owned(); + return "https"; } // 2. Try the Forwarded header (RFC 7239) @@ -284,11 +301,11 @@ fn detect_request_scheme( && let Ok(ssl_str) = ssl.to_str() && (ssl_str == "1" || ssl_str.to_lowercase() == "true") { - return "https".to_owned(); + return "https"; } // Default to HTTP - "http".to_owned() + "http" } /// Build a static text response with strong `ETag` and standard caching headers. diff --git a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs index 9b7990ae38..fb80c60b20 100644 --- a/crates/trusted-server-core/src/integrations/js_asset_proxy.rs +++ b/crates/trusted-server-core/src/integrations/js_asset_proxy.rs @@ -497,26 +497,12 @@ pub fn register( )) } -#[async_trait(?Send)] -impl IntegrationProxy for JsAssetProxyIntegration { - fn integration_name(&self) -> &'static str { - JS_ASSET_PROXY_INTEGRATION_ID - } - - fn routes(&self) -> Vec { - self.config - .assets - .iter() - .filter(|asset| asset.proxy == JsAssetProxyMode::Enabled) - .map(|asset| IntegrationEndpoint::new(Method::GET, asset.path.clone())) - .collect() - } - - async fn handle( +impl JsAssetProxyIntegration { + async fn route( &self, + req: Request, settings: &Settings, services: &RuntimeServices, - req: Request, ) -> Result, Report> { let request_path = req.uri().path().to_string(); let asset = self.enabled_asset_for_path(&request_path).ok_or_else(|| { @@ -554,6 +540,30 @@ impl IntegrationProxy for JsAssetProxyIntegration { } } +#[async_trait(?Send)] +impl IntegrationProxy for JsAssetProxyIntegration { + fn integration_name(&self) -> &'static str { + JS_ASSET_PROXY_INTEGRATION_ID + } + + fn routes(&self) -> Vec { + self.config + .assets + .iter() + .filter(|asset| asset.proxy == JsAssetProxyMode::Enabled) + .map(|asset| IntegrationEndpoint::new(Method::GET, asset.path.clone())) + .collect() + } + + async fn handle( + &self, + call: crate::module_context::ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} + impl IntegrationAttributeRewriter for JsAssetProxyIntegration { fn integration_id(&self) -> &'static str { JS_ASSET_PROXY_INTEGRATION_ID @@ -1405,7 +1415,7 @@ mod tests { ); let response = integration - .handle(&settings, &services, request) + .route(request, &settings, &services) .await .expect("should proxy conditional asset response"); @@ -1455,13 +1465,13 @@ mod tests { Arc::clone(&buffered_stub) as Arc ); let success = integration - .handle( - &settings, - &buffered_services, + .route( build_http_request( Method::GET, "https://publisher.example.com/assets/vendor.js", ), + &settings, + &buffered_services, ) .await .expect("should proxy buffered asset response"); @@ -1475,13 +1485,13 @@ mod tests { Arc::clone(&streaming_stub) as Arc ); let success = integration - .handle( - &settings, - &streaming_services, + .route( build_http_request( Method::GET, "https://publisher.example.com/assets/vendor.js", ), + &settings, + &streaming_services, ) .await .expect("should proxy streaming asset response"); @@ -1503,13 +1513,13 @@ mod tests { Arc::clone(&unavailable_stub) as Arc, ); let unavailable = integration - .handle( - &settings, - &unavailable_services, + .route( build_http_request( Method::GET, "https://publisher.example.com/assets/vendor.js", ), + &settings, + &unavailable_services, ) .await .expect("should map unavailable origin response"); @@ -1528,13 +1538,13 @@ mod tests { Arc::clone(&status_stub) as Arc ); let status = integration - .handle( - &settings, - &status_services, + .route( build_http_request( Method::GET, "https://publisher.example.com/assets/vendor.js", ), + &settings, + &status_services, ) .await .expect("should map non-success origin response"); diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index a618dafddc..88d949f5da 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -18,6 +18,7 @@ use crate::ec::module::{EcModuleSelection, EdgeCookieModule}; use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::http_util::is_navigation_request; +use crate::module_context::{ModuleCall, ModuleContext, ResolvedRequest}; use crate::platform::{DisabledGeo, PlatformGeo, RuntimeServices}; use crate::settings::Settings; use crate::streaming_processor::StreamProcessor; @@ -416,11 +417,21 @@ pub trait IntegrationProxy: Send + Sync { /// or define routes manually for backwards compatibility. fn routes(&self) -> Vec; + /// The permissions this route declares, which decide whether a gated + /// value, such as the Edge Cookie identifier, is passed to it. None by + /// default. + fn required_permissions(&self) -> crate::permissions::PermissionSet { + crate::permissions::PermissionSet::none() + } + /// Handle the proxied request. + /// + /// Hand a function to [`ModuleCall::inject_with`] with `req` as its + /// argument. A route whose use needs a permission declares it and checks + /// it is granted, because nothing gates the route itself. async fn handle( &self, - settings: &Settings, - services: &RuntimeServices, + call: ModuleCall<'_>, req: Request, ) -> Result, Report>; @@ -1916,7 +1927,20 @@ impl IntegrationRegistry { // Remove any caller-supplied EC header rather than forwarding it. req.headers_mut().remove(HEADER_X_TS_EC.clone()); - Some(proxy.handle(settings, services, req).await) + // The route takes the request, so the context holds a copy of + // what it resolved to, beside what was resolved for it. + let resolved = ResolvedRequest::of(&req, services.client_info()); + let context = ModuleContext::new(resolved.view()) + .with_settings(settings) + .with_request_state(ec_context, services); + Some( + proxy + .handle( + context.call(proxy.integration_name(), proxy.required_permissions()), + req, + ) + .await, + ) } else { None } @@ -3327,8 +3351,7 @@ mod tests { async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, _req: Request, ) -> Result, Report> { Ok(Response::new(EdgeBody::empty())) @@ -3410,8 +3433,7 @@ mod tests { async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, req: http::Request, ) -> Result, Report> { let response = http::Response::builder() @@ -3903,8 +3925,7 @@ mod tests { async fn handle( &self, - _settings: &Settings, - _services: &RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, req: Request, ) -> Result, Report> { let mut response = Response::builder() @@ -3970,6 +3991,118 @@ mod tests { ); } + /// A route that answers with what its module call handed it. + struct StateRoute { + declared: PermissionSet, + } + + impl StateRoute { + fn report( + &self, + _req: Request, + request: crate::module_context::ModuleRequest<'_>, + permissions: &PermissionState, + edge_cookie: Option>, + ) -> Result, Report> { + let body = format!( + "{} storage={} id={}", + request.host(), + permissions.is_set(Permission::StoreOnDevice), + edge_cookie.map_or("none", |id| id.as_str()), + ); + Ok(Response::builder() + .status(StatusCode::OK) + .body(EdgeBody::from(body)) + .expect("should build the response")) + } + } + + #[async_trait(?Send)] + impl IntegrationProxy for StateRoute { + fn integration_name(&self) -> &'static str { + "state" + } + + fn routes(&self) -> Vec { + vec![IntegrationEndpoint::get("/integrations/test/state")] + } + + fn required_permissions(&self) -> PermissionSet { + self.declared + } + + async fn handle( + &self, + call: ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::report)? + } + } + + /// A route is handed the permissions resolved for its request, and the + /// request's Edge Cookie identifier when it declares the permission the + /// identifier's use needs. + #[test] + fn a_route_is_handed_its_requests_permissions_and_identity() { + let settings = create_test_settings(); + let storage = PermissionSet::none().with(Permission::StoreOnDevice); + let answer = |declared: PermissionSet| { + let registry = IntegrationRegistry::from_routes(vec![( + Method::GET, + "/integrations/test/state", + ( + Arc::new(StateRoute { declared }) as Arc, + "state", + ), + )]); + let req = Request::builder() + .method(Method::GET) + .uri("https://test-publisher.com/integrations/test/state") + .header(header::HOST, "test-publisher.com") + .body(EdgeBody::empty()) + .expect("should build request"); + let mut ec_context = EcContext::new_for_test( + Some("an-id".to_owned()), + crate::consent::ConsentContext::default(), + ) + .with_module_for_test(Arc::new(crate::ec::module::HmacModule::new( + crate::redacted::Redacted::new("test-secret-key-32-bytes-minimum".to_owned()), + ))); + let services = noop_services(); + let response = futures::executor::block_on(registry.handle_proxy(ProxyDispatchInput { + method: &Method::GET, + path: "/integrations/test/state", + settings: &settings, + kv: None, + ec_context: &mut ec_context, + services: &services, + req, + })) + .expect("should find the route") + .expect("the route should answer"); + String::from_utf8( + response + .into_body() + .into_bytes() + .unwrap_or_default() + .to_vec(), + ) + .expect("the answer is text") + }; + + assert_eq!( + answer(storage), + "test-publisher.com storage=true id=an-id", + "a route declaring storage is handed the identifier" + ); + assert_eq!( + answer(PermissionSet::none()), + "test-publisher.com storage=true id=none", + "and one declaring nothing still sees the permissions but not the identifier" + ); + } + #[test] fn handle_proxy_rejects_invalid_ec_request_header() { let settings = create_test_settings(); @@ -5184,8 +5317,7 @@ mod tests { async fn detect( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> crate::ec::device::DeviceSignals { crate::ec::device::DeviceSignals { is_mobile: 2, @@ -5507,8 +5639,7 @@ mod tests { async fn detect( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> crate::ec::device::DeviceSignals { crate::ec::device::DeviceSignals { is_mobile: 1, @@ -5537,9 +5668,7 @@ mod tests { async fn generate( &self, - _request_info: &dyn crate::evidence::RequestInfo, - _input: &crate::ec::module::IdentityInput<'_>, - _services: &crate::platform::RuntimeServices, + _call: crate::module_context::ModuleCall<'_>, ) -> Result> { Ok(crate::ec::module::GeneratedEdgeCookie::default()) } diff --git a/crates/trusted-server-core/src/lib.rs b/crates/trusted-server-core/src/lib.rs index 52b0528b0d..ce461529c6 100644 --- a/crates/trusted-server-core/src/lib.rs +++ b/crates/trusted-server-core/src/lib.rs @@ -53,6 +53,7 @@ pub mod host_rewrite; pub mod html_processor; pub mod http_util; pub mod integrations; +pub mod module_context; pub mod module_name; pub(crate) mod module_secrets; pub mod openrtb; diff --git a/crates/trusted-server-core/src/module_context.rs b/crates/trusted-server-core/src/module_context.rs new file mode 100644 index 0000000000..c75e74db0c --- /dev/null +++ b/crates/trusted-server-core/src/module_context.rs @@ -0,0 +1,703 @@ +//! One context for every module, and the values a module's function may +//! name as parameters. +//! +//! Core builds one [`ModuleContext`] where it calls modules, from what the +//! request already holds. The context borrows everything it carries, so +//! building it copies no request data, and every module called there borrows +//! the same context. Each module is handed a [`ModuleCall`], being the context +//! together with the permissions that module declares. +//! +//! A module's function names what it needs as parameters, and +//! [`ModuleCall::inject`] passes each one in from the context. A parameter may +//! be any type that implements [`FromModuleContext`], an `Option` of one, which +//! is `None` when the value cannot be passed, or a tuple of them. The function +//! shapes that can be called are in [`inject`]. +//! +//! | Parameter | What it is | Absent where | +//! | --- | --- | --- | +//! | [`ModuleRequest`] | method, path, query, host and scheme | never | +//! | `&dyn RequestInfo` | the reader's evidence, headers and client IP | in a route, which holds the request itself | +//! | `&ClientInfo` | the reader's connection | where the device is classified | +//! | `&PermissionState` | the permissions resolved for the request | before they are resolved, which is where the device is classified and where the signal modules run | +//! | `&ConsentContext` | the decoded consent signals | where the device is classified | +//! | `&Settings` | the deployment's settings | outside any request | +//! | `&RuntimeServices` | stores, caches, backends and the HTTP client | outside any request | +//! | `&GeoInfo` | the reader's location | no location resolved, or withheld | +//! | `&DeviceSignals` | the device classification | not classified, or withheld | +//! | [`EdgeCookie`] | the request's Edge Cookie identifier | none, or withheld | +//! +//! A module asked outside any request, such as a signal module asked on its +//! own, is called in [`ModuleContext::empty`], which carries the request line +//! of the table and nothing else. +//! +//! # Permissions +//! +//! Geo, the device signals and the Edge Cookie identifier each carry the +//! permissions the module that produced them declares. Such a value is passed +//! only to a module that declares every one of those permissions and is granted +//! them on this request. A value whose producer declares none is passed to +//! every module, because evidence is not rationed and its use is what the +//! permissions govern. When a value is withheld, an `Option` parameter is +//! `None`, and a call naming the value itself is skipped, with the reason in +//! the debug log. + +use core::fmt; + +use edgezero_core::body::Body as EdgeBody; +use http::Method; + +use crate::consent::ConsentContext; +use crate::ec::EcContext; +use crate::ec::device::DeviceSignals; +use crate::error::TrustedServerError; +use crate::evidence::RequestInfo; +use crate::geo::GeoInfo; +use crate::permissions::{PermissionSet, PermissionState}; +use crate::platform::{ClientInfo, RuntimeServices}; +use crate::settings::Settings; + +pub mod inject; + +pub use inject::{Process, ProcessWith}; + +/// What a request resolved to: its method and address, and the host and +/// scheme it was made for. +/// +/// Carries nothing about who asked, so it is in every context. +#[derive(Debug, Clone, Copy)] +pub struct ModuleRequest<'r> { + method: &'r Method, + path: &'r str, + query: &'r str, + host: &'r str, + scheme: &'r str, +} + +impl<'r> ModuleRequest<'r> { + /// A request for `path` on `host` over `scheme`, with no query. + #[must_use] + pub const fn new(method: &'r Method, host: &'r str, scheme: &'r str, path: &'r str) -> Self { + Self { + method, + path, + query: "", + host, + scheme, + } + } + + /// What `request` resolved to, borrowed from it, the host and scheme read + /// as the publisher path reads them. + #[must_use] + pub fn of(request: &'r http::Request, client: &ClientInfo) -> Self { + Self::new( + request.method(), + crate::http_util::request_host(request), + crate::http_util::request_scheme(request, client), + request.uri().path(), + ) + .with_query(request.uri().query().unwrap_or_default()) + } + + /// The same request with the query string, without its leading `?`. + #[must_use] + pub fn with_query(self, query: &'r str) -> Self { + Self { query, ..self } + } + + /// The request method. + #[must_use] + pub fn method(&self) -> &'r Method { + self.method + } + + /// The URL path, without the query string. + #[must_use] + pub fn path(&self) -> &'r str { + self.path + } + + /// The query string, without its leading `?`, or `""`. + #[must_use] + pub fn query(&self) -> &'r str { + self.query + } + + /// The publisher-facing host the reader asked for. + #[must_use] + pub fn host(&self) -> &'r str { + self.host + } + + /// The publisher-facing scheme the reader asked for. + #[must_use] + pub fn scheme(&self) -> &'r str { + self.scheme + } +} + +/// A [`ModuleRequest`] that owns its parts, for a caller that must keep them +/// past the borrow of the request, such as one that hands the request on. +/// +/// The default is a `GET` of nothing, which a caller holds where it captured +/// no request. +#[derive(Debug, Clone, Default)] +pub struct ResolvedRequest { + method: Method, + path: String, + query: String, + host: String, + scheme: String, +} + +impl ResolvedRequest { + /// The method, address, host and scheme of `request`, the host and + /// scheme read as the publisher path reads them. + #[must_use] + pub fn of(request: &http::Request, client: &ClientInfo) -> Self { + let view = ModuleRequest::of(request, client); + Self { + method: view.method.clone(), + path: view.path.to_owned(), + query: view.query.to_owned(), + host: view.host.to_owned(), + scheme: view.scheme.to_owned(), + } + } + + /// The borrowed view a context carries. + #[must_use] + pub fn view(&self) -> ModuleRequest<'_> { + ModuleRequest::new(&self.method, &self.host, &self.scheme, &self.path) + .with_query(&self.query) + } +} + +/// The request's Edge Cookie identifier, as the module that created it wrote +/// it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EdgeCookie<'r>(&'r str); + +impl<'r> EdgeCookie<'r> { + /// The identifier. + #[must_use] + pub fn as_str(&self) -> &'r str { + self.0 + } +} + +/// A value whose use needs the permissions its producer declares. +struct Gated<'r, T: ?Sized> { + value: &'r T, + requires: PermissionSet, +} + +impl Clone for Gated<'_, T> { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for Gated<'_, T> {} + +/// Everything a module may be passed for one request. +/// +/// Built once where core calls modules, by borrowing what the request already +/// holds, and borrowed by every module called there. A module never sees it +/// directly. It is handed a [`ModuleCall`] and names what it needs, see +/// [`ModuleCall::inject`]. +#[derive(Clone, Copy)] +pub struct ModuleContext<'r> { + request: ModuleRequest<'r>, + evidence: Option<&'r dyn RequestInfo>, + client: Option<&'r ClientInfo>, + permissions: Option<&'r PermissionState>, + consent: Option<&'r ConsentContext>, + settings: Option<&'r Settings>, + services: Option<&'r RuntimeServices>, + geo: Option>, + device: Option>, + edge_cookie: Option>, +} + +impl<'r> ModuleContext<'r> { + /// A context carrying `request` and nothing else. + /// + /// This is the context of a module asked outside any request, until the + /// builder methods add to it. + #[must_use] + pub const fn new(request: ModuleRequest<'r>) -> Self { + Self { + request, + evidence: None, + client: None, + permissions: None, + consent: None, + settings: None, + services: None, + geo: None, + device: None, + edge_cookie: None, + } + } + + /// The same context carrying the reader's evidence. + #[must_use] + pub fn with_evidence(self, evidence: &'r dyn RequestInfo) -> Self { + Self { + evidence: Some(evidence), + ..self + } + } + + /// The same context carrying the reader's connection. + #[must_use] + pub fn with_client(self, client: &'r ClientInfo) -> Self { + Self { + client: Some(client), + ..self + } + } + + /// The same context carrying the permissions resolved for the request. + #[must_use] + pub fn with_permissions(self, permissions: &'r PermissionState) -> Self { + Self { + permissions: Some(permissions), + ..self + } + } + + /// The same context carrying the request's decoded consent signals. + #[must_use] + pub fn with_consent(self, consent: &'r ConsentContext) -> Self { + Self { + consent: Some(consent), + ..self + } + } + + /// The same context carrying the deployment's settings. + #[must_use] + pub fn with_settings(self, settings: &'r Settings) -> Self { + Self { + settings: Some(settings), + ..self + } + } + + /// The same context carrying the request's services. + #[must_use] + pub fn with_services(self, services: &'r RuntimeServices) -> Self { + Self { + services: Some(services), + ..self + } + } + + /// The same context carrying the reader's location, whose use needs + /// `requires`, being what the geo module that resolved it declares. + #[must_use] + pub fn with_geo(self, geo: &'r GeoInfo, requires: PermissionSet) -> Self { + Self { + geo: Some(Gated { + value: geo, + requires, + }), + ..self + } + } + + /// The same context carrying the device signals, whose use needs + /// `requires`, being what the device module that derived them declares. + #[must_use] + pub fn with_device(self, device: &'r DeviceSignals, requires: PermissionSet) -> Self { + Self { + device: Some(Gated { + value: device, + requires, + }), + ..self + } + } + + /// The same context carrying the request's Edge Cookie identifier, whose + /// use needs `requires`, being what the Edge Cookie module declares. + #[must_use] + pub fn with_edge_cookie(self, id: &'r str, requires: PermissionSet) -> Self { + Self { + edge_cookie: Some(Gated { + value: id, + requires, + }), + ..self + } + } + + /// The same context carrying what the request's Edge Cookie state holds, + /// being its permissions, consent, location, device signals and Edge + /// Cookie identifier, and the request's services and connection. + /// + /// Each gated value needs what its producer declares. The location needs + /// what the geo module in `services` declares, the device signals what the + /// device module in `services` declares, and the identifier what the Edge + /// Cookie module the state was built with declares. Signals from a device + /// module the adapter builds itself, as Fastly does with none registered, + /// are gated by nothing, so such a module must declare no permission. + #[must_use] + pub fn with_request_state(self, state: &'r EcContext, services: &'r RuntimeServices) -> Self { + let mut context = self + .with_services(services) + .with_client(services.client_info()) + .with_permissions(state.permissions()) + .with_consent(state.consent()); + if let Some(geo) = state.geo_info() { + context = context.with_geo(geo, services.geo().required_permissions()); + } + if let Some(device) = state.device_signals() { + let requires = services + .device_module() + .map_or_else(PermissionSet::none, |module| module.required_permissions()); + context = context.with_device(device, requires); + } + if let (Some(id), Some(module)) = (state.ec_value(), state.selected_module()) { + context = context.with_edge_cookie(id, module.required_permissions()); + } + context + } + + /// What the request resolved to. + #[must_use] + pub fn request(&self) -> ModuleRequest<'r> { + self.request + } + + /// The request's decoded consent signals, when the context carries them. + pub(crate) fn consent(&self) -> Option<&'r ConsentContext> { + self.consent + } + + /// The reader's evidence, when the context carries it. + pub(crate) fn evidence(&self) -> Option<&'r dyn RequestInfo> { + self.evidence + } + + /// The call to one module, named `module`, which declares the permissions + /// in `declared`. + #[must_use] + pub fn call(&self, module: &'static str, declared: PermissionSet) -> ModuleCall<'_> { + ModuleCall { + context: self, + module, + declared, + } + } +} + +/// The method of the empty context. +static EMPTY_METHOD: Method = Method::GET; + +/// A context carrying nothing but a request for `/` on no host, for a module +/// asked outside any request. +static EMPTY: ModuleContext<'static> = + ModuleContext::new(ModuleRequest::new(&EMPTY_METHOD, "", "", "/")); + +impl ModuleContext<'static> { + /// The context of a module asked outside any request, which carries + /// nothing a module could name but an empty request. + /// + /// A caller with no request to resolve, such as a tool that evaluates + /// signals on their own, builds on a copy of it with the builder methods. + #[must_use] + pub fn empty() -> &'static Self { + &EMPTY + } +} + +impl ModuleCall<'static> { + /// A call into [`ModuleContext::empty`], by no name and declaring + /// nothing, for a module asked outside any request, such as by a test. + #[must_use] + pub fn empty() -> Self { + ModuleContext::empty().call("", PermissionSet::none()) + } +} + +impl fmt::Debug for ModuleContext<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ModuleContext") + .field("request", &self.request) + .field("evidence", &self.evidence.is_some()) + .field("permissions", &self.permissions.is_some()) + .field("settings", &self.settings.is_some()) + .field("services", &self.services.is_some()) + .field("geo", &self.geo.is_some()) + .field("device", &self.device.is_some()) + .field("edge_cookie", &self.edge_cookie.is_some()) + .finish_non_exhaustive() + } +} + +/// Core's call to one module, being the request's [`ModuleContext`] and the +/// permissions the module declares. +/// +/// A module's function is handed its parameters through +/// [`inject`](Self::inject). +#[derive(Debug, Clone, Copy)] +pub struct ModuleCall<'c> { + context: &'c ModuleContext<'c>, + module: &'static str, + declared: PermissionSet, +} + +impl<'c> ModuleCall<'c> { + /// The module being called. + #[must_use] + pub fn module(&self) -> &'static str { + self.module + } + + /// The permissions the module declares. + #[must_use] + pub fn declared(&self) -> PermissionSet { + self.declared + } + + /// A gated value, when this module may be passed it. + fn gated( + &self, + value: Option>, + name: &'static str, + ) -> Result<&'c T, Withheld> { + let gated = value.ok_or(Withheld::absent(name))?; + if gated.requires.is_empty() { + return Ok(gated.value); + } + let undeclared = gated.requires.difference(self.declared); + if !undeclared.is_empty() { + return Err(Withheld::new(name, WithheldReason::NotDeclared(undeclared))); + } + let state = self + .context + .permissions + .ok_or(Withheld::new(name, WithheldReason::Unresolved))?; + let unset = gated.requires.difference(state.permissions()); + if !unset.is_empty() { + return Err(Withheld::new(name, WithheldReason::NotGranted(unset))); + } + Ok(gated.value) + } +} + +/// Why a value is not passed to a module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Withheld { + value: &'static str, + reason: WithheldReason, +} + +/// What kept a value from a module. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WithheldReason { + /// The context carries no such value where this module runs. + Absent, + /// The module does not declare these permissions, which the value's use + /// needs. + NotDeclared(PermissionSet), + /// These permissions, which the value's use needs, are not set on this + /// request. + NotGranted(PermissionSet), + /// The value's use needs permissions, and none are resolved where this + /// module runs. + Unresolved, +} + +impl Withheld { + fn new(value: &'static str, reason: WithheldReason) -> Self { + Self { value, reason } + } + + fn absent(value: &'static str) -> Self { + Self::new(value, WithheldReason::Absent) + } + + /// The value that was withheld, such as `geo`. + #[must_use] + pub fn value(&self) -> &'static str { + self.value + } + + /// Why it was withheld. + #[must_use] + pub fn reason(&self) -> WithheldReason { + self.reason + } +} + +impl fmt::Display for Withheld { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let value = self.value; + match self.reason { + WithheldReason::Absent => write!(f, "there is no {value} where this module runs"), + WithheldReason::NotDeclared(missing) => write!( + f, + "{value} is withheld because the module does not declare {missing}, which its \ + use needs" + ), + WithheldReason::NotGranted(missing) => write!( + f, + "{value} is withheld because {missing} is not set on this request" + ), + WithheldReason::Unresolved => write!( + f, + "{value} is withheld because its use needs permissions and none are resolved \ + where this module runs" + ), + } + } +} + +impl core::error::Error for Withheld {} + +/// A route whose parameter the request's permissions withhold answers `403`. +/// A value absent where the module runs, or needing permissions that are not +/// resolved there, is a fault in how the module was written or placed, and +/// answers `500`. +impl From for error_stack::Report { + fn from(withheld: Withheld) -> Self { + let message = withheld.to_string(); + error_stack::Report::new(match withheld.reason { + WithheldReason::NotDeclared(_) | WithheldReason::NotGranted(_) => { + TrustedServerError::Forbidden { message } + } + WithheldReason::Absent | WithheldReason::Unresolved => { + TrustedServerError::Configuration { message } + } + }) + } +} + +/// A value a module's function can name as a parameter. +/// +/// Implemented for each value a [`ModuleContext`] carries, for an `Option` of +/// one, which is `None` rather than withheld, and for tuples of up to eight. +pub trait FromModuleContext<'c>: Sized { + /// The value for this call. + /// + /// # Errors + /// + /// [`Withheld`] when the context carries no such value here, or when the + /// value's use needs a permission this module does not declare or is not + /// granted. + fn from_module_context(call: &ModuleCall<'c>) -> Result; +} + +impl<'c, T: FromModuleContext<'c>> FromModuleContext<'c> for Option { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + Ok(T::from_module_context(call).ok()) + } +} + +impl<'c> FromModuleContext<'c> for ModuleRequest<'c> { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + Ok(call.context.request) + } +} + +impl<'c> FromModuleContext<'c> for &'c dyn RequestInfo { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context + .evidence + .ok_or(Withheld::absent("request evidence")) + } +} + +impl<'c> FromModuleContext<'c> for &'c ClientInfo { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context.client.ok_or(Withheld::absent("client info")) + } +} + +impl<'c> FromModuleContext<'c> for &'c PermissionState { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context + .permissions + .ok_or(Withheld::absent("permissions")) + } +} + +impl<'c> FromModuleContext<'c> for &'c ConsentContext { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context.consent.ok_or(Withheld::absent("consent")) + } +} + +impl<'c> FromModuleContext<'c> for &'c Settings { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context.settings.ok_or(Withheld::absent("settings")) + } +} + +impl<'c> FromModuleContext<'c> for &'c RuntimeServices { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.context.services.ok_or(Withheld::absent("services")) + } +} + +impl<'c> FromModuleContext<'c> for &'c GeoInfo { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.gated(call.context.geo, "geo") + } +} + +impl<'c> FromModuleContext<'c> for &'c DeviceSignals { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.gated(call.context.device, "device signals") + } +} + +impl<'c> FromModuleContext<'c> for EdgeCookie<'c> { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + call.gated(call.context.edge_cookie, "the Edge Cookie") + .map(EdgeCookie) + } +} + +/// A tuple of values is passed when every one of them is. +macro_rules! tuple_from_module_context { + ($($value:ident),+) => { + impl<'c, $($value: FromModuleContext<'c>),+> FromModuleContext<'c> for ($($value,)+) { + fn from_module_context(call: &ModuleCall<'c>) -> Result { + Ok(($($value::from_module_context(call)?,)+)) + } + } + }; +} + +tuple_from_module_context!(A1); +tuple_from_module_context!(A1, A2); +tuple_from_module_context!(A1, A2, A3); +tuple_from_module_context!(A1, A2, A3, A4); +tuple_from_module_context!(A1, A2, A3, A4, A5); +tuple_from_module_context!(A1, A2, A3, A4, A5, A6); +tuple_from_module_context!(A1, A2, A3, A4, A5, A6, A7); +tuple_from_module_context!(A1, A2, A3, A4, A5, A6, A7, A8); + +/// Requests for a module's own tests, which have no request to resolve. +#[cfg(any(test, feature = "test-utils"))] +pub mod test_support { + use http::Method; + + use super::ModuleRequest; + + static GET: Method = Method::GET; + + /// A `GET` of `path` on `publisher.example` over `https`, with no query. + #[must_use] + pub fn request(path: &'static str) -> ModuleRequest<'static> { + ModuleRequest::new(&GET, "publisher.example", "https", path) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/trusted-server-core/src/module_context/inject.rs b/crates/trusted-server-core/src/module_context/inject.rs new file mode 100644 index 0000000000..ba41fed5f0 --- /dev/null +++ b/crates/trusted-server-core/src/module_context/inject.rs @@ -0,0 +1,160 @@ +//! Calling a module's function with its parameters taken from the request's +//! [`ModuleContext`](super::ModuleContext). +//! +//! A trait method takes a [`ModuleCall`] and the implementation hands its own +//! function to [`ModuleCall::inject`], which is generic where the module type +//! is known. +//! +//! The function takes the module first, then up to eight parameters, each a +//! [`FromModuleContext`] value. [`ModuleCall::inject_with`] passes one argument +//! of the caller's own after the module, for the input that is the module's to +//! act on, such as the request a route answers. The function may be `async`, +//! in which case the future it returns is what comes back. +//! +//! ``` +//! use trusted_server_core::evidence::RequestInfo; +//! use trusted_server_core::geo::GeoInfo; +//! use trusted_server_core::module_context::{ModuleCall, Withheld}; +//! +//! struct Greeter; +//! +//! impl Greeter { +//! fn greet(&self, request: &dyn RequestInfo, geo: Option<&GeoInfo>) -> String { +//! let place = geo.map_or("somewhere", |geo| geo.country.as_str()); +//! format!("{} from {place}", request.user_agent()) +//! } +//! +//! fn process(&self, call: ModuleCall<'_>) -> Result { +//! call.inject(self, Self::greet) +//! } +//! } +//! ``` + +use super::{FromModuleContext, ModuleCall, Withheld}; + +/// A function core can call with the module and its parameters taken from a +/// [`ModuleCall`]. +/// +/// Implemented for every function and closure taking `&M` and up to eight +/// [`FromModuleContext`] parameters, so a module never implements it itself. +pub trait Process<'c, M: ?Sized, Args, O> { + /// Calls the function. + /// + /// # Errors + /// + /// [`Withheld`] for the first parameter that cannot be passed, in which case + /// the function is not called. + fn process(self, module: &'c M, call: &ModuleCall<'c>) -> Result; +} + +/// A function core can call with the module, one argument of the caller's +/// own, and its parameters taken from a [`ModuleCall`]. +/// +/// Implemented for every function and closure taking `&M`, the argument, and +/// up to eight [`FromModuleContext`] parameters. +pub trait ProcessWith<'c, M: ?Sized, X, Args, O> { + /// Calls the function. + /// + /// # Errors + /// + /// [`Withheld`] for the first parameter that cannot be passed, in which case + /// the function is not called and the argument is dropped. + fn process_with(self, module: &'c M, argument: X, call: &ModuleCall<'c>) + -> Result; +} + +macro_rules! process { + ($($parameter:ident),*) => { + impl<'c, M, F, O, $($parameter,)*> Process<'c, M, ($($parameter,)*), O> for F + where + M: ?Sized + 'c, + F: FnOnce(&'c M, $($parameter,)*) -> O, + $($parameter: FromModuleContext<'c>,)* + { + #[allow(non_snake_case, reason = "the parameters are named by their types")] + fn process(self, module: &'c M, call: &ModuleCall<'c>) -> Result { + let _ = call; + $(let $parameter = $parameter::from_module_context(call)?;)* + Ok(self(module, $($parameter,)*)) + } + } + + impl<'c, M, X, F, O, $($parameter,)*> ProcessWith<'c, M, X, ($($parameter,)*), O> for F + where + M: ?Sized + 'c, + F: FnOnce(&'c M, X, $($parameter,)*) -> O, + $($parameter: FromModuleContext<'c>,)* + { + #[allow(non_snake_case, reason = "the parameters are named by their types")] + fn process_with( + self, + module: &'c M, + argument: X, + call: &ModuleCall<'c>, + ) -> Result { + let _ = call; + $(let $parameter = $parameter::from_module_context(call)?;)* + Ok(self(module, argument, $($parameter,)*)) + } + } + }; +} + +process!(); +process!(A1); +process!(A1, A2); +process!(A1, A2, A3); +process!(A1, A2, A3, A4); +process!(A1, A2, A3, A4, A5); +process!(A1, A2, A3, A4, A5, A6); +process!(A1, A2, A3, A4, A5, A6, A7); +process!(A1, A2, A3, A4, A5, A6, A7, A8); + +impl<'c> ModuleCall<'c> { + /// Calls `function` with `module` and each of its other parameters taken + /// from the context. + /// + /// A parameter that is withheld skips the call, which is logged at debug + /// level with the reason, and an `Option` parameter that is withheld is + /// `None` instead. See [`module_context`](super) for what is withheld when. + /// + /// # Errors + /// + /// [`Withheld`] when a parameter cannot be passed, and the function is not + /// called. + pub fn inject(&self, module: &'c M, function: F) -> Result + where + M: ?Sized, + F: Process<'c, M, Args, O>, + { + function + .process(module, self) + .inspect_err(|withheld| self.log_skip(withheld)) + } + + /// Calls `function` with `module`, `argument`, and each of its other + /// parameters taken from the context, as [`inject`](Self::inject) does. + /// + /// # Errors + /// + /// [`Withheld`] when a parameter cannot be passed, and the function is not + /// called. + pub fn inject_with( + &self, + module: &'c M, + argument: X, + function: F, + ) -> Result + where + M: ?Sized, + F: ProcessWith<'c, M, X, Args, O>, + { + function + .process_with(module, argument, self) + .inspect_err(|withheld| self.log_skip(withheld)) + } + + fn log_skip(&self, withheld: &Withheld) { + log::debug!("Skipping module `{}`: {withheld}", self.module()); + } +} diff --git a/crates/trusted-server-core/src/module_context/tests.rs b/crates/trusted-server-core/src/module_context/tests.rs new file mode 100644 index 0000000000..dff4fbc923 --- /dev/null +++ b/crates/trusted-server-core/src/module_context/tests.rs @@ -0,0 +1,456 @@ +use std::cell::Cell; +use std::sync::Arc; + +use http::{HeaderMap, HeaderValue, Method, header}; + +use super::*; +use crate::ec::module::HmacModule; +use crate::evidence::OwnedRequestInfo; +use crate::permissions::{Permission, PermissionSet, PermissionState}; +use crate::platform::test_support::{noop_services, noop_services_with_client_ip}; +use crate::redacted::Redacted; + +/// The permission the gated values of these tests need. +fn storage() -> PermissionSet { + PermissionSet::none().with(Permission::StoreOnDevice) +} + +fn geo() -> GeoInfo { + GeoInfo { + city: "Bristol".to_owned(), + country: "GB".to_owned(), + continent: "Europe".to_owned(), + latitude: 51.45, + longitude: -2.58, + metro_code: 0, + region: Some("BST".to_owned()), + asn: None, + } +} + +fn device() -> DeviceSignals { + DeviceSignals::derive_ua_only("Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/146.0") +} + +fn evidence() -> OwnedRequestInfo { + let mut headers = HeaderMap::new(); + headers.insert( + header::USER_AGENT, + HeaderValue::from_static("ExampleAgent/1.0"), + ); + OwnedRequestInfo::new("203.0.113.7".to_owned(), headers) +} + +/// A module that is no more than the functions these tests hand to it. +struct Module; + +#[test] +fn the_request_reaches_every_function_that_names_it() { + let method = Method::POST; + let request = + ModuleRequest::new(&method, "publisher.example", "https", "/page").with_query("a=1"); + let context = ModuleContext::new(request); + let call = context.call("module", PermissionSet::none()); + + let seen = call + .inject(&Module, |_: &Module, request: ModuleRequest<'_>| { + ( + request.method().clone(), + request.host().to_owned(), + request.scheme().to_owned(), + request.path().to_owned(), + request.query().to_owned(), + ) + }) + .expect("the request is in every context"); + + assert_eq!( + seen, + ( + Method::POST, + "publisher.example".to_owned(), + "https".to_owned(), + "/page".to_owned(), + "a=1".to_owned(), + ) + ); +} + +#[test] +fn each_value_the_context_carries_reaches_a_function_that_names_it() { + let method = Method::GET; + let evidence = evidence(); + let client = ClientInfo { + tls_protocol: Some("TLSv1.3".to_owned()), + ..ClientInfo::default() + }; + let permissions = PermissionState::new(storage()); + let consent = ConsentContext::default(); + let mut settings = Settings::default(); + settings.publisher.domain = "publisher.example".to_owned(); + let services_ip: std::net::IpAddr = "203.0.113.9".parse().expect("should parse an IP"); + let services = noop_services_with_client_ip(services_ip); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_evidence(&evidence) + .with_client(&client) + .with_permissions(&permissions) + .with_consent(&consent) + .with_settings(&settings) + .with_services(&services); + let call = context.call("module", PermissionSet::none()); + + let first = call + .inject( + &Module, + |_: &Module, + evidence: &dyn RequestInfo, + client: &ClientInfo, + permissions: &PermissionState, + consent: &ConsentContext| { + ( + evidence.user_agent().to_owned(), + evidence.client_ip().to_owned(), + client.tls_protocol.clone(), + permissions.is_set(Permission::StoreOnDevice), + consent.jurisdiction.clone(), + ) + }, + ) + .expect("every value named is carried"); + assert_eq!( + first, + ( + "ExampleAgent/1.0".to_owned(), + "203.0.113.7".to_owned(), + Some("TLSv1.3".to_owned()), + true, + ConsentContext::default().jurisdiction, + ) + ); + + let second = call + .inject( + &Module, + |_: &Module, settings: &Settings, services: &RuntimeServices| { + ( + settings.publisher.domain.clone(), + services.client_info().client_ip, + ) + }, + ) + .expect("every value named is carried"); + assert_eq!(second, ("publisher.example".to_owned(), Some(services_ip))); +} + +#[test] +fn a_value_the_context_does_not_carry_is_none_or_skips_the_call() { + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")); + let call = context.call("module", PermissionSet::none()); + + let absent = call + .inject( + &Module, + |_: &Module, + evidence: Option<&dyn RequestInfo>, + settings: Option<&Settings>, + permissions: Option<&PermissionState>| { + ( + evidence.is_none(), + settings.is_none(), + permissions.is_none(), + ) + }, + ) + .expect("an optional parameter never skips the call"); + assert_eq!(absent, (true, true, true)); + + let called = Cell::new(false); + let skipped = call + .inject(&Module, |_: &Module, _settings: &Settings| called.set(true)) + .expect_err("a parameter the context does not carry skips the call"); + assert!(!called.get(), "the function is not called"); + assert_eq!(skipped.value(), "settings"); + assert_eq!(skipped.reason(), WithheldReason::Absent); +} + +/// The three gated values, each set on a context that needs `requires` for +/// it, read back through `Option` parameters by a module declaring +/// `declared`. +fn gated_values( + permissions: Option<&PermissionState>, + requires: PermissionSet, + declared: PermissionSet, +) -> (bool, bool, bool) { + let method = Method::GET; + let geo = geo(); + let device = device(); + let mut context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_geo(&geo, requires) + .with_device(&device, requires) + .with_edge_cookie("an-id", requires); + if let Some(permissions) = permissions { + context = context.with_permissions(permissions); + } + context + .call("module", declared) + .inject( + &Module, + |_: &Module, + geo: Option<&GeoInfo>, + device: Option<&DeviceSignals>, + edge_cookie: Option>| { + (geo.is_some(), device.is_some(), edge_cookie.is_some()) + }, + ) + .expect("an optional parameter never skips the call") +} + +#[test] +fn a_gated_value_reaches_a_module_that_declares_its_permission_and_is_granted_it() { + let granted = PermissionState::new(storage()); + + assert_eq!( + gated_values(Some(&granted), storage(), storage()), + (true, true, true) + ); +} + +#[test] +fn a_gated_value_is_withheld_from_a_module_that_does_not_declare_its_permission() { + let granted = PermissionState::new(storage()); + + assert_eq!( + gated_values(Some(&granted), storage(), PermissionSet::none()), + (false, false, false), + "granted on the request is not enough without the declaration" + ); +} + +#[test] +fn a_gated_value_is_withheld_when_its_permission_is_not_set() { + let refused = PermissionState::default(); + + assert_eq!( + gated_values(Some(&refused), storage(), storage()), + (false, false, false), + "the declaration is not enough without the permission being set" + ); + assert_eq!( + gated_values(None, storage(), storage()), + (false, false, false), + "and nothing is granted where no permissions are resolved" + ); +} + +#[test] +fn a_value_whose_producer_declares_nothing_reaches_every_module() { + assert_eq!( + gated_values(None, PermissionSet::none(), PermissionSet::none()), + (true, true, true), + "evidence is not rationed" + ); +} + +#[test] +fn a_withheld_value_skips_a_call_that_names_it_and_says_why() { + let method = Method::GET; + let geo = geo(); + let refused = PermissionState::default(); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_geo(&geo, storage()) + .with_permissions(&refused); + let called = Cell::new(false); + + let undeclared = context + .call("module", PermissionSet::none()) + .inject(&Module, |_: &Module, _geo: &GeoInfo| called.set(true)) + .expect_err("geo is withheld from a module that does not declare storage"); + let unset = context + .call("module", storage()) + .inject(&Module, |_: &Module, _geo: &GeoInfo| called.set(true)) + .expect_err("geo is withheld while storage is not set"); + + assert!(!called.get(), "a skipped call is never made"); + assert_eq!(undeclared.reason(), WithheldReason::NotDeclared(storage())); + assert_eq!(unset.reason(), WithheldReason::NotGranted(storage())); + assert_eq!( + undeclared.to_string(), + "geo is withheld because the module does not declare necessary.operations.storage, \ + which its use needs" + ); +} + +#[test] +fn an_argument_of_the_callers_own_is_passed_after_the_module() { + let method = Method::GET; + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/path")); + + let answer = context + .call("module", PermissionSet::none()) + .inject_with( + &Module, + String::from("argument"), + |_: &Module, argument: String, request: ModuleRequest<'_>| { + format!("{argument} {}", request.path()) + }, + ) + .expect("nothing is withheld"); + + assert_eq!(answer, "argument /path"); +} + +impl Module { + async fn classify(&self, evidence: &dyn RequestInfo, services: &RuntimeServices) -> String { + format!( + "{} {}", + evidence.user_agent(), + services.client_info().client_ip.is_some() + ) + } + + #[allow( + clippy::too_many_arguments, + reason = "the most parameters a module function may name" + )] + fn eight( + &self, + _request: ModuleRequest<'_>, + _evidence: &dyn RequestInfo, + _permissions: &PermissionState, + _settings: &Settings, + _services: &RuntimeServices, + _geo: Option<&GeoInfo>, + _device: Option<&DeviceSignals>, + _edge_cookie: Option>, + ) -> usize { + 8 + } +} + +#[test] +fn an_async_function_is_called_and_its_future_comes_back() { + let method = Method::GET; + let evidence = evidence(); + let services = noop_services_with_client_ip("203.0.113.9".parse().expect("should parse an IP")); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_evidence(&evidence) + .with_services(&services); + let call = context.call("module", PermissionSet::none()); + + let future = call + .inject(&Module, Module::classify) + .expect("nothing is withheld"); + + assert_eq!(futures::executor::block_on(future), "ExampleAgent/1.0 true"); +} + +#[test] +fn a_function_may_name_eight_values_and_a_tuple_groups_more() { + let method = Method::GET; + let evidence = evidence(); + let permissions = PermissionState::default(); + let deployment = Settings::default(); + let request_services = noop_services(); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_evidence(&evidence) + .with_permissions(&permissions) + .with_settings(&deployment) + .with_services(&request_services); + let call = context.call("module", PermissionSet::none()); + + assert_eq!(call.inject(&Module, Module::eight).expect("all carried"), 8); + let grouped = call + .inject( + &Module, + |_: &Module, (settings, services): (&Settings, &RuntimeServices)| { + ( + std::ptr::eq(settings, &deployment), + std::ptr::eq(services, &request_services), + ) + }, + ) + .expect("a tuple is passed when every value in it is"); + assert_eq!( + grouped, + (true, true), + "the tuple holds the context's own values" + ); +} + +#[test] +fn the_request_state_carries_each_value_gated_by_what_its_producer_declares() { + let method = Method::GET; + let mut state = EcContext::new_for_test(Some("an-id".to_owned()), ConsentContext::default()) + .with_module_for_test(Arc::new(HmacModule::new(Redacted::new( + "test-secret-key-32-bytes-minimum".to_owned(), + )))) + .with_geo_for_test(geo()); + state.set_device_signals(device()); + let services = + noop_services().with_device_module(Arc::new(crate::ec::device::BuiltinDeviceModule)); + let context = ModuleContext::new(ModuleRequest::new(&method, "h", "https", "/")) + .with_request_state(&state, &services); + + let undeclared = context + .call("module", PermissionSet::none()) + .inject( + &Module, + |_: &Module, + geo: Option<&GeoInfo>, + device: Option<&DeviceSignals>, + edge_cookie: Option>, + permissions: &PermissionState| { + ( + geo.map(|geo| geo.country.clone()), + device.is_some(), + edge_cookie.map(|id| id.as_str().to_owned()), + permissions.is_set(Permission::StoreOnDevice), + ) + }, + ) + .expect("nothing named is required"); + assert_eq!( + undeclared, + (Some("GB".to_owned()), true, None, true), + "geo and the device signals come from producers declaring nothing, and the \ + identifier needs what the HMAC module declares" + ); + + let declared = context + .call("module", storage()) + .inject(&Module, |_: &Module, edge_cookie: EdgeCookie<'_>| { + edge_cookie.as_str().to_owned() + }) + .expect("a module declaring storage is passed the identifier"); + assert_eq!(declared, "an-id"); +} + +#[test] +fn a_route_answers_forbidden_only_for_a_value_its_permissions_withhold() { + use crate::error::IntoHttpResponse as _; + + let status = |reason| { + error_stack::Report::::from(Withheld::new("geo", reason)) + .current_context() + .status_code() + }; + + assert_eq!( + status(WithheldReason::NotDeclared(storage())), + http::StatusCode::FORBIDDEN + ); + assert_eq!( + status(WithheldReason::NotGranted(storage())), + http::StatusCode::FORBIDDEN + ); + assert_eq!( + status(WithheldReason::Absent), + http::StatusCode::INTERNAL_SERVER_ERROR, + "a value absent where the route runs is the route's fault, not the reader's" + ); + assert_eq!( + status(WithheldReason::Unresolved), + http::StatusCode::INTERNAL_SERVER_ERROR + ); +} diff --git a/crates/trusted-server-core/src/permission_signal/mod.rs b/crates/trusted-server-core/src/permission_signal/mod.rs index fcb5f534fa..74d7d33b8e 100644 --- a/crates/trusted-server-core/src/permission_signal/mod.rs +++ b/crates/trusted-server-core/src/permission_signal/mod.rs @@ -7,6 +7,7 @@ use error_stack::Report; use crate::consent::ConsentContext; use crate::error::TrustedServerError; use crate::evidence::RequestInfo; +use crate::module_context::{ModuleCall, ModuleContext}; use crate::permissions::{ Acquisition, ConsentSignal, Permission, PermissionSet, SignalPolicy, ValidSignal, }; @@ -16,7 +17,11 @@ use crate::tdl::Tdl; /// What a signal module may read about a request. /// /// A struct rather than a parameter list, so a module needing something new -/// does not change every implementation. +/// does not change every implementation. The fields are what the signal run +/// is deciding. Anything else in the request's module context, such as what +/// the request resolved to or the settings, a module names through +/// [`call`](Self::call). The permissions are what the run produces, so none +/// are resolved yet and no value whose use needs one is passed here. pub struct SignalInput<'a> { /// The decoded consent record for this request. /// @@ -54,10 +59,13 @@ pub struct SignalInput<'a> { /// False while answering a consultation, which is what stops two modules /// that consult each other from looping. may_ask: bool, + /// The request's module context. + context: &'a ModuleContext<'a>, } impl<'a> SignalInput<'a> { - /// An input for a module asked on its own, outside an ordered run. + /// An input for a module asked on its own, outside an ordered run, in a + /// context carrying nothing, see [`ModuleContext::empty`]. #[must_use] pub fn new( consent: &'a ConsentContext, @@ -74,6 +82,26 @@ impl<'a> SignalInput<'a> { modules: &[], position: 0, may_ask: true, + context: ModuleContext::empty(), + } + } + + /// The same input in the request's module context. + #[must_use] + pub fn with_context(self, context: &'a ModuleContext<'a>) -> Self { + Self { context, ..self } + } + + /// The module's call into the request's module context, which it hands + /// its own function to, naming what it needs. A module asked on its own + /// is called by no name and declares nothing. + #[must_use] + pub fn call(&self) -> ModuleCall<'a> { + match self.modules.get(self.position) { + Some(module) => self + .context + .call(module.id(), module.required_permissions()), + None => self.context.call("", PermissionSet::none()), } } @@ -129,6 +157,7 @@ impl<'a> SignalInput<'a> { modules: self.modules, position, may_ask: false, + context: self.context, }; Some(module.signal(permission, &input)) } @@ -167,6 +196,13 @@ pub trait PermissionSignalModule: Send + Sync { /// `[permission-signal] modules` may write as `example`. fn id(&self) -> &'static str; + /// The permissions this module declares, which decide whether a gated + /// value is passed to it. None by default. Signal modules run before the + /// permissions are resolved, so a declaration here passes nothing yet. + fn required_permissions(&self) -> PermissionSet { + PermissionSet::none() + } + /// How this module would amend `permission` for this request. fn signal(&self, permission: Permission, input: &SignalInput<'_>) -> ConsentSignal; @@ -216,12 +252,9 @@ pub trait PermissionSignalModule: Send + Sync { /// taken silently. Core carries what every module vouched for on the /// permission state, and a signal nobody vouched for is dropped from /// everything Trusted Server sends on, so a corrupt record never reaches - /// a page or a bid request. - fn valid_signal( - &self, - _consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { + /// a page or a bid request. See [`crate::module_context`] for what an + /// implementation may name. + fn valid_signal(&self, _call: ModuleCall<'_>) -> Option { None } @@ -239,7 +272,7 @@ pub trait PermissionSignalModule: Send + Sync { /// /// A locator must point at a document that is never edited once /// published, which [`Tdl`] documents and cannot enforce. - fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + fn tdls(&self, _call: ModuleCall<'_>) -> Vec { Vec::new() } } @@ -252,6 +285,7 @@ pub trait PermissionSignalModule: Send + Sync { pub(crate) fn combine( modules: &[Arc], permission: Permission, + context: &ModuleContext<'_>, consent: &ConsentContext, evidence: &dyn RequestInfo, policy: &SignalPolicy, @@ -268,6 +302,7 @@ pub(crate) fn combine( modules, position, may_ask: true, + context, }; // Every module is asked, because a later one may amend what an // earlier one settled. Stopping at the first answer would make the @@ -296,6 +331,7 @@ pub(crate) fn combine( pub(crate) fn withdrawn( modules: &[Arc], permission: Permission, + context: &ModuleContext<'_>, consent: &ConsentContext, evidence: &dyn RequestInfo, policy: &SignalPolicy, @@ -314,6 +350,7 @@ pub(crate) fn withdrawn( modules, position, may_ask: true, + context, }; module.withdraws(permission, &input) }) @@ -328,12 +365,11 @@ pub(crate) fn withdrawn( #[must_use] pub(crate) fn tdls( modules: &[Arc], - consent: &ConsentContext, - evidence: &dyn RequestInfo, + context: &ModuleContext<'_>, ) -> Arc<[Tdl]> { let mut declared: Vec = Vec::new(); for module in modules { - for tdl in module.tdls(consent, evidence) { + for tdl in module.tdls(context.call(module.id(), module.required_permissions())) { if !declared.contains(&tdl) { declared.push(tdl); } @@ -365,12 +401,13 @@ pub fn answerable( #[must_use] pub(crate) fn signals( modules: &[Arc], - consent: &ConsentContext, - evidence: &dyn RequestInfo, + context: &ModuleContext<'_>, ) -> Arc<[ValidSignal]> { modules .iter() - .filter_map(|module| module.valid_signal(consent, evidence)) + .filter_map(|module| { + module.valid_signal(context.call(module.id(), module.required_permissions())) + }) .collect() } @@ -502,6 +539,7 @@ mod tests { use super::*; use crate::evidence::OwnedRequestInfo; + use crate::module_context::test_support; /// A module that always answers the same thing, for testing the rule /// rather than any particular scheme. @@ -552,7 +590,7 @@ mod tests { ConsentSignal::Neutral } - fn tdls(&self, _consent: &ConsentContext, _evidence: &dyn RequestInfo) -> Vec { + fn tdls(&self, _call: ModuleCall<'_>) -> Vec { vec![Tdl::new(self.1).expect("should accept the test locator")] } } @@ -578,6 +616,31 @@ mod tests { OwnedRequestInfo::new(String::new(), HeaderMap::new()) } + /// The signals `modules` vouch for, asked in a context carrying `consent` + /// and no evidence. + fn signals_for( + modules: &[Arc], + consent: &ConsentContext, + ) -> Arc<[ValidSignal]> { + let evidence = no_evidence(); + let context = ModuleContext::new(test_support::request("/")) + .with_consent(consent) + .with_evidence(&evidence); + signals(modules, &context) + } + + /// The terms `modules` declare, asked as [`signals_for`] asks. + fn tdls_for( + modules: &[Arc], + consent: &ConsentContext, + ) -> Arc<[Tdl]> { + let evidence = no_evidence(); + let context = ModuleContext::new(test_support::request("/")) + .with_consent(consent) + .with_evidence(&evidence); + tdls(modules, &context) + } + fn fixed(id: &'static str, signal: ConsentSignal) -> Arc { Arc::new(Fixed(id, signal)) } @@ -643,11 +706,7 @@ mod tests { ConsentSignal::Neutral } - fn valid_signal( - &self, - _consent: &ConsentContext, - _evidence: &dyn RequestInfo, - ) -> Option { + fn valid_signal(&self, _call: ModuleCall<'_>) -> Option { Some(ValidSignal::new(self.0, self.0, self.1)) } } @@ -660,7 +719,7 @@ mod tests { fixed("silent", ConsentSignal::Revoke), Arc::new(Vouching("second", "two")), ]; - let valid = signals(&modules, &consent, &no_evidence()); + let valid = signals_for(&modules, &consent); assert_eq!( &*valid, &[ @@ -670,12 +729,7 @@ mod tests { "should carry what was vouched for, in configured order, and nothing else" ); assert!( - signals( - &[fixed("silent", ConsentSignal::Grant)], - &consent, - &no_evidence() - ) - .is_empty(), + signals_for(&[fixed("silent", ConsentSignal::Grant)], &consent).is_empty(), "a module vouches for nothing unless it says otherwise" ); } @@ -683,11 +737,7 @@ mod tests { #[test] fn a_module_declaring_no_terms_leaves_the_list_empty() { let consent = ConsentContext::default(); - let declared = tdls( - &[fixed("quiet", ConsentSignal::Grant)], - &consent, - &no_evidence(), - ); + let declared = tdls_for(&[fixed("quiet", ConsentSignal::Grant)], &consent); assert!( declared.is_empty(), "should declare nothing, because the IAB schemes carry no terms" @@ -702,7 +752,7 @@ mod tests { fixed("quiet", ConsentSignal::Neutral), Arc::new(Declaring("second", "https://terms.example.com/b/1.txt")), ]; - let declared = tdls(&modules, &consent, &no_evidence()); + let declared = tdls_for(&modules, &consent); let addresses: Vec<&str> = declared.iter().map(Tdl::as_str).collect(); assert_eq!( addresses, @@ -721,7 +771,7 @@ mod tests { Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), Arc::new(Declaring("second", "https://terms.example.com/a/1.txt")), ]; - let declared = tdls(&modules, &consent, &no_evidence()); + let declared = tdls_for(&modules, &consent); assert_eq!( declared.len(), 1, @@ -736,7 +786,7 @@ mod tests { Arc::new(Declaring("first", "https://terms.example.com/a/1.txt")), Arc::new(Declaring("second", "https://terms.example.com/a/2.txt")), ]; - let declared = tdls(&modules, &consent, &no_evidence()); + let declared = tdls_for(&modules, &consent); assert_eq!( declared.len(), 2, @@ -748,7 +798,7 @@ mod tests { fn no_modules_declare_nothing() { let consent = ConsentContext::default(); assert!( - tdls(&[], &consent, &no_evidence()).is_empty(), + tdls_for(&[], &consent).is_empty(), "should declare nothing when no module runs, rather than implying terms" ); } @@ -759,6 +809,7 @@ mod tests { combine( modules, Permission::StoreOnDevice, + ModuleContext::empty(), &consent, &no_evidence(), &policy, @@ -781,6 +832,7 @@ mod tests { withdrawn( modules, Permission::StoreOnDevice, + ModuleContext::empty(), &consent, &no_evidence(), &policy, diff --git a/crates/trusted-server-core/src/permissions.rs b/crates/trusted-server-core/src/permissions.rs index fbd7b7a2dd..ccb8fa0f52 100644 --- a/crates/trusted-server-core/src/permissions.rs +++ b/crates/trusted-server-core/src/permissions.rs @@ -292,6 +292,12 @@ impl PermissionSet { Self(self.0 | other.0) } + /// The permissions in this set and not in `other`. + #[must_use] + pub const fn difference(self, other: PermissionSet) -> Self { + Self(self.0 & !other.0) + } + /// Iterates the permissions in the set, in bit-index order. /// /// The built-ins read nothing from the full set; this serves a module or @@ -301,6 +307,23 @@ impl PermissionSet { } } +/// The Data Use identifiers in the set, in bit-index order, separated by +/// `, `, or `nothing` for the empty set. +impl core::fmt::Display for PermissionSet { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + if self.is_empty() { + return f.write_str("nothing"); + } + for (index, permission) in self.iter().enumerate() { + if index > 0 { + f.write_str(", ")?; + } + f.write_str(permission.as_str())?; + } + Ok(()) + } +} + impl FromIterator for PermissionSet { fn from_iter>(iter: I) -> Self { iter.into_iter() diff --git a/docs/guide/integration-guide.md b/docs/guide/integration-guide.md index d8ca298eef..d10be83bc5 100644 --- a/docs/guide/integration-guide.md +++ b/docs/guide/integration-guide.md @@ -7,8 +7,10 @@ Axum, or Spin SDK types. ## Choose the narrowest hook -- `IntegrationProxy` owns explicit method/path endpoints and receives - `Settings`, `RuntimeServices`, and an EdgeZero-neutral request. +- `IntegrationProxy` owns explicit method/path endpoints and receives an + EdgeZero-neutral request and its call into the request's module context, + from which it names the `Settings`, the `RuntimeServices` and whatever + else it needs. - `IntegrationAttributeRewriter` inspects selected HTML attributes. - `IntegrationScriptRewriter` handles one declared selector. - `IntegrationHeadInjector` inserts deterministic head markup. @@ -193,12 +195,21 @@ is omitted, so adapter startup must construct the complete service graph. ## Proxy implementation rules -An `IntegrationProxy::handle` implementation receives the complete runtime -service graph. Register or predict backends through `services.backend()`, -send through `services.http_client()`, bound request and response bodies, and -return `Report` with integration context. The registry -strips internal identity headers before dispatch; an integration must opt into -any explicit forwarding behavior. +An `IntegrationProxy::handle` implementation receives the request and its +`ModuleCall`. It hands a function of its own to `call.inject_with`, with the +request as that function's own argument, and names what else it needs as +parameters, such as `&Settings` and `&RuntimeServices`, which is the complete +runtime service graph. Register or predict backends through +`services.backend()`, send through `services.http_client()`, bound request and +response bodies, and return `Report` with the route's +context. The registry strips internal identity headers before dispatch, and a +route must opt into any explicit forwarding behavior. + +A route that names a value whose use needs a permission, such as the Edge +Cookie identifier, declares that permission in `required_permissions` and is +passed the value only on a request that grants it. Nothing gates the route +itself, so a route that must not run without a permission reads the +`&PermissionState` and refuses. Streaming support is an adapter capability. Request `PlatformHttpRequest::with_stream_response()` only when the caller has @@ -331,6 +342,82 @@ startup. The message lists the modules of that type the deployment runs, and says when the name is a module no section selects or one that supplies no module of that type. +### What a module is handed + +Core builds one module context for a request and hands each module it calls +a `ModuleCall`, which is that context together with the permissions the +module declares. The module hands a function of its own to `call.inject`, +and that function names what it needs as parameters. Each is passed in from +the context, which borrows what the request already holds. + +```rust +impl ExampleProxy { + /// Answers with the country the request came from, when the location + /// may be passed to this route. + async fn route( + &self, + _req: Request, + settings: &Settings, + geo: Option<&GeoInfo>, + ) -> Result, Report> { + let country = geo.map_or("unknown", |geo| geo.country.as_str()); + let body = format!("{} {country}", settings.publisher.domain); + Ok(Response::new(EdgeBody::from(body))) + } +} + +#[async_trait(?Send)] +impl IntegrationProxy for ExampleProxy { + fn integration_name(&self) -> &'static str { + "example" + } + + fn routes(&self) -> Vec { + vec![self.get("/country")] + } + + async fn handle( + &self, + call: ModuleCall<'_>, + req: Request, + ) -> Result, Report> { + call.inject_with(self, req, Self::route)?.await + } +} +``` + +The values a function may name, and where each is absent, are listed in the +documentation of `trusted_server_core::module_context`. A parameter may be an +`Option` of one of them, which is `None` where the value is absent or +withheld, or a tuple of several. A function that names a value itself is not +called when the value cannot be passed, and the reason goes to the debug log. +The module decides what that means. A device module answers the unknown +device, an Edge Cookie module creates no identifier, and a route's `?` answers +`403` for a value the request's permissions withhold and `500` for one absent +where the route runs. `call.inject_with` passes one argument of the caller's +own after the module, which is how a route takes its request and how an Edge +Cookie module takes the value a page posted. + +The location, the device signals and the Edge Cookie identifier each carry +the permissions that the module which produced them declares. One of them is +passed only to a module that declares every one of those permissions in its +`required_permissions` and is granted them on this request. A value whose +producer declares no permission is passed to every module. + +What a context carries depends on where the module is called. + +| Module | Called through | What its context carries | +| ----------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Device | `DeviceModule::detect` | The request, the evidence, the settings and the services. No permission is resolved yet, so nothing whose use needs one | +| Permission signal | `SignalInput::call`, `valid_signal` and `tdls` | The request, the evidence, the connection, the consent, the settings and the services, and the location when its use needs no permission. The permissions are what the run produces | +| Edge Cookie | `EdgeCookieModule::generate` and `resolve_from_client` | Every row of the table above | +| Route | `IntegrationProxy::handle` | Every row of the table above except the evidence, because the route holds the request itself | + +A request preparer and a response finalizer are plain functions on the +builder and are not handed the context. A preparer changes the request before +anything about it is resolved, and a finalizer is handed only what its own +module left for the request. + ### Acting on one request A module that decides something about one request and acts on it later diff --git a/tools/permissions-inspector/wasm/src/lib.rs b/tools/permissions-inspector/wasm/src/lib.rs index 2031a4a09d..59f1e26c77 100644 --- a/tools/permissions-inspector/wasm/src/lib.rs +++ b/tools/permissions-inspector/wasm/src/lib.rs @@ -11,6 +11,7 @@ use trusted_server_core::consent::build_context_from_signals; use trusted_server_core::consent::types::RawConsentSignals; use trusted_server_core::ec::consent::{GeoStatus, assemble_permissions}; use trusted_server_core::evidence::OwnedRequestInfo; +use trusted_server_core::module_context::ModuleContext; use trusted_server_core::permission_signal::PermissionSignalModule; use trusted_server_core::permissions::{Permission, PermissionMaps}; use trusted_server_core::platform::GeoInfo; @@ -71,17 +72,21 @@ fn eval_json(input: &str) -> String { // request evidence, so empty evidence changes none of their answers. A // module that read a header or a cookie would need real evidence here. let evidence = OwnedRequestInfo::default(); + // The page stands for no request, so the modules are asked in the empty + // context with the signals entered. + let context = ModuleContext::empty() + .with_consent(&ctx) + .with_evidence(&evidence); let modules = modules(); // The page's repository mode evaluates the repository's sample. let maps = sample_maps(); let (state, jurisdiction) = match input.geo.as_str() { "failed" => { - let state = assemble_permissions(maps, &ctx, &evidence, GeoStatus::Failed, &modules); + let state = assemble_permissions(maps, &context, GeoStatus::Failed, &modules); (state, "unknown".to_string()) } "none" => { - let state = - assemble_permissions(maps, &ctx, &evidence, GeoStatus::NoLocation, &modules); + let state = assemble_permissions(maps, &context, GeoStatus::NoLocation, &modules); (state, jurisdiction_name(maps.default_jurisdiction())) } _ => { @@ -95,8 +100,7 @@ fn eval_json(input: &str) -> String { region: input.region.clone().filter(|r| !r.is_empty()), asn: None, }; - let state = - assemble_permissions(maps, &ctx, &evidence, GeoStatus::Located(&info), &modules); + let state = assemble_permissions(maps, &context, GeoStatus::Located(&info), &modules); let jurisdiction = jurisdiction_name( maps.jurisdiction_for(input.country.as_deref(), input.region.as_deref()), );