Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions crates/trusted-server-adapter-axum/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -522,16 +522,13 @@ impl PlatformHttpClient for AxumPlatformHttpClient {
///
/// # Degraded features in dev
///
/// KV store is [`trusted_server_core::platform::UnavailableKvStore`] — any route
/// touching synthetic-ID or consent KV will degrade gracefully. A `warn` log is
/// The generic runtime KV slot uses
/// [`trusted_server_core::platform::UnavailableKvStore`]. A `warn` log is
/// emitted once per process.
pub fn build_runtime_services(ctx: &edgezero_core::context::RequestContext) -> RuntimeServices {
static KV_WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
KV_WARNED.get_or_init(|| {
log::warn!(
"Axum dev server: KV store is unavailable (UnavailableKvStore). \
Routes that depend on synthetic-ID or consent KV will degrade gracefully."
);
log::warn!("Axum dev server: generic runtime KV is unavailable (UnavailableKvStore).");
});

let client_ip = edgezero_adapter_axum::context::AxumRequestContext::get(ctx.request())
Expand Down
233 changes: 36 additions & 197 deletions crates/trusted-server-adapter-fastly/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ use trusted_server_core::tester_cookie::{handle_clear_tester, handle_set_tester}

use crate::middleware::{AuthMiddleware, FinalizeResponseMiddleware};
use crate::platform::{
open_kv_store, FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo,
FastlyPlatformHttpClient, FastlyPlatformSecretStore, UnavailableKvStore,
FastlyPlatformBackend, FastlyPlatformConfigStore, FastlyPlatformGeo, FastlyPlatformHttpClient,
FastlyPlatformSecretStore, UnavailableKvStore,
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -195,36 +195,6 @@ fn warn_if_certificate_check_disabled(settings: &Settings) {
}
}

/// Resolves per-request consent KV store services for routes that read consent data.
///
/// When `settings.consent.consent_store` is configured and the named KV store cannot
/// be opened, returns `Err` so the caller can respond with 503 (fail-closed). This is
/// intentional hardening over the legacy `route_request` path, which builds
/// `runtime_services` with `UnavailableKvStore` and never opens the named consent
/// store, so it never fails closed — the `EdgeZero` path instead makes consent-dependent
/// routes unavailable rather than proceeding without consent.
///
/// # Errors
///
/// Returns an error when the configured consent store cannot be opened.
pub(crate) fn runtime_services_for_consent_route(
settings: &Settings,
runtime_services: &RuntimeServices,
) -> Result<RuntimeServices, Report<TrustedServerError>> {
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(),
})
})
}

// ---------------------------------------------------------------------------
// Per-request RuntimeServices
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -589,10 +559,6 @@ async fn run_named_route(
NamedRouteHandler::SetTester => handle_set_tester(&state.settings),
NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings),
NamedRouteHandler::Auction => {
// The auction reads consent data, so the consent KV store must be
// available — fail closed with 503 when it is configured but
// cannot be opened, matching legacy behavior.
let consent_services = runtime_services_for_consent_route(&state.settings, services)?;
let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?;
let registry_ref = if partner_registry.is_empty() {
None
Expand All @@ -605,7 +571,7 @@ async fn run_named_route(
ec.kv_graph.as_ref(),
registry_ref,
&ec.ec_context,
&consent_services,
services,
req,
)
.await
Expand All @@ -617,10 +583,6 @@ async fn run_named_route(
if req.method() == Method::OPTIONS {
return Ok(page_bids_preflight_denied());
}
// Like the auction, page-bids reads consent data, so the consent KV
// store must be available — fail closed with 503 when configured but
// unopenable, matching legacy.
let consent_services = runtime_services_for_consent_route(&state.settings, services)?;
let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?;
let registry_ref = if partner_registry.is_empty() {
None
Expand All @@ -634,7 +596,7 @@ async fn run_named_route(
};
handle_page_bids(
&state.settings,
&consent_services,
services,
ec.kv_graph.as_ref(),
auction,
&ec.ec_context,
Expand Down Expand Up @@ -768,49 +730,41 @@ async fn dispatch_fallback(
}
}

// Publisher pages read consent data, so the consent KV store must be
// available — fail closed with 503 when it is configured but cannot
// be opened, matching legacy behavior.
match runtime_services_for_consent_route(&state.settings, services) {
Ok(publisher_services) => {
// Run the server-side auction with the configured creative-
// opportunity slots and collect the dispatched bids in the
// buffered finalize (`buffer_publisher_response_async`), matching
// the legacy streaming path. `handle_publisher_request` matches the
// slots against the request path. The partner registry plus the
// EC identity-graph KV (`ec.kv_graph`) enrich the bid request with
// server-side EIDs, same as the legacy auction.
let slots = state.settings.creative_opportunity_slots();
match PartnerRegistry::from_config(&state.settings.ec.partners) {
Ok(partner_registry) => {
let auction = AuctionDispatch {
orchestrator: &state.orchestrator,
slots,
registry: Some(&partner_registry),
};
match handle_publisher_request(
// Run the server-side auction with the configured creative-
// opportunity slots and collect the dispatched bids in the
// buffered finalize (`buffer_publisher_response_async`), matching
// the legacy streaming path. `handle_publisher_request` matches the
// slots against the request path. The partner registry plus the
// EC identity-graph KV (`ec.kv_graph`) enrich the bid request with
// server-side EIDs, same as the legacy auction.
let slots = state.settings.creative_opportunity_slots();
match PartnerRegistry::from_config(&state.settings.ec.partners) {
Ok(partner_registry) => {
let auction = AuctionDispatch {
orchestrator: &state.orchestrator,
slots,
registry: Some(&partner_registry),
};
match handle_publisher_request(
&state.settings,
services,
ec.kv_graph.as_ref(),
&mut ec.ec_context,
auction,
req,
)
.await
{
Ok(pub_response) => {
buffer_publisher_response_async(
pub_response,
&method,
&state.settings,
&publisher_services,
ec.kv_graph.as_ref(),
&mut ec.ec_context,
auction,
req,
&state.registry,
&state.orchestrator,
services,
)
.await
{
Ok(pub_response) => {
buffer_publisher_response_async(
pub_response,
&method,
&state.settings,
&state.registry,
&state.orchestrator,
&publisher_services,
)
.await
}
Err(e) => Err(e),
}
}
Err(e) => Err(e),
}
Expand Down Expand Up @@ -1225,7 +1179,6 @@ mod tests {

use error_stack::Report;
use futures::executor::block_on;
use serde_json::json;
use trusted_server_core::constants::HEADER_X_GEO_INFO_AVAILABLE;
use trusted_server_core::ec::device::DeviceSignals;
use trusted_server_core::error::TrustedServerError;
Expand All @@ -1240,51 +1193,6 @@ mod tests {
};
use trusted_server_core::settings::Settings;

fn settings_with_missing_consent_store() -> Settings {
Settings::from_toml(
r#"
[[handlers]]
path = "^/(_ts/)?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"

[proxy]
allowed_domains = ["*.example", "*.example.com"]

[ec]
passphrase = "test-passphrase-at-least-32-bytes!!"

[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"
external_bundle_url = "https://assets.example/prebid/trusted-prebid.js"

[integrations.datadome]
enabled = true

[auction]
enabled = true
providers = ["prebid"]
timeout_ms = 2000
"#,
)
.expect("should parse EdgeZero app test settings")
}

fn app_state_for_settings(settings: Settings) -> Arc<AppState> {
build_state_from_settings(settings).expect("should build app state from settings")
}
Expand Down Expand Up @@ -1979,27 +1887,6 @@ mod tests {
);
}

#[test]
fn dispatch_auction_with_missing_consent_store_returns_503() {
let state = app_state_for_settings(settings_with_missing_consent_store());
let router = TrustedServerApp::routes_for_state(&state);
let body = json!({ "adUnits": [] }).to_string();
let req = request_builder()
.method(Method::POST)
.uri("/auction")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.expect("should build auction request");

let response = route(&router, req);

assert_eq!(
response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"auction route should fail closed when configured consent store cannot be opened"
);
}

#[test]
fn dispatch_unregistered_method_returns_405_at_router_level() {
// Documents the known router-level behavior for verbs outside the
Expand Down Expand Up @@ -2031,54 +1918,6 @@ mod tests {
);
}

#[test]
fn edgezero_missing_consent_store_breaks_only_consent_routes() {
let state = app_state_for_settings(settings_with_missing_consent_store());
let router = TrustedServerApp::routes_for_state(&state);

let admin_response = route(
&router,
empty_request(Method::POST, "/_ts/admin/keys/rotate"),
);
assert_eq!(
admin_response.status(),
StatusCode::UNAUTHORIZED,
"admin auth behavior should not depend on consent KV availability"
);

let auction_request = request_builder()
.method(Method::POST)
.uri("/auction")
.body(Body::from(r#"{"adUnits":[]}"#))
.expect("should build auction request");
let auction_response = route(&router, auction_request);
assert_eq!(
auction_response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"auction should fail closed when configured consent KV cannot be opened"
);

let publisher_response = route(&router, empty_request(Method::GET, "/articles/example"));
assert_eq!(
publisher_response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"publisher fallback should fail closed when configured consent KV cannot be opened"
);

// Integration routes must NOT require the consent KV — runtime_services_for_consent_route
// is wired only into the publisher and auction branches of dispatch_fallback, not into
// the integration proxy branch. A missing consent store must not 503 integration routes.
let integration_response = route(
&router,
empty_request(Method::GET, "/integrations/datadome/tags.js"),
);
assert_ne!(
integration_response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"integration routes should be unaffected by a missing consent KV store"
);
}

#[test]
fn dispatch_fallback_asset_route_skips_ec_finalization() {
// Parity guard for the configured asset-route fallback: a GET matching a
Expand Down
22 changes: 4 additions & 18 deletions crates/trusted-server-adapter-fastly/src/platform.rs
Original file line number Diff line number Diff line change
@@ -1,25 +1,21 @@
//! Fastly-backed implementations of the platform traits defined in
//! `trusted-server-core::platform`.

use std::io::Read as _;
use std::net::IpAddr;
use std::sync::Arc;

use bytes::Bytes;
use edgezero_adapter_fastly::key_value_store::FastlyKvStore;
use edgezero_core::key_value_store::KvError;
use error_stack::{Report, ResultExt};
use fastly::geo::{geo_lookup, Geo};
use fastly::{ConfigStore, Request, SecretStore};
use std::io::Read as _;
use std::net::IpAddr;

use crate::backend::BackendConfig;
pub(crate) use trusted_server_core::platform::UnavailableKvStore;
use trusted_server_core::platform::{
ClientInfo, GeoInfo, PlatformBackend, PlatformBackendSpec, PlatformConfigStore, PlatformError,
PlatformGeo, PlatformHttpClient, PlatformHttpRequest, PlatformImageOptimizerCrop,
PlatformImageOptimizerCropMode, PlatformImageOptimizerOptions, PlatformImageOptimizerParams,
PlatformImageOptimizerRegion, PlatformKvStore, PlatformPendingRequest, PlatformResponse,
PlatformSecretStore, PlatformSelectResult, StoreId, StoreName,
PlatformImageOptimizerRegion, PlatformPendingRequest, PlatformResponse, PlatformSecretStore,
PlatformSelectResult, StoreId, StoreName,
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -583,16 +579,6 @@ pub fn client_info_from_request(req: &Request) -> ClientInfo {
}
}

/// Open a named KV store as a [`PlatformKvStore`] implementation.
///
/// # Errors
///
/// Returns [`KvError::Unavailable`] when the store does not exist, or
/// [`KvError::Internal`] when the Fastly SDK fails to open it.
pub fn open_kv_store(store_name: &str) -> Result<Arc<dyn PlatformKvStore>, KvError> {
FastlyKvStore::open(store_name).map(|store| Arc::new(store) as Arc<dyn PlatformKvStore>)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
Expand Down
11 changes: 3 additions & 8 deletions crates/trusted-server-adapter-spin/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,9 @@ fn spin_secret_variable_name(
///
/// Delegates all operations through `KvHandle`'s raw-bytes API. Spin KV has no
/// native TTL support, so [`put_bytes_with_ttl`](KvStore::put_bytes_with_ttl)
/// *errors* (`KvError::Validation`) rather than silently writing a non-expiring
/// record — the privacy-safe failure mode. Consequently TTL-backed consent
/// persistence (`save_consent_to_kv`) is unavailable on Spin: each write returns
/// the error, which the core caller logs and treats as non-fatal (consistent
/// with all adapters — failing to persist consent never breaks the request, and
/// not persisting is the safe direction). Operators configuring
/// `settings.consent.consent_store` on Spin should expect stored-consent
/// fallback not to function.
/// returns `KvError::Validation` rather than silently writing a non-expiring
/// record. Callers of the generic platform KV interface must handle that
/// capability difference explicitly.
struct KvHandleAdapter(KvHandle);

#[async_trait::async_trait(?Send)]
Expand Down
Loading
Loading