diff --git a/crates/edgecookie/README.md b/crates/edgecookie/README.md new file mode 100644 index 000000000..f322c431f --- /dev/null +++ b/crates/edgecookie/README.md @@ -0,0 +1,18 @@ +# Edge Cookie modules + +Vendor Edge Cookie module crates live here, one per vendor, for example +`crates/edgecookie/`. Each implements the `EdgeCookieModule` trait +from `trusted-server-core` and is wired in by an adapter. + +The built-in HMAC module (HMAC over the client IP) ships in +`trusted-server-core` (`ec::module`), so no crate is needed for it. There is +no default module, and a deployment selects one explicitly with +`[ec] module`. + +A module's own settings live in the `[ec.]` table the selector names. +The name is the module's implementation id, the same string its +`EdgeCookieModule::id` returns, unless the table names one with +`implementation = ""`, which lets an operator configure a module under a +name of their own choosing. A module with no settings needs no table. + +This directory is a placeholder until a vendor module is added. diff --git a/crates/trusted-server-adapter-axum/src/app.rs b/crates/trusted-server-adapter-axum/src/app.rs index caba714d6..7b50568a0 100644 --- a/crates/trusted-server-adapter-axum/src/app.rs +++ b/crates/trusted-server-adapter-axum/src/app.rs @@ -18,6 +18,7 @@ use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::module::ensure_module_available; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -77,8 +78,9 @@ fn build_state() -> Result, Report> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { @@ -89,6 +91,26 @@ fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { + // Composition root: reject a module selection this adapter can never + // supply, once, before any request is served. A caller supplying its own + // `RuntimeServices` may already have resolved a module, so the check is + // given whatever those services carry, which is what `EcContext` sees per + // request. + // + // This adapter checks rather than keeps what the check resolved, unlike the + // Fastly, Cloudflare and Spin adapters, because it is a long-lived process + // whose application state is built once at start-up while theirs is rebuilt + // for every request. With no services supplied it threads no module, so + // `EcContext` resolves the selection itself on every request, building a + // fresh built-in module that reads no request data; this dev server + // accepts that per-request construction rather than caching a resolved + // module. + ensure_module_available( + &settings.ec, + services + .as_ref() + .and_then(RuntimeServices::resolved_ec_module), + )?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -179,13 +201,26 @@ where /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the platform (a no-op on the local Axum dev server, so -/// jurisdiction stays Unknown there unless the request carries TCF consent). A -/// malformed consent string is logged and falls back to the default -/// (fail-closed) context rather than being silently swallowed. -fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) -> EcContext { +/// Geo comes from the platform (a no-op on the local Axum dev server, so +/// jurisdiction stays Unknown there unless the request carries TCF consent), and +/// a geo lookup failure is logged and treated as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + state: &AppState, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -194,10 +229,6 @@ fn build_ec_context(state: &AppState, services: &RuntimeServices, req: &Request) None }); EcContext::read_from_request_with_geo(&state.settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } // --------------------------------------------------------------------------- @@ -244,7 +275,7 @@ async fn dispatch_fallback( // Run the server-side auction with the configured creative-opportunity // slots; `handle_publisher_request` matches them against the request path. - let mut ec_context = build_ec_context(state, services, &req); + let mut ec_context = build_ec_context(state, services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -505,7 +536,7 @@ fn named_route_handler( // Build the geo-aware EC context so the auction consent // gate sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let mut ec_context = build_ec_context(&state, &services, &req); + let mut ec_context = build_ec_context(&state, &services, &req)?; handle_auction( &state.settings, &state.orchestrator, @@ -524,7 +555,7 @@ fn named_route_handler( if req.method() == Method::OPTIONS { Ok(page_bids_preflight_denied()) } else { - let mut ec_context = build_ec_context(&state, &services, &req); + let mut ec_context = build_ec_context(&state, &services, &req)?; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -715,3 +746,81 @@ fn build_router(state: &Arc) -> RouterService { router.build() } + +#[cfg(test)] +mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + + use super::*; + + /// Settings selecting a vendor Edge Cookie module this adapter does not + /// inject, with the `[ec.acme]` block that module's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// Builds application state directly, bypassing the composition root's + /// startup check, so the per-request behavior can be exercised with a + /// selection the adapter cannot supply. + fn state_with_uninjected_module() -> AppState { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + let plan = Arc::new(compile_auction_plan(&settings).expect("should compile auction plan")); + let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings) + .expect("should build orchestrator"); + let registry = + IntegrationRegistry::with_plan(&settings, plan).expect("should build registry"); + AppState { + settings: Arc::new(settings), + orchestrator: Arc::new(orchestrator), + registry: Arc::new(registry), + // This test drives the per-request path, which builds its services + // from the request context. + services: None, + } + } + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// Continuing with `EcContext::default()` would serve every request with + /// no identity when the selected module cannot be built. The call sites + /// propagate the error to `http_error`, matching the Fastly adapter. + #[test] + fn build_ec_context_fails_when_the_selected_module_is_unavailable() { + let state = state_with_uninjected_module(); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + + let error = build_ec_context(&state, &services, &req) + .expect_err("an unavailable Edge Cookie module must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected module, got: {error}" + ); + } +} diff --git a/crates/trusted-server-adapter-axum/src/middleware.rs b/crates/trusted-server-adapter-axum/src/middleware.rs index fd11d7728..ec6e19cd1 100644 --- a/crates/trusted-server-adapter-axum/src/middleware.rs +++ b/crates/trusted-server-adapter-axum/src/middleware.rs @@ -193,6 +193,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-axum/tests/routes.rs b/crates/trusted-server-adapter-axum/tests/routes.rs index 7126b2a71..1f6700fa7 100644 --- a/crates/trusted-server-adapter-axum/tests/routes.rs +++ b/crates/trusted-server-adapter-axum/tests/routes.rs @@ -33,6 +33,9 @@ fn test_settings() -> trusted_server_core::settings::Settings { proxy_secret = "integration-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -868,6 +871,57 @@ async fn first_party_proxy_rebuild_is_routed() { ); } +// --------------------------------------------------------------------------- +// Edge Cookie module availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie module this adapter does not +/// inject, with the `[ec.acme]` block that module's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "integration-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A module selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a module knows what that module needs, and the Axum dev +/// server injects no vendor Edge Cookie module, so only the composition root +/// can catch it. Without the startup check the deployment would come up and +/// answer every request. +#[test] +fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { + let settings = trusted_server_core::settings::Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = trusted_server_adapter_axum::app::TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected module should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected module, got: {error}" + ); +} + /// Regression test: a Next.js navigation with a pending auction must buffer to /// the structural body close. The Flight payload carries a literal ``, so /// a parser-blind seam would inject bids early and split the RSC data. diff --git a/crates/trusted-server-adapter-cloudflare/src/app.rs b/crates/trusted-server-adapter-cloudflare/src/app.rs index 87b9567e7..5c1613597 100644 --- a/crates/trusted-server-adapter-cloudflare/src/app.rs +++ b/crates/trusted-server-adapter-cloudflare/src/app.rs @@ -22,6 +22,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::module::{EdgeCookieModule, build_shared_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::integrations::{IntegrationRegistry, ProxyDispatchInput}; @@ -74,6 +75,17 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie module `[ec] module` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request and resolving reads no + /// request data, so the selection is resolved when the state is built + /// and handed to every request through + /// [`RuntimeServices::resolved_ec_module`](trusted_server_core::platform::RuntimeServices::resolved_ec_module), + /// rather than resolved again on the request path. + /// `None` for a deployment that selects no module. + ec_module: Option>, + /// Services a caller supplied for every request, rather than services built + /// from the request context. `None` in a deployment. services: Option, } @@ -191,8 +203,9 @@ fn cloudflare_config_envelope( /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { @@ -203,6 +216,18 @@ fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { + // Composition root: resolve the module selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // injects no vendor Edge Cookie module of its own, so the only injected + // module is one a caller put into the services it supplied. + let ec_module = build_shared_module( + &settings.ec, + services + .as_ref() + .and_then(RuntimeServices::resolved_ec_module), + )?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -212,15 +237,20 @@ fn build_state_with_services( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_module, services, })) } impl AppState { + /// Builds the per-request services, carrying the Edge Cookie module the + /// composition root already resolved so the request path does not resolve + /// `[ec] module` a second time. fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() .unwrap_or_else(|| build_runtime_services(ctx)) + .with_resolved_ec_module(self.ec_module.clone()) } } @@ -231,12 +261,25 @@ impl AppState { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Geo comes from the Workers `cf` object when deployed. A malformed -/// consent string is logged and falls back to the default (fail-closed) context -/// rather than being silently swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +/// Geo comes from the Workers `cf` object when deployed, and a geo lookup +/// failure is logged and treated as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -245,10 +288,6 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } // --------------------------------------------------------------------------- @@ -553,7 +592,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie module is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -658,7 +703,7 @@ fn build_router(state: &Arc) -> RouterService { // Build the geo-aware EC context so the auction consent gate // sees the caller's jurisdiction — `EcContext::default()` // fails it closed for consented users. - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req)?; handle_auction( &s.settings, &s.orchestrator, @@ -722,7 +767,7 @@ fn build_router(state: &Arc) -> RouterService { // preflight fall through to a permissive origin would reopen exactly // the cross-site hole the canonical path closes. let page_bids = make_handler(Arc::clone(&state), |s, services, req| async move { - let mut ec_context = build_ec_context(&s.settings, &services, &req); + let mut ec_context = build_ec_context(&s.settings, &services, &req)?; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -775,8 +820,67 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; + /// Settings selecting a vendor Edge Cookie module this adapter does not + /// inject, with the `[ec.acme]` block that module's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// Continuing with `EcContext::default()` would serve every request with + /// no identity when the selected module cannot be built. The call sites + /// propagate the error to `http_error`, matching the Fastly adapter. The + /// settings are parsed directly, bypassing the composition root's startup + /// check, so the per-request behavior can be exercised with a selection + /// the adapter cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_module_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // No resolved module is threaded here, so the request path resolves + // the selection itself, which is what an embedder driving core + // directly does and where the loud failure has to stay. + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie module must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected module, got: {error}" + ); + } + fn aps_profile_settings() -> Settings { let mut settings = Settings::from_toml( r#" diff --git a/crates/trusted-server-adapter-cloudflare/src/middleware.rs b/crates/trusted-server-adapter-cloudflare/src/middleware.rs index 14efed56a..f8c7e2dd0 100644 --- a/crates/trusted-server-adapter-cloudflare/src/middleware.rs +++ b/crates/trusted-server-adapter-cloudflare/src/middleware.rs @@ -209,6 +209,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-cloudflare/tests/routes.rs b/crates/trusted-server-adapter-cloudflare/tests/routes.rs index f385bf529..422ed654e 100644 --- a/crates/trusted-server-adapter-cloudflare/tests/routes.rs +++ b/crates/trusted-server-adapter-cloudflare/tests/routes.rs @@ -36,6 +36,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -85,6 +88,9 @@ fn make_router() -> RouterService { proxy_secret = "integration-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -676,6 +682,57 @@ async fn tsjs_route_prefix_is_handled_not_5xx() { ); } +// --------------------------------------------------------------------------- +// Edge Cookie module availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie module this adapter does not +/// inject, with the `[ec.acme]` block that module's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A module selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a module knows what that module needs, and this adapter +/// injects no vendor Edge Cookie module, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. +#[test] +fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected module should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected module, got: {error}" + ); +} + /// Regression test: a Next.js navigation with a pending auction must buffer to /// the structural body close. The Flight payload carries a literal ``, so /// a parser-blind seam would inject bids early and split the RSC data. diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index fbcd5735d..8562e8277 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -116,6 +116,8 @@ use trusted_server_core::ec::consent::ec_consent_withdrawn; use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; +use trusted_server_core::ec::module::request_module; +use trusted_server_core::ec::module::{EdgeCookieModule, build_shared_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; @@ -183,6 +185,15 @@ pub(crate) struct AppState { pub(crate) registry: Arc, pub(crate) default_kv_store: Arc, pub(crate) auction_telemetry_sink: Arc, + /// The Edge Cookie module `[ec] module` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request and resolving reads no + /// request data, so the selection is resolved when the state is built + /// and handed to every request through + /// [`RuntimeServices::resolved_ec_module`](trusted_server_core::platform::RuntimeServices::resolved_ec_module), + /// rather than resolved again on the request path. + /// `None` for a deployment that selects no module. + pub(crate) ec_module: Option>, } /// Build the application state, loading settings and constructing all per-application components. @@ -209,11 +220,26 @@ pub(crate) fn load_settings_from_config_store( ) } +/// Build the application state from explicit settings. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. pub(crate) fn build_state_from_settings( settings: Settings, ) -> Result, Report> { warn_if_certificate_check_disabled(&settings); + // Composition root: resolve the module selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // injects no vendor Edge Cookie module, so `None` is the injected + // argument, and one is passed here once this adapter supplies it. + let ec_module = build_shared_module(&settings.ec, None)?; + let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Fastly)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -228,6 +254,7 @@ pub(crate) fn build_state_from_settings( registry: Arc::new(registry), default_kv_store, auction_telemetry_sink, + ec_module, })) } @@ -265,7 +292,7 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime ..ClientInfo::default() }); - RuntimeServices::builder() + let builder = RuntimeServices::builder() .config_store(Arc::new(FastlyPlatformConfigStore)) .secret_store(Arc::new(FastlyPlatformSecretStore)) .kv_store(Arc::clone(&state.default_kv_store)) @@ -278,8 +305,16 @@ fn build_per_request_services(state: &AppState, ctx: &RequestContext) -> Runtime .http_client(Arc::new(FastlyPlatformHttpClient)) .geo(Arc::new(FastlyPlatformGeo)) .auction_telemetry_sink(Arc::clone(&state.auction_telemetry_sink)) - .client_info(client_info) - .build() + .client_info(client_info); + + // Hand every request the module resolved at the composition root, so the + // request path reuses that instance instead of resolving `[ec] module` + // again. Nothing is set for a deployment that selects no module, which + // resolves to nothing either way. + match state.ec_module.clone() { + Some(module) => builder.resolved_ec_module(module).build(), + None => builder.build(), + } } fn publisher_fallback_methods() -> [Method; 7] { @@ -577,7 +612,12 @@ async fn execute_named( // copy is bot-gated, while operators use curl for this // authenticated diagnostic. let kv = crate::maybe_identity_graph(&state.settings); - handle_admin_ec_lookup(kv.as_ref(), ®istry, &req) + // The selected module decides which identifiers this + // deployment recognizes, so build it here rather than + // assuming the built-in HMAC shape. The read-only + // diagnostic builds no EC request state to borrow it from. + let module = request_module(&state.settings.ec, &services)?; + handle_admin_ec_lookup(kv.as_ref(), ®istry, module.as_deref(), &req) } NamedRouteHandler::AdminEidsLookup => handle_admin_eids_lookup(®istry, &req), _ => unreachable!("admin diagnostics should use early dispatch"), @@ -737,7 +777,11 @@ fn run_batch_sync(state: &AppState, services: &RuntimeServices, req: Request) -> let result = crate::require_identity_graph(&state.settings).and_then(|kv| { let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; let limiter = FastlyRateLimiter::new(RATE_COUNTER_NAME); - handle_batch_sync(&kv, &partner_registry, &limiter, req) + // A partner echoes back an identifier the deployment's own module + // created, so validation and KV normalization are dispatched through + // that module rather than the built-in HMAC grammar. + let module = request_module(&state.settings.ec, services)?; + handle_batch_sync(&kv, &partner_registry, &limiter, module.as_deref(), req) }); let mut response = result.unwrap_or_else(|e| http_error(&e)); @@ -847,7 +891,7 @@ async fn dispatch_fallback( .ec_context .generate_if_needed(&state.settings, ec.kv_graph.as_ref()) { - log::warn!("EC generation failed for publisher proxy: {err:?}"); + log::error!("EC generation failed for publisher proxy: {err:?}"); } // Run the server-side auction with the configured creative- @@ -1521,6 +1565,9 @@ mod tests { allowed_domains = ["*.example", "*.example.com"] [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -1596,6 +1643,10 @@ mod tests { let registry = IntegrationRegistry::from_request_filters(filters); let default_kv_store = Arc::new(crate::platform::UnavailableKvStore) as Arc; + // Resolved the same way the composition root resolves it, so this + // router behaves like a served one. + let ec_module = trusted_server_core::ec::module::build_shared_module(&settings.ec, None) + .expect("should resolve the Edge Cookie module selection"); let state = Arc::new(super::AppState { auction_telemetry_sink: Arc::new( trusted_server_core::auction::NoopAuctionTelemetrySink, @@ -1604,6 +1655,7 @@ mod tests { orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), default_kv_store, + ec_module, }); TrustedServerApp::routes_for_state(&state) } @@ -2022,6 +2074,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -2695,6 +2750,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] @@ -3122,6 +3180,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index b9f3b86be..e4b52bb94 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -859,6 +859,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-fastly/src/middleware.rs b/crates/trusted-server-adapter-fastly/src/middleware.rs index 283f16255..423a411f3 100644 --- a/crates/trusted-server-adapter-fastly/src/middleware.rs +++ b/crates/trusted-server-adapter-fastly/src/middleware.rs @@ -320,6 +320,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" [request_signing] diff --git a/crates/trusted-server-adapter-spin/src/app.rs b/crates/trusted-server-adapter-spin/src/app.rs index b8e8a2492..c60feb060 100644 --- a/crates/trusted-server-adapter-spin/src/app.rs +++ b/crates/trusted-server-adapter-spin/src/app.rs @@ -24,6 +24,7 @@ use trusted_server_core::ec::admin::{ admin_ec_lookup_not_supported as core_admin_ec_lookup_not_supported, deny_admin_diagnostic_fallback, handle_admin_eids_lookup, }; +use trusted_server_core::ec::module::{EdgeCookieModule, build_shared_module}; use trusted_server_core::ec::registry::PartnerRegistry; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::sanitize_forwarded_headers; @@ -67,15 +68,31 @@ pub struct AppState { settings: Arc, orchestrator: Arc, registry: Arc, + /// The Edge Cookie module `[ec] module` selects, resolved once here. + /// + /// This adapter runs a fresh instance per request and resolving reads no + /// request data, so the selection is resolved when the state is built + /// and handed to every request through + /// [`RuntimeServices::resolved_ec_module`](trusted_server_core::platform::RuntimeServices::resolved_ec_module), + /// rather than resolved again on the request path. + /// `None` for a deployment that selects no module. + ec_module: Option>, + /// Services a caller supplied for every request, rather than services built + /// from the request context. `None` in a deployment. services: Option, } /// Build the application state, loading settings and constructing all per-application components. /// +/// Settings are read from the platform config store at run time, the same way +/// the Fastly and Axum adapters read them, so an operator publishes one with +/// `ts config push` and the deployed component picks it up. +/// /// # Errors /// -/// Returns an error when settings, the auction orchestrator, or the integration -/// registry fail to initialise. +/// Returns an error when the config store holds no readable app config, or when +/// settings, the auction orchestrator, or the integration registry fail to +/// initialize. fn build_state() -> Result, Report> { let settings = load_startup_settings()?; build_state_with_settings(settings) @@ -119,8 +136,9 @@ fn load_startup_settings() -> Result> { /// /// # Errors /// -/// Returns an error when the auction orchestrator or the integration -/// registry fail to initialise. +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this adapter, or when the auction orchestrator or the integration registry +/// fail to initialize. fn build_state_with_settings( settings: Settings, ) -> Result, Report> { @@ -131,6 +149,18 @@ fn build_state_with_services( settings: Settings, services: Option, ) -> Result, Report> { + // Composition root: resolve the module selection once, before any request + // is served, so a selection this adapter can never supply fails here rather + // than on the first request. Keeping what the resolution produced is what + // stops the request path resolving the same settings again. This adapter + // injects no vendor Edge Cookie module of its own, so the only injected + // module is one a caller put into the services it supplied. + let ec_module = build_shared_module( + &settings.ec, + services + .as_ref() + .and_then(RuntimeServices::resolved_ec_module), + )?; let plan = Arc::new(compile_auction_plan(&settings)?); plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Spin)?; let orchestrator = build_orchestrator_with_plan(Arc::clone(&plan), &settings)?; @@ -140,15 +170,20 @@ fn build_state_with_services( settings: Arc::new(settings), orchestrator: Arc::new(orchestrator), registry: Arc::new(registry), + ec_module, services, })) } impl AppState { + /// Builds the per-request services, carrying the Edge Cookie module the + /// composition root already resolved so the request path does not resolve + /// `[ec] module` a second time. fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices { self.services .clone() .unwrap_or_else(|| build_runtime_services(ctx)) + .with_resolved_ec_module(self.ec_module.clone()) } } @@ -409,13 +444,26 @@ fn health_response() -> Response { /// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`, /// `/_ts/page-bids`, and the publisher fallback). /// -/// Mirrors the Fastly entry point: `EcContext::default()` leaves jurisdiction -/// Unknown, which fails the auction consent gate closed even for consented -/// users. Spin's platform geo is a no-op, so jurisdiction stays Unknown unless -/// the request carries TCF consent. A malformed consent string is logged and -/// falls back to the default (fail-closed) context rather than being silently -/// swallowed. -fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Request) -> EcContext { +/// Spin's platform geo is a no-op, so jurisdiction stays Unknown unless the +/// request carries TCF consent, and a geo lookup failure is logged and treated +/// as no location. +/// +/// Mirrors the Fastly entry point, which keeps the report and answers with an +/// error response: when the Edge Cookie context cannot be read the request +/// fails rather than continuing with `EcContext::default()`, which would serve +/// every request with no identity. A malformed cookie value, a bad consent +/// string and a failed geo lookup do not reach this error path at all, so +/// failing here does not fail requests for ordinary parse problems. +/// +/// # Errors +/// +/// Returns an error when the selected Edge Cookie module cannot be built for +/// this request, or when the request's `Cookie` header is not valid UTF-8. +fn build_ec_context( + settings: &Settings, + services: &RuntimeServices, + req: &Request, +) -> Result> { let geo_info = services .geo() .lookup(services.client_info().client_ip) @@ -424,10 +472,6 @@ fn build_ec_context(settings: &Settings, services: &RuntimeServices, req: &Reque None }); EcContext::read_from_request_with_geo(settings, req, services, geo_info.as_ref()) - .unwrap_or_else(|e| { - log::warn!("EC context read failed: {e:?}"); - EcContext::default() - }) } fn cache_purge_not_supported() -> Response { @@ -680,8 +724,13 @@ fn build_router(state: &Arc) -> RouterService { } // Build the geo-aware EC context so the auction consent gate sees // the caller's jurisdiction — `EcContext::default()` fails it - // closed for consented users. - let mut ec_context = build_ec_context(&s.settings, &services, &req); + // closed for consented users. When identity cannot be + // established at all, answer with an error rather than running + // the auction with no identity. + let mut ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; Ok(handle_auction( &s.settings, &s.orchestrator, @@ -711,7 +760,13 @@ fn build_router(state: &Arc) -> RouterService { { return Ok(http_error(&error)); } - let mut ec_context = build_ec_context(&s.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie module is unavailable). Answer with an error + // rather than re-running the auction with no identity. + let mut ec_context = match build_ec_context(&s.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &s.orchestrator, slots: s.settings.creative_opportunity_slots(), @@ -834,7 +889,13 @@ fn build_router(state: &Arc) -> RouterService { })) }) } else { - let mut ec_context = build_ec_context(&state.settings, &services, &req); + // Identity could not be established (for example the selected + // Edge Cookie module is unavailable). Answer with an error + // rather than serving the page with no identity. + let mut ec_context = match build_ec_context(&state.settings, &services, &req) { + Ok(context) => context, + Err(report) => return Ok(http_error(&report)), + }; let auction = AuctionDispatch { orchestrator: &state.orchestrator, slots: state.settings.creative_opportunity_slots(), @@ -975,6 +1036,9 @@ fn build_router(state: &Arc) -> RouterService { #[cfg(test)] mod tests { + use edgezero_core::http::request_builder; + use edgezero_core::params::PathParams; + use super::*; fn multi_provider_settings() -> Settings { @@ -1071,6 +1135,85 @@ mod tests { ); } + #[test] + fn build_state_takes_its_settings_from_the_platform_config_store() { + // Every other test enters through the `routes_with_settings` parity + // seam, so this is the one that calls `build_state` itself. There is + // no Spin runtime under `cargo test`, so there are no component + // variables to read and this cannot return `Ok` here. Its settings + // come from the config store alone, so the failure has to be the + // absence of a config store, and never a configuration compiled into + // the binary, such as a template's placeholder password. + let Err(error) = build_state() else { + return; + }; + let message = format!("{error:?}"); + assert!( + message.contains("config store"), + "build_state should fail only for want of a config store, got: {message}" + ); + assert!( + !message.to_lowercase().contains("password"), + "build_state must not fail on a configuration compiled into the binary, got: {message}" + ); + } + + /// Settings selecting a vendor Edge Cookie module this adapter does not + /// inject, with the `[ec.acme]` block that module's settings live in. + /// `acme` is a fictional vendor key. + const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "unit-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" + "#; + + /// The per-request Edge Cookie read must return its error rather than a + /// default context. + /// + /// Continuing with `EcContext::default()` would serve every request with + /// no identity when the selected module cannot be built. The call sites + /// propagate the error to `http_error`, matching the Fastly adapter. The + /// settings are parsed directly, bypassing the composition root's startup + /// check, so the per-request behavior can be exercised with a selection + /// the adapter cannot supply. + #[test] + fn build_ec_context_fails_when_the_selected_module_is_unavailable() { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + let req = request_builder() + .method("POST") + .uri("https://test-publisher.example.com/auction") + .body(edgezero_core::body::Body::empty()) + .expect("should build test request"); + let ctx = RequestContext::new(req, PathParams::default()); + // No resolved module is threaded here, so the request path resolves + // the selection itself, which is what an embedder driving core + // directly does and where the loud failure has to stay. + let services = build_runtime_services(&ctx); + let req = ctx.into_request(); + + let error = build_ec_context(&settings, &services, &req) + .expect_err("an unavailable Edge Cookie module must fail the request"); + + assert!( + error.to_string().contains("acme"), + "the error should name the selected module, got: {error}" + ); + } + #[test] fn scheme_host_from_spin_url_extracts_localhost_with_port() { assert_eq!( diff --git a/crates/trusted-server-adapter-spin/src/middleware.rs b/crates/trusted-server-adapter-spin/src/middleware.rs index d7a09987a..d8300d4ff 100644 --- a/crates/trusted-server-adapter-spin/src/middleware.rs +++ b/crates/trusted-server-adapter-spin/src/middleware.rs @@ -236,6 +236,9 @@ mod tests { proxy_secret = "unit-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) diff --git a/crates/trusted-server-adapter-spin/tests/routes.rs b/crates/trusted-server-adapter-spin/tests/routes.rs index 613832b05..ab33bc16b 100644 --- a/crates/trusted-server-adapter-spin/tests/routes.rs +++ b/crates/trusted-server-adapter-spin/tests/routes.rs @@ -35,6 +35,9 @@ fn test_router() -> RouterService { proxy_secret = "route-test-proxy-secret" [ec] + module = "hmac" + + [ec.hmac] passphrase = "test-secret-key-32-bytes-minimum" "#, ) @@ -974,6 +977,57 @@ async fn admin_deactivate_key_auth_fail_returns_401() { ); } +// --------------------------------------------------------------------------- +// Edge Cookie module availability +// --------------------------------------------------------------------------- + +/// Test settings selecting a vendor Edge Cookie module this adapter does not +/// inject, with the `[ec.acme]` block that module's settings live in. +/// `acme` is a fictional vendor key. +const UNINJECTED_MODULE_TOML: &str = r#" + [[handlers]] + path = "^/_ts/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.example.com" + cookie_domain = ".test-publisher.example.com" + origin_url = "https://origin.test-publisher.example.com" + proxy_secret = "route-test-proxy-secret" + + [ec] + module = "acme" + + [ec.acme] + endpoint = "https://ec.acme.example.com" +"#; + +/// A module selection this adapter can never supply must fail while the +/// application state is built, before any request is served. +/// +/// Configuration validation accepts this selection, because only the adapter +/// that injects a module knows what that module needs, and this adapter +/// injects no vendor Edge Cookie module, so only the composition root can +/// catch it. Without the startup check the deployment would come up and answer +/// every request. +#[test] +fn selecting_a_module_this_adapter_cannot_supply_fails_at_startup() { + let settings = Settings::from_toml(UNINJECTED_MODULE_TOML) + .expect("should parse settings selecting an uninjected module"); + + // `RouterService` is not `Debug`, so take the error side directly rather + // than through `expect_err`. + let error = TrustedServerApp::routes_with_settings(settings) + .err() + .expect("building state with an uninjected module should fail"); + + assert!( + error.to_string().contains("acme"), + "the startup error should name the selected module, got: {error}" + ); +} + /// Regression test: a Next.js navigation with a pending auction must buffer to /// the structural body close. The Flight payload carries a literal ``, so /// a parser-blind seam would inject bids early and split the RSC data. diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index ab3585e3d..0a40e86a4 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -303,8 +303,13 @@ pub async fn handle_auction( // EC and both KV and partner stores are available. Gate the read on a // present registry: without one, `resolve_auction_eids` yields no // server-side EIDs, so the snapshot would be an unused billable KV read. + // The row is read under the owning module's canonical form of the + // identifier, the key it is stored under, rather than under the identifier + // as issued. let auction_kv_snapshot = match (kv, ec_id.as_deref(), registry) { - (Some(graph), Some(ec_id), Some(_)) => graph.load_snapshot(ec_id), + (Some(graph), Some(_), Some(_)) => ec_context + .ec_kv_key() + .map_or(EcKvSnapshot::NotRead, |kv_key| graph.load_snapshot(&kv_key)), _ => EcKvSnapshot::NotRead, }; // Hand the loaded row to the request context so response finalization — @@ -451,7 +456,13 @@ pub(crate) fn resolve_auction_eids( let ec_id = ec_context.ec_value()?; - let Some(entry) = snapshot.entry_for(ec_id) else { + // Callers read the snapshot under the identity-graph key, the owning + // module's canonical form of the identifier, so the entry is looked up + // under that key rather than under the identifier as issued. + let Some(entry) = ec_context + .kv_key_for(ec_id) + .and_then(|kv_key| snapshot.entry_for(&kv_key)) + else { return Some(Vec::new()); }; @@ -624,6 +635,7 @@ mod tests { use crate::auction::types::{AuctionRequest, AuctionResponse}; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::ConsentContext; + use crate::ec::tests::{CANONICAL_COOKIE_VALUE, CANONICAL_KV_KEY, CanonicalizingModule}; use crate::error::IntoHttpResponse as _; use crate::openrtb::Uid; use crate::platform::test_support::{ @@ -797,6 +809,85 @@ mod tests { ); } + #[tokio::test] + async fn auction_endpoint_loads_the_row_under_the_canonical_key() { + // The identity graph stores a row under the owning module's + // canonical form of the identifier. Loaded and resolved under the + // identifier as issued, a module whose canonical form differs from + // the cookie value found no row, so the auction carried no server-side + // EIDs and the context kept a snapshot bound to the wrong key. + let settings = create_test_settings(); + let had_eids = Arc::new(std::sync::Mutex::new(None)); + let mut orchestrator = AuctionOrchestrator::new(AuctionConfig { + enabled: true, + providers: AuctionConfig::legacy_provider_map(&["eid_capturing_provider"]), + timeout_ms: 2000, + mediator: None, + ..Default::default() + }); + orchestrator.register_provider(Arc::new(EidCapturingProvider { + had_eids: Arc::clone(&had_eids), + })); + let registry = PartnerRegistry::from_config(&[counting_test_partner("ssp.example.com")]) + .expect("should build partner registry"); + let graph = KvIdentityGraph::in_memory("canonical-auction-store"); + graph + .create( + CANONICAL_KV_KEY, + &crate::ec::kv_types::KvEntry::minimal( + "ssp.example.com", + "partner-uid-123", + 1_741_824_000, + ), + ) + .expect("should seed the row under the canonical key"); + let mut ec_context = + make_ec_context(Jurisdiction::NonRegulated, Some(CANONICAL_COOKIE_VALUE)) + .with_module_for_test(Arc::new(CanonicalizingModule)); + let req = Request::builder() + .method("POST") + .uri("https://test-publisher.com/auction") + .body(EdgeBody::from( + serde_json::to_vec(&json!({ + "adUnits": [ + { + "code": "div-gpt-ad-1", + "mediaTypes": { "banner": { "sizes": [[300, 250]] } } + } + ] + })) + .expect("should serialize body"), + )) + .expect("should build auction request"); + + // The capturing provider records whether the request carried EIDs and + // then fails its launch, which is all this test needs. The request + // carries no client EIDs, so any EID it records came from the graph. + let _ = handle_auction( + &settings, + &orchestrator, + Some(&graph), + Some(®istry), + &mut ec_context, + &noop_services(), + req, + ) + .await; + + assert!( + ec_context + .kv_snapshot() + .entry_for(CANONICAL_KV_KEY) + .is_some(), + "the endpoint should load the row stored under the canonical key" + ); + assert_eq!( + *had_eids.lock().expect("should lock captured eids"), + Some(true), + "the auction should carry the canonical row's partner ID as an EID" + ); + } + /// Provider that fails the test if it is ever contacted. Used to prove the /// `/auction` consent gate short-circuits before any outbound bid request. struct PanicOnBidProvider; diff --git a/crates/trusted-server-core/src/config.rs b/crates/trusted-server-core/src/config.rs index 79f100e2f..d1bc937c4 100644 --- a/crates/trusted-server-core/src/config.rs +++ b/crates/trusted-server-core/src/config.rs @@ -11,8 +11,9 @@ use std::borrow::Cow; use edgezero_core::app_config::{SecretField, SecretKind, SecretPathSegment}; use error_stack::Report; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use validator::{Validate, ValidationError, ValidationErrors}; +use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; +use crate::ec::module::HMAC_MODULE_KEY; use crate::ec::registry::PartnerRegistry; use crate::error::TrustedServerError; use crate::integrations::{ @@ -32,7 +33,9 @@ use crate::integrations::{ sourcepoint::SourcepointConfig, testlight::TestlightConfig, }; -use crate::settings::{AssetOriginAuth, IntegrationConfig, Settings}; +use crate::settings::{ + AssetOriginAuth, Ec, IntegrationConfig, MODULE_IMPLEMENTATION_KEY, Settings, +}; const DEPLOY_VALIDATION_FIELD: &str = "trusted_server"; #[cfg(test)] @@ -117,6 +120,7 @@ impl<'de> Deserialize<'de> for TrustedServerAppConfig { impl Validate for TrustedServerAppConfig { fn validate(&self) -> Result<(), ValidationErrors> { let mut errors = self.settings.validate().err().unwrap_or_default(); + remove_labeled_module_secret_errors(&mut errors, &self.settings.ec); if let Err(report) = validate_settings_for_deploy(&self.settings) { errors.add( DEPLOY_VALIDATION_FIELD, @@ -131,6 +135,90 @@ impl Validate for TrustedServerAppConfig { } } +/// Removes the passphrase checks on Edge Cookie module blocks written under +/// a label. +/// +/// Push-time validation reads a configuration whose secret fields hold +/// secret-store key names rather than the secrets themselves, so a value check +/// such as the 32-byte passphrase minimum would be judging a key name. +/// `EdgeZero`'s `validate_excluding_secrets` removes those checks for the +/// leaves [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) +/// lists, which covers the `[ec.hmac]` block. A block under a label of the +/// operator's choosing has no fixed path that list can hold, so its check is +/// removed here instead. The check itself is unchanged, and runs wherever +/// settings are loaded with their secrets resolved. +fn remove_labeled_module_secret_errors(errors: &mut ValidationErrors, ec: &Ec) { + let Some(ValidationErrorsKind::Struct(ec_errors)) = errors.errors_mut().get_mut("ec") else { + return; + }; + for (name, _) in ec + .module_blocks + .hmac_blocks() + .filter(|(name, _)| *name != HMAC_MODULE_KEY) + { + let Some(ValidationErrorsKind::Struct(block_errors)) = ec_errors.errors_mut().get_mut(name) + else { + continue; + }; + block_errors.errors_mut().remove("passphrase"); + if block_errors.errors().is_empty() { + ec_errors.errors_mut().remove(name); + } + } + // An `ec` entry holding nothing would keep the whole result an error, the + // same reason `EdgeZero` prunes emptied containers after its own removals. + let ec_is_empty = ec_errors.errors().is_empty(); + if ec_is_empty { + errors.errors_mut().remove("ec"); + } +} + +impl crate::secret_resolution::ConfiguredSecretFields for TrustedServerAppConfig { + /// The passphrase of every Edge Cookie module block that configures the + /// built-in HMAC module under a label. + /// + /// [`secret_fields`](edgezero_core::app_config::AppConfigMeta::secret_fields) + /// lists the passphrase of the `[ec.hmac]` block, the one path this + /// module's block has when its name is its implementation. The same + /// module under a label of the operator's choosing holds that secret at + /// `ec.