diff --git a/crates/trusted-server-adapter-fastly/src/main.rs b/crates/trusted-server-adapter-fastly/src/main.rs index bcbc50731..72cae33ea 100644 --- a/crates/trusted-server-adapter-fastly/src/main.rs +++ b/crates/trusted-server-adapter-fastly/src/main.rs @@ -29,9 +29,11 @@ use trusted_server_core::settings_data::get_settings; mod error; mod platform; +#[cfg(test)] +mod route_tests; use crate::error::to_error_response; -use crate::platform::{build_runtime_services, UnavailableKvStore}; +use crate::platform::{build_runtime_services, open_kv_store, UnavailableKvStore}; #[fastly::main] fn main(req: Request) -> Result { @@ -69,9 +71,9 @@ fn main(req: Request) -> Result { } }; - // No KV store is currently required — Edge Cookie generation no longer - // uses one. When a feature needs a KV store, add a config field and call - // `open_kv_store` here. + // Start with an unavailable KV slot. Consent-dependent routes lazily + // replace it with the configured store at dispatch time so unrelated + // routes stay available when consent persistence is misconfigured. let kv_store = std::sync::Arc::new(UnavailableKvStore) as std::sync::Arc; let runtime_services = build_runtime_services(&req, kv_store); @@ -149,7 +151,14 @@ async fn route_request( (Method::POST, "/admin/keys/deactivate") => handle_deactivate_key(settings, req), // Unified auction endpoint (returns creative HTML inline) - (Method::POST, "/auction") => handle_auction(settings, orchestrator, req).await, + (Method::POST, "/auction") => { + match runtime_services_for_consent_route(settings, runtime_services) { + Ok(auction_services) => { + handle_auction(settings, orchestrator, &auction_services, req).await + } + Err(e) => Err(e), + } + } // tsjs endpoints (Method::GET, "/first-party/proxy") => handle_first_party_proxy(settings, req).await, @@ -176,12 +185,22 @@ async fn route_request( path ); - match handle_publisher_request(settings, integration_registry, req) { - Ok(response) => Ok(response), - Err(e) => { - log::error!("Failed to proxy to publisher origin: {:?}", e); - Err(e) + match runtime_services_for_consent_route(settings, runtime_services) { + Ok(publisher_services) => { + match handle_publisher_request( + settings, + integration_registry, + &publisher_services, + req, + ) { + Ok(response) => Ok(response), + Err(e) => { + log::error!("Failed to proxy to publisher origin: {:?}", e); + Err(e) + } + } } + Err(e) => Err(e), } } }; @@ -194,6 +213,24 @@ async fn route_request( Ok(response) } +fn runtime_services_for_consent_route( + settings: &Settings, + runtime_services: &RuntimeServices, +) -> Result> { + let Some(store_name) = settings.consent.consent_store.as_deref() else { + return Ok(runtime_services.clone()); + }; + + open_kv_store(store_name) + .map(|store| runtime_services.clone().with_kv_store(store)) + .map_err(|e| { + Report::new(TrustedServerError::KvStore { + store_name: store_name.to_string(), + message: e.to_string(), + }) + }) +} + /// 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-adapter-fastly/src/platform.rs b/crates/trusted-server-adapter-fastly/src/platform.rs index 4f936b8f0..0b75f3ab3 100644 --- a/crates/trusted-server-adapter-fastly/src/platform.rs +++ b/crates/trusted-server-adapter-fastly/src/platform.rs @@ -286,7 +286,6 @@ pub fn build_runtime_services( /// /// Returns [`KvError::Unavailable`] when the store does not exist, or /// [`KvError::Internal`] when the Fastly SDK fails to open it. -#[allow(dead_code)] pub fn open_kv_store(store_name: &str) -> Result, KvError> { FastlyKvStore::open(store_name).map(|store| Arc::new(store) as Arc) } diff --git a/crates/trusted-server-adapter-fastly/src/route_tests.rs b/crates/trusted-server-adapter-fastly/src/route_tests.rs new file mode 100644 index 000000000..0fd0113f8 --- /dev/null +++ b/crates/trusted-server-adapter-fastly/src/route_tests.rs @@ -0,0 +1,251 @@ +use std::net::IpAddr; +use std::sync::Arc; + +use edgezero_core::key_value_store::NoopKvStore; +use error_stack::Report; +use fastly::http::StatusCode; +use fastly::Request; +use trusted_server_core::auction::build_orchestrator; +use trusted_server_core::integrations::IntegrationRegistry; +use trusted_server_core::platform::{ + ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError, + PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformKvStore, PlatformPendingRequest, + PlatformResponse, PlatformSecretStore, PlatformSelectResult, RuntimeServices, StoreId, + StoreName, +}; +use trusted_server_core::request_signing::JWKS_CONFIG_STORE_NAME; +use trusted_server_core::settings::Settings; + +use super::route_request; + +struct StubJwksConfigStore; + +impl PlatformConfigStore for StubJwksConfigStore { + fn get(&self, _store_name: &StoreName, key: &str) -> Result> { + match key { + "active-kids" => Ok("test-kid-1".to_string()), + "test-kid-1" => Ok( + r#"{"kty":"OKP","crv":"Ed25519","x":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","kid":"test-kid-1","alg":"EdDSA"}"# + .to_string(), + ), + _ => Err(Report::new(PlatformError::ConfigStore)), + } + } + + fn put( + &self, + _store_id: &StoreId, + _key: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _key: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +struct NoopSecretStore; + +impl PlatformSecretStore for NoopSecretStore { + fn get_bytes( + &self, + _store_name: &StoreName, + _key: &str, + ) -> Result, Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn create( + &self, + _store_id: &StoreId, + _name: &str, + _value: &str, + ) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn delete(&self, _store_id: &StoreId, _name: &str) -> Result<(), Report> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +struct NoopBackend; + +impl PlatformBackend for NoopBackend { + fn predict_name(&self, _spec: &PlatformBackendSpec) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + fn ensure(&self, _spec: &PlatformBackendSpec) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +struct NoopHttpClient; + +#[async_trait::async_trait(?Send)] +impl PlatformHttpClient for NoopHttpClient { + async fn send( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn send_async( + &self, + _request: PlatformHttpRequest, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } + + async fn select( + &self, + _pending_requests: Vec, + ) -> Result> { + Err(Report::new(PlatformError::Unsupported)) + } +} + +struct NoopGeo; + +impl PlatformGeo for NoopGeo { + fn lookup(&self, _client_ip: Option) -> Result, Report> { + Ok(None) + } +} + +fn create_test_settings() -> Settings { + let settings = Settings::from_toml( + r#" + [[handlers]] + path = "^/admin" + username = "admin" + password = "admin-pass" + + [publisher] + domain = "test-publisher.com" + cookie_domain = ".test-publisher.com" + origin_url = "https://origin.test-publisher.com" + proxy_secret = "unit-test-proxy-secret" + + [edge_cookie] + secret_key = "test-secret-key" + + [request_signing] + enabled = false + config_store_id = "test-config-store-id" + secret_store_id = "test-secret-store-id" + + [consent] + consent_store = "missing-consent-store" + + [integrations.prebid] + enabled = true + server_url = "https://test-prebid.com/openrtb2/auction" + + [auction] + enabled = true + providers = ["prebid"] + timeout_ms = 2000 + "#, + ) + .expect("should parse adapter route test settings"); + + assert_eq!( + JWKS_CONFIG_STORE_NAME, "jwks_store", + "should keep the stub discovery store aligned with the production constant" + ); + + settings +} + +fn test_runtime_services(req: &Request) -> RuntimeServices { + RuntimeServices::builder() + .config_store(Arc::new(StubJwksConfigStore)) + .secret_store(Arc::new(NoopSecretStore)) + .kv_store(Arc::new(NoopKvStore) as Arc) + .backend(Arc::new(NoopBackend)) + .http_client(Arc::new(NoopHttpClient)) + .geo(Arc::new(NoopGeo)) + .client_info(ClientInfo { + client_ip: req.get_client_ip_addr(), + tls_protocol: req.get_tls_protocol().map(str::to_string), + tls_cipher: req.get_tls_cipher_openssl_name().map(str::to_string), + }) + .build() +} + +#[test] +fn configured_missing_consent_store_only_breaks_consent_routes() { + let settings = create_test_settings(); + let orchestrator = build_orchestrator(&settings).expect("should build auction orchestrator"); + let integration_registry = + IntegrationRegistry::new(&settings).expect("should create integration registry"); + + let discovery_req = Request::get("https://test.com/.well-known/trusted-server.json"); + let discovery_services = test_runtime_services(&discovery_req); + let discovery_resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &discovery_services, + discovery_req, + )) + .expect("should route discovery request"); + assert_eq!( + discovery_resp.get_status(), + StatusCode::OK, + "should keep discovery available when the consent store is unavailable" + ); + + let admin_req = Request::post("https://test.com/admin/keys/rotate"); + let admin_services = test_runtime_services(&admin_req); + let admin_resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &admin_services, + admin_req, + )) + .expect("should route admin request"); + assert_eq!( + admin_resp.get_status(), + StatusCode::UNAUTHORIZED, + "should keep admin auth behavior unchanged when the consent store is unavailable" + ); + + let auction_req = Request::post("https://test.com/auction").with_body(r#"{"adUnits":[]}"#); + let auction_services = test_runtime_services(&auction_req); + let auction_resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &auction_services, + auction_req, + )) + .expect("should return an error response for auction requests"); + assert_eq!( + auction_resp.get_status(), + StatusCode::SERVICE_UNAVAILABLE, + "should fail auction requests when consent persistence is configured but unavailable" + ); + + let publisher_req = Request::get("https://test.com/articles/example"); + let publisher_services = test_runtime_services(&publisher_req); + let publisher_resp = futures::executor::block_on(route_request( + &settings, + &orchestrator, + &integration_registry, + &publisher_services, + publisher_req, + )) + .expect("should return an error response for publisher fallback"); + assert_eq!( + publisher_resp.get_status(), + StatusCode::SERVICE_UNAVAILABLE, + "should scope consent store failures to the consent-dependent routes" + ); +} diff --git a/crates/trusted-server-core/src/auction/README.md b/crates/trusted-server-core/src/auction/README.md index 76eccf6bc..d6f4483a0 100644 --- a/crates/trusted-server-core/src/auction/README.md +++ b/crates/trusted-server-core/src/auction/README.md @@ -52,9 +52,10 @@ When a request arrives at the `/auction` endpoint, it goes through the following │ ▼ ┌──────────────────────────────────────────────────────────────────────┐ -│ 2. Route Matching (crates/trusted-server-adapter-fastly/src/main.rs:84) │ +│ 2. Route Matching (crates/trusted-server-adapter-fastly/src/main.rs)│ │ - Pattern: (Method::POST, "/auction") │ -│ - Handler: handle_auction(settings, &orchestrator, &storage, req)│ +│ - Handler: handle_auction(settings, &orchestrator, │ +│ &runtime_services, req) │ └──────────────────────────────────────────────────────────────────────┘ │ ▼ @@ -277,7 +278,9 @@ The Fastly Compute entrypoint uses pattern matching on `(Method, path)` tuples: ```rust let result = match (method, path.as_str()) { // Auction endpoint - (Method::POST, "/auction") => handle_auction(&settings, req).await, + (Method::POST, "/auction") => { + handle_auction(&settings, &orchestrator, &runtime_services, req).await + }, // First-party endpoints (Method::GET, "/first-party/proxy") => handle_first_party_proxy(&settings, req).await, @@ -288,7 +291,7 @@ let result = match (method, path.as_str()) { }, // Fallback to publisher origin - _ => handle_publisher_request(&settings, &integration_registry, req), + _ => handle_publisher_request(&settings, &integration_registry, &runtime_services, req), } ``` diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index d912a5525..018b2b383 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -9,6 +9,7 @@ use crate::cookies::handle_request_cookies; use crate::edge_cookie::get_or_generate_ec_id; use crate::error::TrustedServerError; use crate::geo::GeoInfo; +use crate::platform::RuntimeServices; use crate::settings::Settings; use super::formats::{convert_to_openrtb_response, convert_tsjs_to_auction_request}; @@ -31,6 +32,7 @@ use super::AuctionOrchestrator; pub async fn handle_auction( settings: &Settings, orchestrator: &AuctionOrchestrator, + runtime_services: &RuntimeServices, mut req: Request, ) -> Result> { // Parse request body @@ -62,6 +64,11 @@ pub async fn handle_auction( config: &settings.consent, geo: geo.as_ref(), ec_id: Some(ec_id.as_str()), + kv_store: settings + .consent + .consent_store + .as_deref() + .map(|_| runtime_services.kv_store()), }); // Convert tsjs request format to auction request diff --git a/crates/trusted-server-core/src/consent/mod.rs b/crates/trusted-server-core/src/consent/mod.rs index b33a330bd..9d6050088 100644 --- a/crates/trusted-server-core/src/consent/mod.rs +++ b/crates/trusted-server-core/src/consent/mod.rs @@ -23,17 +23,18 @@ //! config: &settings.consent, //! geo: geo.as_ref(), //! ec_id: Some("ec_abc123"), +//! kv_store: Some(runtime_services.kv_store()), //! }); //! ``` mod extraction; pub mod gpp; pub mod jurisdiction; -pub mod kv; pub mod tcf; pub mod types; pub mod us_privacy; +pub use crate::storage::kv_store as kv; pub use extraction::extract_consent_signals; pub use types::{ ConsentContext, ConsentSource, PrivacyFlag, RawConsentSignals, TcfConsent, UsPrivacy, @@ -68,10 +69,15 @@ pub struct ConsentPipelineInput<'a> { pub geo: Option<&'a GeoInfo>, /// EC ID for KV Store consent persistence. /// - /// When set along with `config.consent_store`, enables: + /// When set along with `kv_store`, enables: /// - **Read fallback**: loads consent from KV when cookies are absent. /// - **Write-on-change**: persists cookie-sourced consent to KV. pub ec_id: Option<&'a str>, + /// KV store for consent persistence. + /// + /// `None` when consent persistence is not configured for this request, or + /// when the caller intentionally skips consent KV access. + pub kv_store: Option<&'a dyn crate::platform::PlatformKvStore>, } /// Extracts, decodes, and normalizes consent signals from a request. @@ -516,14 +522,13 @@ fn should_try_kv_fallback(signals: &RawConsentSignals) -> bool { /// Attempts to load consent from the KV Store when cookie signals are empty. /// /// Returns `Some(ConsentContext)` if a valid entry was found and decoded, -/// `None` otherwise. Requires both `consent_store` and `ec_id` to -/// be configured. +/// `None` otherwise. Requires both `kv_store` and `ec_id` to be present. fn try_kv_fallback(input: &ConsentPipelineInput<'_>) -> Option { - let store_name = input.config.consent_store.as_deref()?; + let kv_store = input.kv_store?; let ec_id = input.ec_id?; log::debug!("No cookie consent signals, trying KV fallback for '{ec_id}'"); - let mut ctx = kv::load_consent_from_kv(store_name, ec_id)?; + let mut ctx = kv::load_consent_from_kv(kv_store, ec_id)?; // Re-detect jurisdiction from current geo (may differ from stored value). ctx.jurisdiction = jurisdiction::detect_jurisdiction(input.geo, input.config); @@ -539,14 +544,14 @@ fn try_kv_fallback(input: &ConsentPipelineInput<'_>) -> Option { /// Only writes when consent signals are non-empty and have changed since /// the last write (fingerprint comparison). fn try_kv_write(input: &ConsentPipelineInput<'_>, ctx: &ConsentContext) { - let Some(store_name) = input.config.consent_store.as_deref() else { + let Some(kv_store) = input.kv_store else { return; }; let Some(ec_id) = input.ec_id else { return; }; - kv::save_consent_to_kv(store_name, ec_id, ctx, input.config.max_consent_age_days); + kv::save_consent_to_kv(kv_store, ec_id, ctx, input.config.max_consent_age_days); } // --------------------------------------------------------------------------- @@ -746,6 +751,7 @@ mod tests { config: &config, geo: None, ec_id: None, + kv_store: None, }); assert!( @@ -775,6 +781,7 @@ mod tests { config: &config, geo: None, ec_id: None, + kv_store: None, }); assert!( diff --git a/crates/trusted-server-core/src/platform/mod.rs b/crates/trusted-server-core/src/platform/mod.rs index 9b9da1a32..bab3e0cc8 100644 --- a/crates/trusted-server-core/src/platform/mod.rs +++ b/crates/trusted-server-core/src/platform/mod.rs @@ -36,9 +36,55 @@ pub use types::{ #[cfg(test)] mod tests { + use std::sync::Arc; + use std::time::Duration; + + use async_trait::async_trait; + use bytes::Bytes; + use edgezero_core::key_value_store::KvPage; + use super::test_support::noop_services; use super::*; + struct MarkerKvStore(&'static str); + + #[async_trait(?Send)] + impl PlatformKvStore for MarkerKvStore { + async fn get_bytes(&self, key: &str) -> Result, KvError> { + if key == "marker" { + Ok(Some(Bytes::from(self.0.to_string()))) + } else { + Ok(None) + } + } + + async fn put_bytes(&self, _key: &str, _value: Bytes) -> Result<(), KvError> { + Ok(()) + } + + async fn put_bytes_with_ttl( + &self, + _key: &str, + _value: Bytes, + _ttl: Duration, + ) -> Result<(), KvError> { + Ok(()) + } + + async fn delete(&self, _key: &str) -> Result<(), KvError> { + Ok(()) + } + + async fn list_keys_page( + &self, + _prefix: &str, + _cursor: Option<&str>, + _limit: usize, + ) -> Result { + Ok(KvPage::default()) + } + } + fn _assert_config_store_object_safe(_: &dyn PlatformConfigStore) {} fn _assert_secret_store_object_safe(_: &dyn PlatformSecretStore) {} fn _assert_kv_store_object_safe(_: &dyn PlatformKvStore) {} @@ -79,6 +125,29 @@ mod tests { assert!(result.is_none(), "should return None when no IP is present"); } + #[test] + fn runtime_services_with_kv_store_replaces_only_the_new_clone() { + let services = noop_services(); + let replaced = services + .clone() + .with_kv_store(Arc::new(MarkerKvStore("replaced"))); + + let original_value = futures::executor::block_on(services.kv_store().get_bytes("marker")) + .expect("should query the original noop store"); + let replaced_value = futures::executor::block_on(replaced.kv_store().get_bytes("marker")) + .expect("should query the replaced marker store"); + + assert_eq!( + original_value, None, + "should keep the original RuntimeServices KV store unchanged" + ); + assert_eq!( + replaced_value, + Some(Bytes::from_static(b"replaced")), + "should expose the replacement KV store through kv_store()" + ); + } + #[test] fn platform_pending_request_downcasts_and_preserves_backend_name() { let pending = PlatformPendingRequest::new(7_u8).with_backend_name("origin"); diff --git a/crates/trusted-server-core/src/platform/types.rs b/crates/trusted-server-core/src/platform/types.rs index 0eaa3a0c0..3b17ee3b6 100644 --- a/crates/trusted-server-core/src/platform/types.rs +++ b/crates/trusted-server-core/src/platform/types.rs @@ -134,10 +134,11 @@ pub struct RuntimeServices { pub(crate) config_store: Arc, /// Access to encrypted secret stores. pub(crate) secret_store: Arc, - /// KV store for the primary (opid) store. + /// KV store service selected for the current request path. /// - /// Additional stores (`counter_store`, `creative_store`) are opened on - /// demand in individual handlers until multi-store support lands here. + /// Adapters may replace this with a different concrete store on a + /// per-request basis by cloning [`RuntimeServices`] with + /// [`RuntimeServices::with_kv_store`]. pub(crate) kv_store: Arc, /// Dynamic backend registration and name prediction. pub(crate) backend: Arc, @@ -186,6 +187,12 @@ impl RuntimeServices { &*self.secret_store } + /// Returns the KV store service. + #[must_use] + pub fn kv_store(&self) -> &dyn PlatformKvStore { + &*self.kv_store + } + /// Returns the dynamic backend service. #[must_use] pub fn backend(&self) -> &dyn PlatformBackend { @@ -216,6 +223,19 @@ impl RuntimeServices { pub fn kv_handle(&self) -> super::KvHandle { super::KvHandle::new(self.kv_store.clone()) } + + /// Returns a clone of this instance with the KV store replaced by `store`. + /// + /// Adapters use this to lazily inject the request-specific KV store for + /// handlers that require one without rebuilding the rest of the runtime + /// services graph. + #[must_use] + pub fn with_kv_store(self, store: Arc) -> Self { + Self { + kv_store: store, + ..self + } + } } impl fmt::Debug for RuntimeServices { diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 7f35bb952..a642c60f9 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -10,6 +10,7 @@ use crate::edge_cookie::get_or_generate_ec_id; use crate::error::TrustedServerError; use crate::http_util::{serve_static_with_etag, RequestInfo}; use crate::integrations::IntegrationRegistry; +use crate::platform::RuntimeServices; use crate::rsc_flight::RscFlightUrlRewriter; use crate::settings::Settings; use crate::streaming_processor::{Compression, PipelineConfig, StreamProcessor, StreamingPipeline}; @@ -289,6 +290,7 @@ fn create_html_stream_processor( pub fn handle_publisher_request( settings: &Settings, integration_registry: &IntegrationRegistry, + services: &RuntimeServices, mut req: Request, ) -> Result> { log::debug!("Proxying request to publisher_origin"); @@ -339,6 +341,11 @@ pub fn handle_publisher_request( config: &settings.consent, geo: geo.as_ref(), ec_id: Some(ec_id.as_str()), + kv_store: settings + .consent + .consent_store + .as_deref() + .map(|_| services.kv_store()), }); let ec_allowed = allows_ec_creation(&consent_context); log::trace!("Proxy EC ID: {}, ec_allowed: {}", ec_id, ec_allowed); @@ -457,8 +464,8 @@ pub fn handle_publisher_request( consent_context.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); + if settings.consent.consent_store.is_some() { + crate::consent::kv::delete_consent_from_kv(services.kv_store(), cookie_ec_id); } } else { log::debug!( diff --git a/crates/trusted-server-core/src/consent/kv.rs b/crates/trusted-server-core/src/storage/kv_store.rs similarity index 65% rename from crates/trusted-server-core/src/consent/kv.rs rename to crates/trusted-server-core/src/storage/kv_store.rs index 9aa513492..c118005a4 100644 --- a/crates/trusted-server-core/src/consent/kv.rs +++ b/crates/trusted-server-core/src/storage/kv_store.rs @@ -1,28 +1,29 @@ //! KV Store consent persistence. //! -//! Stores and retrieves consent data from a Fastly KV Store, keyed by -//! EC ID. This provides consent continuity for returning users -//! whose browsers may not have consent cookies on every request. +//! Stores and retrieves consent data from a KV Store, keyed by EC ID. This +//! provides consent continuity for returning users whose browsers may not +//! have consent cookies on every request. //! //! # Storage layout //! -//! Each entry uses: -//! - **Body** ([`KvConsentEntry`]) — JSON with raw consent strings and context. -//! - **Metadata** ([`ConsentKvMetadata`]) — compact JSON summary for fast -//! consent status checks and change detection (max 2000 bytes). +//! Each entry uses a single JSON body ([`KvConsentEntry`]) containing the raw +//! consent strings, context flags, and a fingerprint for write-on-change +//! detection. //! //! # Change detection //! //! Writes only occur when consent signals have actually changed. //! [`consent_fingerprint`] hashes the raw strings into a compact fingerprint -//! stored in metadata. On the next request, the existing fingerprint is -//! compared before writing. +//! stored in the body's `fp` field. On the next request, the existing +//! fingerprint is compared before writing. +use bytes::Bytes; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use super::jurisdiction::Jurisdiction; -use super::types::{ConsentContext, ConsentSource}; +use crate::consent::jurisdiction::Jurisdiction; +use crate::consent::types::{ConsentContext, ConsentSource}; +use crate::platform::PlatformKvStore; // --------------------------------------------------------------------------- // KV body (JSON, stored as value) @@ -33,8 +34,20 @@ use super::types::{ConsentContext, ConsentSource}; /// Contains the raw consent strings needed to reconstruct a [`ConsentContext`]. /// Decoded data (TCF, GPP, US Privacy) is not stored — it is re-decoded on /// read to avoid stale decoded state. +/// +/// The `fp` field holds the consent fingerprint for write-on-change detection. +/// Entries written before PR5 lack this field; `#[serde(default)]` treats them +/// as having an empty fingerprint, which always triggers a self-healing +/// re-write. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KvConsentEntry { + /// Fingerprint of consent signals for write-on-change detection. + /// + /// Written by [`save_consent_to_kv`]. Entries written before PR5 lack + /// this field; `#[serde(default)]` treats them as having an empty + /// fingerprint, which always triggers a self-healing re-write. + #[serde(default)] + pub fp: String, /// Raw TC String from `euconsent-v2` cookie. #[serde(skip_serializing_if = "Option::is_none")] pub raw_tc_string: Option, @@ -62,31 +75,6 @@ pub struct KvConsentEntry { pub stored_at_ds: u64, } -// --------------------------------------------------------------------------- -// KV metadata (compact JSON, max 2000 bytes) -// --------------------------------------------------------------------------- - -/// Compact consent summary stored in KV Store metadata. -/// -/// Used for fast consent status checks without reading the full body, -/// and for change detection via the `fingerprint` field. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ConsentKvMetadata { - /// SHA-256 fingerprint (first 16 hex chars) of all raw consent strings. - /// - /// Used for write-on-change detection. If the fingerprint matches the - /// current request's consent signals, no write is needed. - pub fp: String, - /// Whether GDPR applies. - pub gdpr: bool, - /// Whether GPC is set. - pub gpc: bool, - /// Whether a US Privacy string is present. - pub usp: bool, - /// Whether a TCF string is present. - pub tcf: bool, -} - // --------------------------------------------------------------------------- // Conversions // --------------------------------------------------------------------------- @@ -94,10 +82,12 @@ pub struct ConsentKvMetadata { /// Builds a [`KvConsentEntry`] from a [`ConsentContext`]. /// /// Captures only the raw strings and contextual flags. Decoded data is -/// intentionally omitted — it will be re-decoded on read. +/// intentionally omitted — it will be re-decoded on read. The `fp` field is +/// initialized to an empty string and must be set by the caller before writing. #[must_use] pub fn entry_from_context(ctx: &ConsentContext, now_ds: u64) -> KvConsentEntry { KvConsentEntry { + fp: String::new(), raw_tc_string: ctx.raw_tc_string.clone(), raw_gpp_string: ctx.raw_gpp_string.clone(), gpp_section_ids: ctx.gpp_section_ids.clone(), @@ -110,23 +100,11 @@ pub fn entry_from_context(ctx: &ConsentContext, now_ds: u64) -> KvConsentEntry { } } -/// Builds a [`ConsentKvMetadata`] from a [`ConsentContext`]. -#[must_use] -pub fn metadata_from_context(ctx: &ConsentContext) -> ConsentKvMetadata { - ConsentKvMetadata { - fp: consent_fingerprint(ctx), - gdpr: ctx.gdpr_applies, - gpc: ctx.gpc, - usp: ctx.raw_us_privacy.is_some(), - tcf: ctx.raw_tc_string.is_some(), - } -} - -/// Converts a [`KvConsentEntry`] into [`super::types::RawConsentSignals`] -/// suitable for re-decoding via [`super::build_context_from_signals`]. +/// Converts a [`KvConsentEntry`] into [`crate::consent::types::RawConsentSignals`] +/// suitable for re-decoding via [`crate::consent::build_context_from_signals`]. #[must_use] -pub fn signals_from_entry(entry: &KvConsentEntry) -> super::types::RawConsentSignals { - super::types::RawConsentSignals { +pub fn signals_from_entry(entry: &KvConsentEntry) -> crate::consent::types::RawConsentSignals { + crate::consent::types::RawConsentSignals { raw_tc_string: entry.raw_tc_string.clone(), raw_gpp_string: entry.raw_gpp_string.clone(), raw_gpp_sid: entry.gpp_section_ids.as_ref().map(|ids| { @@ -148,7 +126,7 @@ pub fn signals_from_entry(entry: &KvConsentEntry) -> super::types::RawConsentSig #[must_use] pub fn context_from_entry(entry: &KvConsentEntry) -> ConsentContext { let signals = signals_from_entry(entry); - let mut ctx = super::build_context_from_signals(&signals); + let mut ctx = crate::consent::build_context_from_signals(&signals); // Restore context fields that aren't derived from raw signals. ctx.gdpr_applies = entry.gdpr_applies; @@ -223,66 +201,51 @@ fn parse_jurisdiction(s: &str) -> Jurisdiction { // KV Store operations // --------------------------------------------------------------------------- -/// Opens a Fastly KV Store by name, logging a warning on failure. -/// -/// Returns [`None`] if the store does not exist or cannot be opened. -fn open_store(store_name: &str) -> Option { - match fastly::kv_store::KVStore::open(store_name) { - Ok(Some(store)) => Some(store), - Ok(None) => { - log::warn!("Consent KV store '{store_name}' not found"); - None - } - Err(e) => { - log::warn!("Failed to open consent KV store '{store_name}': {e}"); - None - } - } -} - /// Checks whether the stored consent fingerprint matches the current one. /// -/// Returns `true` when the stored metadata fingerprint equals `new_fp`, -/// meaning no write is needed. +/// Returns `true` when the stored body's `fp` field equals `new_fp`, meaning +/// no write is needed. Returns `false` when the key is absent, the body +/// cannot be deserialized, or the fingerprint differs. /// -/// Entries written by older code versions may lack metadata, in which case -/// this returns `false` and the entry will be unconditionally re-written -/// with the current fingerprint (self-healing migration). -fn fingerprint_unchanged(store: &fastly::kv_store::KVStore, ec_id: &str, new_fp: &str) -> bool { - let stored_fp = store - .lookup(ec_id) - .ok() - .and_then(|resp| resp.metadata()) - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .map(|meta| meta.fp); - - stored_fp.as_deref() == Some(new_fp) +/// Entries written before PR5 have an empty `fp` (via `#[serde(default)]`), +/// which never matches a computed fingerprint and triggers a self-healing +/// re-write. +fn fingerprint_unchanged(store: &dyn PlatformKvStore, key: &str, new_fp: &str) -> bool { + let bytes = match futures::executor::block_on(store.get_bytes(key)) { + Ok(Some(bytes)) => bytes, + _ => return false, + }; + + serde_json::from_slice::(&bytes) + .map(|entry| entry.fp == new_fp) + .unwrap_or(false) } -/// Loads consent data from the KV Store for a given EC ID. +/// Loads consent data from the KV store for a given EC ID. /// /// Returns `Some(ConsentContext)` if a valid entry is found, [`None`] if the /// key does not exist or deserialization fails. Errors are logged but never -/// propagated — KV Store failures must not break the request pipeline. +/// propagated — KV failures must not break the request pipeline. /// /// # Arguments /// -/// * `store_name` — The KV Store name (from `consent.consent_store` config). -/// * `ec_id` — The EC ID used as the KV Store key. +/// * `store` — KV store opened by the adapter. +/// * `ec_id` — Edge Cookie ID used as the KV key. #[must_use] -pub fn load_consent_from_kv(store_name: &str, ec_id: &str) -> Option { - let store = open_store(store_name)?; - - let mut response = match store.lookup(ec_id) { - Ok(resp) => resp, +pub fn load_consent_from_kv(store: &dyn PlatformKvStore, ec_id: &str) -> Option { + let bytes = match futures::executor::block_on(store.get_bytes(ec_id)) { + Ok(Some(bytes)) => bytes, + Ok(None) => { + log::debug!("Consent KV lookup miss for '{ec_id}'"); + return None; + } Err(e) => { - log::debug!("Consent KV lookup miss for '{ec_id}': {e}"); + log::debug!("Consent KV lookup error for '{ec_id}': {e}"); return None; } }; - let body_bytes = response.take_body_bytes(); - match serde_json::from_slice::(&body_bytes) { + match serde_json::from_slice::(&bytes) { Ok(entry) => { log::info!( "Loaded consent from KV store for '{ec_id}' (stored_at_ds={})", @@ -297,62 +260,52 @@ pub fn load_consent_from_kv(store_name: &str, ec_id: &str) -> Option Bytes::from(body), + Err(e) => { + log::warn!("Failed to serialize consent entry for '{ec_id}': {e}"); + return; + } }; let ttl = std::time::Duration::from_secs(u64::from(max_age_days) * 86_400); - match store - .build_insert() - .metadata(&meta_str) - .time_to_live(ttl) - .execute(ec_id, body) - { + match futures::executor::block_on(store.put_bytes_with_ttl(ec_id, body, ttl)) { Ok(()) => { - log::info!( - "Saved consent to KV store for '{ec_id}' (fp={}, ttl={max_age_days}d)", - metadata.fp - ); + log::info!("Saved consent to KV store for '{ec_id}' (fp={fp}, ttl={max_age_days}d)"); } Err(e) => { log::warn!("Failed to write consent to KV store for '{ec_id}': {e}"); @@ -360,19 +313,15 @@ pub fn save_consent_to_kv(store_name: &str, ec_id: &str, ctx: &ConsentContext, m } } -/// Deletes a consent entry from the KV Store for a given EC ID. +/// Deletes a consent entry from the KV store for a given EC ID. /// /// Used when a user revokes consent — the existing EC cookie is being /// expired, so the persisted consent data must also be removed. /// -/// Errors are logged but never propagated — KV Store failures must not +/// Errors are logged but never propagated — KV failures must not /// break the request pipeline. -pub fn delete_consent_from_kv(store_name: &str, ec_id: &str) { - let Some(store) = open_store(store_name) else { - return; - }; - - match store.delete(ec_id) { +pub fn delete_consent_from_kv(store: &dyn PlatformKvStore, ec_id: &str) { + match futures::executor::block_on(store.delete(ec_id)) { Ok(()) => { log::info!("Deleted consent KV entry for '{ec_id}' (consent revoked)"); } @@ -386,30 +335,31 @@ pub fn delete_consent_from_kv(store_name: &str, ec_id: &str) { // Tests // --------------------------------------------------------------------------- +#[cfg(test)] +fn make_test_context() -> ConsentContext { + ConsentContext { + raw_tc_string: Some("CPXxGfAPXxGfA".to_owned()), + raw_gpp_string: Some("DBACNYA~CPXxGfA".to_owned()), + gpp_section_ids: Some(vec![2, 6]), + raw_us_privacy: Some("1YNN".to_owned()), + raw_ac_string: None, + gdpr_applies: true, + tcf: None, + gpp: None, + us_privacy: None, + expired: false, + gpc: false, + jurisdiction: Jurisdiction::Gdpr, + source: ConsentSource::Cookie, + } +} + #[cfg(test)] mod tests { use super::*; use crate::consent::jurisdiction::Jurisdiction; use crate::consent::types::{ConsentContext, ConsentSource}; - fn make_test_context() -> ConsentContext { - ConsentContext { - raw_tc_string: Some("CPXxGfAPXxGfA".to_owned()), - raw_gpp_string: Some("DBACNYA~CPXxGfA".to_owned()), - gpp_section_ids: Some(vec![2, 6]), - raw_us_privacy: Some("1YNN".to_owned()), - raw_ac_string: None, - gdpr_applies: true, - tcf: None, - gpp: None, - us_privacy: None, - expired: false, - gpc: false, - jurisdiction: Jurisdiction::Gdpr, - source: ConsentSource::Cookie, - } - } - #[test] fn entry_roundtrip() { let ctx = make_test_context(); @@ -428,27 +378,29 @@ mod tests { } #[test] - fn metadata_roundtrip() { + fn kv_consent_entry_roundtrip_preserves_fp() { let ctx = make_test_context(); - let meta = metadata_from_context(&ctx); - let json = serde_json::to_string(&meta).expect("should serialize"); - let restored: ConsentKvMetadata = serde_json::from_str(&json).expect("should deserialize"); - - assert_eq!(restored.fp, meta.fp); - assert!(restored.gdpr); - assert!(!restored.gpc); - assert!(restored.usp); - assert!(restored.tcf); + let fp = consent_fingerprint(&ctx); + let mut entry = entry_from_context(&ctx, 1_000_000); + entry.fp = fp.clone(); + let json = serde_json::to_string(&entry).expect("should serialize"); + let restored: KvConsentEntry = serde_json::from_str(&json).expect("should deserialize"); + + assert_eq!( + restored.fp, fp, + "should preserve fingerprint through roundtrip" + ); } #[test] - fn metadata_fits_in_2000_bytes() { + fn entry_fits_in_2000_bytes() { let ctx = make_test_context(); - let meta = metadata_from_context(&ctx); - let json = serde_json::to_string(&meta).expect("should serialize"); + let mut entry = entry_from_context(&ctx, 1_000_000); + entry.fp = consent_fingerprint(&ctx); + let json = serde_json::to_string(&entry).expect("should serialize"); assert!( json.len() <= 2000, - "metadata JSON must fit in 2000 bytes, was {} bytes", + "entry JSON must fit in 2000 bytes, was {} bytes", json.len() ); } @@ -577,3 +529,42 @@ mod tests { ); } } + +#[cfg(test)] +mod new_api_tests { + use super::*; + use edgezero_core::key_value_store::NoopKvStore; + + fn noop() -> NoopKvStore { + NoopKvStore + } + + #[test] + fn load_returns_none_when_key_absent() { + let result = load_consent_from_kv(&noop(), "some-ec-id"); + assert!(result.is_none(), "should return None when key is absent"); + } + + #[test] + fn save_does_not_panic_with_noop_store() { + let ctx = make_test_context(); + save_consent_to_kv(&noop(), "some-ec-id", &ctx, 30); + } + + #[test] + fn delete_does_not_panic_with_noop_store() { + delete_consent_from_kv(&noop(), "some-ec-id"); + } + + #[test] + fn kv_consent_entry_missing_fp_deserialises_as_empty() { + let json = r#"{"gdpr_applies":true,"gpc":false,"jurisdiction":"GDPR","stored_at_ds":0}"#; + let entry: KvConsentEntry = + serde_json::from_str(json).expect("should deserialize legacy entry"); + assert_eq!( + entry.fp, + String::new(), + "should default fp to empty string for legacy entries" + ); + } +} diff --git a/crates/trusted-server-core/src/storage/mod.rs b/crates/trusted-server-core/src/storage/mod.rs index 6010ae2e4..ed5ff1ff5 100644 --- a/crates/trusted-server-core/src/storage/mod.rs +++ b/crates/trusted-server-core/src/storage/mod.rs @@ -1,13 +1,15 @@ -//! Legacy Fastly-backed store types. +//! Store helpers and legacy Fastly-backed store types. //! -//! These types predate the [`crate::platform`] abstraction and will be removed -//! once all call sites have migrated to the platform traits. New code should -//! use [`crate::platform::PlatformConfigStore`], +//! The Fastly config/secret store types predate the [`crate::platform`] +//! abstraction and will be removed once all call sites have migrated to the +//! platform traits. New code should use +//! [`crate::platform::PlatformConfigStore`], //! [`crate::platform::PlatformSecretStore`], and the management write methods //! via [`crate::platform::RuntimeServices`]. pub(crate) mod api_client; pub(crate) mod config_store; +pub mod kv_store; pub(crate) mod secret_store; pub use api_client::FastlyApiClient; diff --git a/docs/superpowers/specs/2026-03-19-edgezero-migration-design.md b/docs/superpowers/specs/2026-03-19-edgezero-migration-design.md index 894c423e6..0ae8c906b 100644 --- a/docs/superpowers/specs/2026-03-19-edgezero-migration-design.md +++ b/docs/superpowers/specs/2026-03-19-edgezero-migration-design.md @@ -145,10 +145,10 @@ files in `crates/trusted-server-core` and `crates/trusted-server-adapter-fastly` These are close to landing and unblock early migration phases. -| Feature | Issues | PR | Notes | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | ------------------------------------------------ | -| KV Store Abstraction | [#43-50](https://github.com/stackpop/edgezero/issues?q=is%3Aissue%20id%3A%2043%2044%2045%2046%2047%2048%2049%2050) | [#165](https://github.com/stackpop/edgezero/pull/165) | Covers counter_store, opid_store, creative_store | -| Config Store Abstraction | [#51-58](https://github.com/stackpop/edgezero/issues?q=is%3Aissue%20id%3A%2051%2052%2053%2054%2055%2056%2057%2058) | [#209](https://github.com/stackpop/edgezero/pull/209) | Covers jwks_store, runtime config | +| Feature | Issues | PR | Notes | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | ---------------------------------------------------------------------------- | +| KV Store Abstraction | [#43-50](https://github.com/stackpop/edgezero/issues?q=is%3Aissue%20id%3A%2043%2044%2045%2046%2047%2048%2049%2050) | [#165](https://github.com/stackpop/edgezero/pull/165) | Covers the generic runtime KV abstraction used by current live KV call sites | +| Config Store Abstraction | [#51-58](https://github.com/stackpop/edgezero/issues?q=is%3Aissue%20id%3A%2051%2052%2053%2054%2055%2056%2057%2058) | [#209](https://github.com/stackpop/edgezero/pull/209) | Covers jwks_store, runtime config | > **Note:** EdgeZero Config Store and Secret Store provide **read** interfaces. > Management write CRUD (put/delete for config, create/delete for secrets) is a @@ -869,7 +869,10 @@ Changes: - Implement `PlatformKvStore` for Fastly using direct KV store access - Replace direct KV usage in core with calls through `RuntimeServices::kv_store` -- Migrate counter_store, opid_store, creative_store access +- Migrate the remaining live core KV access through the generic runtime KV + slot (currently consent persistence) +- `counter_store` and `opid_store` no longer have live runtime access paths + in the current repo; `creative_store` remains a deprecated config field - Update tests #### PR 6 — Extract backend creation and HTTP client behind traits