diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index 4bbd9c78..d7fba408 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -11,7 +11,10 @@ use trusted_server_core::constants::{ HEADER_X_TS_ENV, HEADER_X_TS_VERSION, }; use trusted_server_core::ec::admin::handle_register_partner; +use trusted_server_core::ec::finalize::ec_finalize_response; +use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::partner::PartnerStore; +use trusted_server_core::ec::EcContext; use trusted_server_core::error::TrustedServerError; use trusted_server_core::geo::GeoInfo; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -80,10 +83,29 @@ async fn route_request( // clients are untrusted and can hijack URL rewriting (see #409). sanitize_forwarded_headers(&mut req); - // Extract geo info before auth check or routing consumes the request + // Extract geo info before auth check or routing consumes the request. let geo_info = GeoInfo::from_request(&req); + let mut ec_context = + match EcContext::read_from_request_with_geo(settings, &req, geo_info.as_ref()) { + Ok(context) => context, + Err(err) => { + let mut response = to_error_response(&err); + finalize_response(settings, geo_info.as_ref(), &mut response); + return Ok(response); + } + }; + + let kv_graph = maybe_identity_graph(settings); + if let Some(mut response) = enforce_basic_auth(settings, &req) { + ec_finalize_response( + settings, + geo_info.as_ref(), + &ec_context, + kv_graph.as_ref(), + &mut response, + ); finalize_response(settings, geo_info.as_ref(), &mut response); return Ok(response); } @@ -116,7 +138,9 @@ async fn route_request( } // Unified auction endpoint (returns creative HTML inline) - (Method::POST, "/auction") => handle_auction(settings, orchestrator, req).await, + (Method::POST, "/auction") => { + handle_auction(settings, orchestrator, kv_graph.as_ref(), &ec_context, req).await + } // tsjs endpoints (Method::GET, "/first-party/proxy") => handle_first_party_proxy(settings, req).await, @@ -128,7 +152,7 @@ async fn route_request( handle_first_party_proxy_rebuild(settings, req).await } (m, path) if integration_registry.has_route(&m, path) => integration_registry - .handle_proxy(&m, path, settings, req) + .handle_proxy(&m, path, settings, kv_graph.as_ref(), &mut ec_context, req) .await .unwrap_or_else(|| { Err(Report::new(TrustedServerError::BadRequest { @@ -143,7 +167,13 @@ async fn route_request( path ); - match handle_publisher_request(settings, integration_registry, req) { + match handle_publisher_request( + settings, + integration_registry, + kv_graph.as_ref(), + &mut ec_context, + req, + ) { Ok(response) => Ok(response), Err(e) => { log::error!("Failed to proxy to publisher origin: {:?}", e); @@ -156,11 +186,23 @@ async fn route_request( // Convert any errors to HTTP error responses let mut response = result.unwrap_or_else(|e| to_error_response(&e)); + ec_finalize_response( + settings, + geo_info.as_ref(), + &ec_context, + kv_graph.as_ref(), + &mut response, + ); + finalize_response(settings, geo_info.as_ref(), &mut response); Ok(response) } +fn maybe_identity_graph(settings: &Settings) -> Option { + settings.ec.ec_store.as_ref().map(KvIdentityGraph::new) +} + /// Applies all standard response headers: geo, version, staging, and configured headers. /// /// Called from every response path (including auth early-returns) so that all diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c78ddcbc..b747e0af 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -4,6 +4,7 @@ use error_stack::{Report, ResultExt}; use fastly::{Request, Response}; use crate::auction::formats::AdRequest; +use crate::ec::kv::KvIdentityGraph; use crate::ec::EcContext; use crate::error::TrustedServerError; use crate::settings::Settings; @@ -28,20 +29,10 @@ use super::AuctionOrchestrator; pub async fn handle_auction( settings: &Settings, orchestrator: &AuctionOrchestrator, + _kv: Option<&KvIdentityGraph>, + ec_context: &EcContext, mut req: Request, ) -> Result> { - // Read EC state before consuming the request body. - let mut ec_context = EcContext::read_from_request(settings, &req).change_context( - TrustedServerError::Auction { - message: "Failed to read EC context".to_string(), - }, - )?; - - // Auction is an organic handler — generate EC if needed. - if let Err(err) = ec_context.generate_if_needed(settings) { - log::warn!("EC generation failed for auction: {err:?}"); - } - // Parse request body let body: AdRequest = serde_json::from_slice(&req.take_body_bytes()).change_context( TrustedServerError::Auction { @@ -54,6 +45,8 @@ pub async fn handle_auction( body.ad_units.len() ); + // Story 5 middleware contract: auction is a read-only EC route. + // It must not generate EC IDs; it only consumes pre-routed context. // Only forward the EC ID to auction partners when consent allows it. // A returning user may still have a ts-ec cookie but have since // withdrawn consent — forwarding that revoked ID to bidders would diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs new file mode 100644 index 00000000..1c2bf71d --- /dev/null +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -0,0 +1,324 @@ +//! EC response finalization. +//! +//! Centralizes post-routing EC behavior so all handlers get consistent cookie +//! and KV semantics. + +use std::collections::HashSet; + +use fastly::Response; + +use crate::consent::allows_ec_creation; +use crate::consent::kv::delete_consent_from_kv; +use crate::constants::HEADER_X_TS_EC; +use crate::geo::GeoInfo; +use crate::settings::Settings; + +use super::cookies::{expire_ec_cookie, set_ec_cookie}; +use super::generation::{ec_hash, is_valid_ec_id}; +use super::kv::KvIdentityGraph; +use super::EcContext; + +/// TS-managed response headers tied to EC identity output. +const EC_RESPONSE_HEADERS: &[&str] = &[ + "x-ts-ec", + "x-ts-eids", + "x-ts-ec-consent", + "x-ts-eids-truncated", +]; + +/// Finalizes EC response behavior for all routes. +/// +/// Applies withdrawal handling, last-seen updates, cookie reconciliation, +/// and cookie writes for new EC generation. +pub fn ec_finalize_response( + settings: &Settings, + _geo_info: Option<&GeoInfo>, // reserved for future route-specific finalize behavior + ec_context: &EcContext, + kv: Option<&KvIdentityGraph>, + response: &mut Response, +) { + let consent_allows_ec = allows_ec_creation(ec_context.consent()); + + // Withdrawal path: no consent + cookie was present. + if !consent_allows_ec && ec_context.cookie_was_present() { + clear_ec_on_response(settings, response); + + if let Some(store_name) = settings.consent.consent_store.as_deref() { + for ec_id in withdrawal_ec_ids(ec_context) { + delete_consent_from_kv(store_name, &ec_id); + } + } + + if let Some(graph) = kv { + for hash in withdrawal_hashes(ec_context) { + if let Err(err) = graph.write_withdrawal_tombstone(&hash) { + log::error!( + "Failed to write withdrawal tombstone for hash '{}': {err:?}", + hash, + ); + } + } + } + + return; + } + + // Returning user: consent is granted and EC came from request. + if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { + if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value()) { + let hash = ec_hash(ec_id); + if is_valid_ec_hash(hash) { + if let Err(err) = graph.update_last_seen(hash, current_timestamp()) { + log::error!("Failed to update last_seen for hash '{}': {err:?}", hash,); + } + } + } + + // If header/cookie were mismatched, rewrite cookie to active EC value. + if ec_context.has_cookie_mismatch() { + set_ec_on_response(settings, ec_context, response); + } + + return; + } + + // Newly generated EC in this request. + if ec_context.ec_generated() { + set_ec_on_response(settings, ec_context, response); + } +} + +/// Sets EC header + cookie on response when an EC ID is available. +pub fn set_ec_on_response(settings: &Settings, ec_context: &EcContext, response: &mut Response) { + if let Some(ec_id) = ec_context.ec_value() { + response.set_header(HEADER_X_TS_EC, ec_id); + set_ec_cookie(settings, response, ec_id); + } +} + +/// Clears EC cookie and removes EC-specific response headers. +pub fn clear_ec_on_response(settings: &Settings, response: &mut Response) { + expire_ec_cookie(settings, response); + + for header in EC_RESPONSE_HEADERS { + response.remove_header(*header); + } +} + +fn withdrawal_hashes(ec_context: &EcContext) -> HashSet { + withdrawal_ec_ids(ec_context) + .into_iter() + .map(|ec_id| ec_hash(&ec_id).to_owned()) + .collect() +} + +fn withdrawal_ec_ids(ec_context: &EcContext) -> HashSet { + let mut hashes = HashSet::new(); + + if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() { + if is_valid_ec_id(cookie_ec_id) { + hashes.insert(cookie_ec_id.to_owned()); + } + } + + if let Some(active_ec_id) = ec_context.ec_value() { + if is_valid_ec_id(active_ec_id) { + hashes.insert(active_ec_id.to_owned()); + } + } + + hashes +} + +fn is_valid_ec_hash(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|b| b.is_ascii_hexdigit()) +} + +fn current_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::consent::jurisdiction::Jurisdiction; + use crate::consent::types::ConsentSource; + use crate::test_support::tests::create_test_settings; + + fn make_context( + ec_value: Option<&str>, + cookie_ec_value: Option<&str>, + ec_was_present: bool, + ec_generated: bool, + jurisdiction: Jurisdiction, + ) -> EcContext { + let consent = crate::consent::types::ConsentContext { + jurisdiction, + source: ConsentSource::Cookie, + ..Default::default() + }; + + EcContext { + ec_value: ec_value.map(str::to_owned), + cookie_ec_value: cookie_ec_value.map(str::to_owned), + ec_was_present, + ec_generated, + consent, + client_ip: None, + geo_info: None, + } + } + + fn sample_ec_id(suffix: &str) -> String { + format!("{}.{suffix}", "a".repeat(64)) + } + + #[test] + fn clear_ec_on_response_removes_headers_and_expires_cookie() { + let settings = create_test_settings(); + let mut response = Response::new(); + response.set_header("x-ts-ec", "abc"); + response.set_header("x-ts-eids", "[]"); + + clear_ec_on_response(&settings, &mut response); + + assert!( + response.get_header("x-ts-ec").is_none(), + "should remove x-ts-ec" + ); + assert!( + response.get_header("x-ts-eids").is_none(), + "should remove x-ts-eids" + ); + + let set_cookie = response + .get_header("set-cookie") + .expect("should append Set-Cookie for expiry") + .to_str() + .expect("should render set-cookie as utf-8"); + + assert!( + set_cookie.contains("Max-Age=0"), + "should expire the EC cookie" + ); + } + + #[test] + fn finalize_withdrawal_clears_cookie_and_headers() { + let settings = create_test_settings(); + let ec_id = sample_ec_id("ABC123"); + let ec_context = make_context( + Some(&ec_id), + Some(&ec_id), + true, + false, + Jurisdiction::Unknown, + ); + let mut response = Response::new(); + response.set_header("x-ts-ec", "stale"); + response.set_header("x-ts-eids", "[]"); + + ec_finalize_response(&settings, None, &ec_context, None, &mut response); + + assert!( + response.get_header("x-ts-ec").is_none(), + "withdrawal should clear x-ts-ec header" + ); + assert!( + response.get_header("x-ts-eids").is_none(), + "withdrawal should clear x-ts-eids header" + ); + let set_cookie = response + .get_header("set-cookie") + .expect("withdrawal should expire cookie") + .to_str() + .expect("set-cookie should be utf-8"); + assert!( + set_cookie.contains("Max-Age=0"), + "withdrawal should set Max-Age=0" + ); + } + + #[test] + fn finalize_returning_user_with_cookie_mismatch_rewrites_cookie_and_header() { + let settings = create_test_settings(); + let active_ec = sample_ec_id("ACTIVE1"); + let cookie_ec = sample_ec_id("COOKIE1"); + let ec_context = make_context( + Some(&active_ec), + Some(&cookie_ec), + true, + false, + Jurisdiction::NonRegulated, + ); + let mut response = Response::new(); + + ec_finalize_response(&settings, None, &ec_context, None, &mut response); + + let header = response + .get_header("x-ts-ec") + .expect("mismatch should set x-ts-ec") + .to_str() + .expect("x-ts-ec should be utf-8"); + assert_eq!(header, active_ec, "should set active EC on header"); + + let set_cookie = response + .get_header("set-cookie") + .expect("mismatch should rewrite cookie") + .to_str() + .expect("set-cookie should be utf-8"); + assert!( + set_cookie.contains(&format!("ts-ec={active_ec}")), + "cookie should be rewritten to active EC" + ); + } + + #[test] + fn finalize_generated_ec_sets_cookie_and_header() { + let settings = create_test_settings(); + let generated_ec = sample_ec_id("GEN123"); + let ec_context = make_context( + Some(&generated_ec), + None, + false, + true, + Jurisdiction::NonRegulated, + ); + let mut response = Response::new(); + + ec_finalize_response(&settings, None, &ec_context, None, &mut response); + + let header = response + .get_header("x-ts-ec") + .expect("generated EC should set response header") + .to_str() + .expect("x-ts-ec should be utf-8"); + assert_eq!(header, generated_ec, "header should contain generated EC"); + + assert!( + response.get_header("set-cookie").is_some(), + "generated EC should set cookie" + ); + } + + #[test] + fn finalize_denied_without_cookie_is_noop() { + let settings = create_test_settings(); + let ec_context = make_context(None, None, false, false, Jurisdiction::Unknown); + let mut response = Response::new(); + + ec_finalize_response(&settings, None, &ec_context, None, &mut response); + + assert!( + response.get_header("x-ts-ec").is_none(), + "should not set EC header" + ); + assert!( + response.get_header("set-cookie").is_none(), + "should not mutate cookie when there is nothing to revoke" + ); + } +} diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index df5e6289..cb440537 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -24,6 +24,7 @@ pub mod admin; pub mod consent; pub mod cookies; +pub mod finalize; pub mod generation; pub mod kv; pub mod kv_types; @@ -40,6 +41,9 @@ use crate::error::TrustedServerError; use crate::geo::GeoInfo; use crate::settings::Settings; +use self::kv::KvIdentityGraph; +use self::kv_types::KvEntry; + pub use generation::{ec_hash, generate_ec_id, is_valid_ec_id}; /// Parsed EC identity from an incoming request. @@ -122,6 +126,8 @@ pub struct EcContext { /// The normalized client IP, captured early before the request body /// is consumed. `None` when the platform cannot determine client IP. client_ip: Option, + /// Geo information captured pre-routing for downstream KV writes. + geo_info: Option, } impl EcContext { @@ -140,6 +146,23 @@ impl EcContext { pub fn read_from_request( settings: &Settings, req: &Request, + ) -> Result> { + let geo_info = GeoInfo::from_request(req); + Self::read_from_request_with_geo(settings, req, geo_info.as_ref()) + } + + /// Reads EC state from an incoming request using pre-extracted geo data. + /// + /// Use this when geo has already been resolved in router prelude to avoid + /// duplicate lookup work. + /// + /// # Errors + /// + /// Returns an error if cookie parsing fails. + pub fn read_from_request_with_geo( + settings: &Settings, + req: &Request, + geo_info: Option<&GeoInfo>, ) -> Result> { let parsed = parse_ec_from_request(req)?; @@ -157,12 +180,11 @@ impl EcContext { // Build consent context. Pass the EC ID (if any) so the consent // pipeline can use it for KV Store fallback/write operations. - let geo = GeoInfo::from_request(req); let consent = consent_mod::build_consent_context(&ConsentPipelineInput { jar: parsed.jar.as_ref(), req, config: &settings.consent, - geo: geo.as_ref(), + geo: geo_info, ec_id: ec_value.as_deref(), }); @@ -173,6 +195,7 @@ impl EcContext { ec_generated: false, consent, client_ip, + geo_info: geo_info.cloned(), }) } @@ -192,6 +215,7 @@ impl EcContext { pub fn generate_if_needed( &mut self, settings: &Settings, + kv: Option<&KvIdentityGraph>, ) -> Result<(), Report> { if self.ec_value.is_some() { return Ok(()); @@ -216,6 +240,19 @@ impl EcContext { self.ec_value = Some(ec_id); self.ec_generated = true; + if let (Some(graph), Some(ec_value)) = (kv, self.ec_value.as_deref()) { + let now = current_timestamp(); + let entry = KvEntry::new(&self.consent, self.geo_info.as_ref(), now); + let ec_hash = generation::ec_hash(ec_value); + + if let Err(err) = graph.create_or_revive(ec_hash, &entry) { + log::error!( + "Failed to create or revive EC entry for hash '{}' after generation: {err:?}", + ec_hash, + ); + } + } + Ok(()) } @@ -256,6 +293,12 @@ impl EcContext { self.client_ip.as_deref() } + /// Returns the pre-routing geo data, if available. + #[must_use] + pub fn geo_info(&self) -> Option<&GeoInfo> { + self.geo_info.as_ref() + } + /// Returns whether EC creation is permitted by consent for this request. #[must_use] pub fn ec_allowed(&self) -> bool { @@ -272,6 +315,22 @@ impl EcContext { pub fn existing_cookie_ec_id(&self) -> Option<&str> { self.cookie_ec_value.as_deref() } + + /// Returns true when both cookie and active EC are present and differ. + #[must_use] + pub fn has_cookie_mismatch(&self) -> bool { + matches!( + (self.cookie_ec_value.as_deref(), self.ec_value.as_deref()), + (Some(cookie), Some(active)) if cookie != active + ) + } +} + +fn current_timestamp() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) } #[cfg(test)] @@ -350,7 +409,7 @@ mod tests { let req = create_test_request(&[("x-ts-ec", "existing-id")]); let mut ec = EcContext::read_from_request(&settings, &req).expect("should read EC context"); - ec.generate_if_needed(&settings) + ec.generate_if_needed(&settings, None) .expect("should not error when EC already exists"); assert_eq!( diff --git a/crates/trusted-server-core/src/integrations/registry.rs b/crates/trusted-server-core/src/integrations/registry.rs index 47e5b456..68dcabc2 100644 --- a/crates/trusted-server-core/src/integrations/registry.rs +++ b/crates/trusted-server-core/src/integrations/registry.rs @@ -9,7 +9,7 @@ use fastly::{Request, Response}; use matchit::Router; use crate::constants::HEADER_X_TS_EC; -use crate::ec::cookies::{expire_ec_cookie, set_ec_cookie}; +use crate::ec::kv::KvIdentityGraph; use crate::ec::EcContext; use crate::error::TrustedServerError; use crate::settings::Settings; @@ -644,61 +644,30 @@ impl IntegrationRegistry { /// Dispatch a proxy request when an integration handles the path. /// - /// This method automatically sets the `x-ts-ec` header and - /// `ts-ec` cookie on successful responses. + /// This method sets request-side `x-ts-ec` for integration backends. + /// Response-side cookie/header mutation is centralized in EC finalize. #[must_use] pub async fn handle_proxy( &self, method: &Method, path: &str, settings: &Settings, + kv: Option<&KvIdentityGraph>, + ec_context: &mut EcContext, mut req: Request, ) -> Option>> { if let Some((proxy, _)) = self.find_route(method, path) { - // Read EC state and generate if needed before consuming request. - let ec_context = match EcContext::read_from_request(settings, &req) { - Ok(mut ec) => { - if let Err(err) = ec.generate_if_needed(settings) { - log::warn!("EC generation failed for integration proxy: {err:?}"); - } - Some(ec) - } - Err(err) => { - log::warn!("Failed to read EC context for integration proxy: {err:?}"); - None - } - }; + // Organic proxy handler: generate if needed (best effort). + if let Err(err) = ec_context.generate_if_needed(settings, kv) { + log::warn!("EC generation failed for integration proxy: {err:?}"); + } // Set EC ID header on the request so integrations can read it. - if let Some(ec_id) = ec_context.as_ref().and_then(|ec| ec.ec_value()) { + if let Some(ec_id) = ec_context.ec_value() { req.set_header(HEADER_X_TS_EC, ec_id); } - let mut result = proxy.handle(settings, req).await; - - // Consent-gated EC on successful responses: - // - Consent given → set header + cookie. - // - Consent denied + existing cookie → expire cookie (revoke). - // - Otherwise → set header only (for internal use). - if let Ok(ref mut response) = result { - if let Some(ref ec) = ec_context { - if let Some(ec_id) = ec.ec_value() { - response.set_header(HEADER_X_TS_EC, ec_id); - if ec.ec_allowed() { - set_ec_cookie(settings, response, ec_id); - } - } - if !ec.ec_allowed() { - if let Some(cookie_ec_id) = ec.existing_cookie_ec_id() { - log::info!( - "EC revoked for '{cookie_ec_id}': consent withdrawn on integration proxy" - ); - expire_ec_cookie(settings, response); - } - } - } - } - Some(result) + Some(proxy.handle(settings, req).await) } else { None } @@ -1253,16 +1222,21 @@ mod tests { async fn handle( &self, _settings: &Settings, - _req: Request, + req: Request, ) -> Result> { - // Return a simple response without the EC ID header. - // The registry's handle_proxy should add it. - Ok(Response::from_status(fastly::http::StatusCode::OK).with_body("test response")) + let mut response = + Response::from_status(fastly::http::StatusCode::OK).with_body("test response"); + + if let Some(ec) = req.get_header(HEADER_X_TS_EC) { + response.set_header("x-echo-ts-ec", ec); + } + + Ok(response) } } #[test] - fn handle_proxy_sets_ec_id_header_on_response() { + fn handle_proxy_sets_ec_id_header_on_request() { let settings = create_test_settings(); let routes = vec![( Method::GET, @@ -1278,12 +1252,16 @@ mod tests { // the test environment, so generation would fail). let mut req = Request::get("https://test-publisher.com/integrations/test/ec"); req.set_header("x-ts-ec", "test-ec-id-from-header"); + let mut ec_context = + EcContext::read_from_request(&settings, &req).expect("should read EC context"); // Call handle_proxy (uses futures executor in test environment) let result = futures::executor::block_on(registry.handle_proxy( &Method::GET, "/integrations/test/ec", &settings, + None, + &mut ec_context, req, )); @@ -1294,23 +1272,15 @@ mod tests { let response = response.unwrap(); - // The x-ts-ec header is always set for internal use by downstream - // integrations, regardless of consent. - assert!( - response.get_header(HEADER_X_TS_EC).is_some(), - "should have x-ts-ec header on response" - ); - - // Without geo data, jurisdiction is Unknown → consent denied - // (fail-closed). The Set-Cookie header should not be set. + // The x-ts-ec header should be set on outbound integration request. assert!( - response.get_header(header::SET_COOKIE).is_none(), - "should not set Set-Cookie when consent is denied" + response.get_header("x-echo-ts-ec").is_some(), + "should have x-ts-ec header on integration request" ); } #[test] - fn handle_proxy_skips_cookie_when_consent_denied() { + fn handle_proxy_keeps_request_ec_even_when_consent_denied() { let settings = create_test_settings(); let routes = vec![( Method::GET, @@ -1323,32 +1293,25 @@ mod tests { let mut req = Request::get("https://test.example.com/integrations/test/ec"); // Pre-existing cookie, but no geo data → Unknown jurisdiction → consent denied. req.set_header(header::COOKIE, "ts-ec=existing_id_12345"); + let mut ec_context = + EcContext::read_from_request(&settings, &req).expect("should read EC context"); let result = futures::executor::block_on(registry.handle_proxy( &Method::GET, "/integrations/test/ec", &settings, + None, + &mut ec_context, req, )) .expect("should handle proxy request"); let response = result.expect("proxy handle should succeed"); - // The x-ts-ec header is always set (for internal use by integrations). - assert!( - response.get_header(HEADER_X_TS_EC).is_some(), - "should still have x-ts-ec header for internal use" - ); - - // Without geo data, jurisdiction is Unknown and consent is denied - // (fail-closed). An existing cookie should be expired (revoked). - let set_cookie = response - .get_header(header::SET_COOKIE) - .expect("should have Set-Cookie header to expire the existing cookie"); - let cookie_str = set_cookie.to_str().expect("should be valid UTF-8"); + // The x-ts-ec request header is still set for integration request flow. assert!( - cookie_str.contains("Max-Age=0"), - "should expire the EC cookie when consent is denied, got: {cookie_str}" + response.get_header("x-echo-ts-ec").is_some(), + "should still set x-ts-ec on integration request" ); } @@ -1368,11 +1331,15 @@ mod tests { let mut req = Request::post("https://test-publisher.com/integrations/test/ec").with_body("test body"); req.set_header("x-ts-ec", "test-ec-id-from-header"); + let mut ec_context = + EcContext::read_from_request(&settings, &req).expect("should read EC context"); let result = futures::executor::block_on(registry.handle_proxy( &Method::POST, "/integrations/test/ec", &settings, + None, + &mut ec_context, req, )); @@ -1382,8 +1349,8 @@ mod tests { let response = response.unwrap(); assert!( - response.get_header(HEADER_X_TS_EC).is_some(), - "POST response should have x-ts-ec header" + response.get_header("x-echo-ts-ec").is_some(), + "POST integration request should include x-ts-ec" ); } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 7de890f8..c0c27128 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -3,8 +3,8 @@ use fastly::http::{header, StatusCode}; use fastly::{Body, Request, Response}; use crate::backend::BackendConfig; -use crate::constants::{HEADER_X_COMPRESS_HINT, HEADER_X_TS_EC}; -use crate::ec::cookies::{expire_ec_cookie, set_ec_cookie}; +use crate::constants::HEADER_X_COMPRESS_HINT; +use crate::ec::kv::KvIdentityGraph; use crate::ec::EcContext; use crate::error::TrustedServerError; use crate::http_util::{serve_static_with_etag, RequestInfo}; @@ -213,6 +213,8 @@ fn create_html_stream_processor( pub fn handle_publisher_request( settings: &Settings, integration_registry: &IntegrationRegistry, + kv: Option<&KvIdentityGraph>, + ec_context: &mut EcContext, mut req: Request, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -234,13 +236,9 @@ pub fn handle_publisher_request( req.get_header("x-forwarded-proto"), ); - // Read EC state from the request (existing ID, consent, client IP). - // This must happen before the request body is consumed. - let mut ec_context = EcContext::read_from_request(settings, &req)?; - // Generate a new EC ID if none exists and consent allows it. // This is an organic handler, so generation is permitted here. - if let Err(err) = ec_context.generate_if_needed(settings) { + if let Err(err) = ec_context.generate_if_needed(settings, kv) { log::warn!("EC generation failed: {err:?}"); } @@ -344,31 +342,6 @@ pub fn handle_publisher_request( ); } - // Consent-gated EC creation: - // - Consent given → set EC ID header + cookie. - // - Consent absent + existing cookie → revoke (expire cookie + delete KV entry). - // - Consent absent + no cookie → do nothing. - if ec_allowed { - if let Some(ec_id) = ec_context.ec_value() { - response.set_header(HEADER_X_TS_EC, ec_id); - set_ec_cookie(settings, &mut response, ec_id); - } - } else if let Some(cookie_ec_id) = ec_context.existing_cookie_ec_id() { - log::info!( - "EC revoked for '{cookie_ec_id}': consent withdrawn (jurisdiction={})", - ec_context.consent().jurisdiction, - ); - expire_ec_cookie(settings, &mut response); - if let Some(store_name) = &settings.consent.consent_store { - crate::consent::kv::delete_consent_from_kv(store_name, cookie_ec_id); - } - } else { - log::debug!( - "EC skipped: no consent and no existing cookie (jurisdiction={})", - ec_context.consent().jurisdiction, - ); - } - Ok(response) } diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index e4dbcfa2..d5767abf 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -152,12 +152,12 @@ Core publisher settings for domain, origin, and proxy configuration. ### `[publisher]` -| Field | Type | Required | Description | -| --------------- | ------ | -------- | ------------------------------------------------------- | -| `domain` | String | Yes | Publisher's apex domain name | -| `cookie_domain` | String | Yes | Domain for non-EC cookies (typically with leading dot) | -| `origin_url` | String | Yes | Full URL of publisher origin server | -| `proxy_secret` | String | Yes | Secret key for encrypting/signing proxy URLs | +| Field | Type | Required | Description | +| --------------- | ------ | -------- | ------------------------------------------------------ | +| `domain` | String | Yes | Publisher's apex domain name | +| `cookie_domain` | String | Yes | Domain for non-EC cookies (typically with leading dot) | +| `origin_url` | String | Yes | Full URL of publisher origin server | +| `proxy_secret` | String | Yes | Secret key for encrypting/signing proxy URLs | > **Note:** EC cookies (`ts-ec`) derive their domain automatically as `.{domain}` and > do not use `cookie_domain`. The `cookie_domain` field is used by other cookie helpers. @@ -270,11 +270,11 @@ Settings for generating privacy-preserving Edge Cookie identifiers. ### `[ec]` -| Field | Type | Required | Description | -| --------------- | --------------- | -------- | ------------------------------------------------ | -| `passphrase` | String | Yes | Publisher passphrase used as HMAC key | -| `ec_store` | String or null | No | Fastly KV store name for EC identity graph | -| `partner_store` | String or null | No | Fastly KV store name for partner registry | +| Field | Type | Required | Description | +| --------------- | -------------- | -------- | ------------------------------------------ | +| `passphrase` | String | Yes | Publisher passphrase used as HMAC key | +| `ec_store` | String or null | No | Fastly KV store name for EC identity graph | +| `partner_store` | String or null | No | Fastly KV store name for partner registry | **Example**: